From 5b1314b0af7d3a7e12d43c7008fb9d02b655d691 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 25 May 2026 11:17:24 +0200 Subject: [PATCH 01/42] chore: convert to cargo workspace with four crates --- Cargo.lock | 1140 +++++++++++++++++++++++++++++++ Cargo.toml | 39 +- crates/xy-ipc/Cargo.toml | 16 + crates/xy-ipc/src/lib.rs | 1 + crates/xy-protocol/Cargo.toml | 16 + crates/xy-protocol/src/lib.rs | 1 + crates/xy-supervisor/Cargo.toml | 17 + crates/xy-supervisor/src/lib.rs | 1 + crates/xy/Cargo.toml | 27 + crates/xy/src/main.rs | 3 + src/main.rs | 3 - 11 files changed, 1256 insertions(+), 8 deletions(-) create mode 100644 Cargo.lock create mode 100644 crates/xy-ipc/Cargo.toml create mode 100644 crates/xy-ipc/src/lib.rs create mode 100644 crates/xy-protocol/Cargo.toml create mode 100644 crates/xy-protocol/src/lib.rs create mode 100644 crates/xy-supervisor/Cargo.toml create mode 100644 crates/xy-supervisor/src/lib.rs create mode 100644 crates/xy/Cargo.toml create mode 100644 crates/xy/src/main.rs delete mode 100644 src/main.rs diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..862d127 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1140 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26c7b13d0780cb82722fd59f6f57f925e143427e4a75313a6c77243bf5326ae6" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.59.0", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "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]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "humantime-serde" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57a3db5ea5923d99402c94e9feb261dc5ee9b4efa158b0315f788cf549cc200c" +dependencies = [ + "humantime", + "serde", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "kdl" +version = "6.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81a29e7b50079ff44549f68c0becb1c73d7f6de2a4ea952da77966daf3d4761e" +dependencies = [ + "miette", + "num", + "winnow", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "unicode-width", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "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]] +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]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "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]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-test" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6d24790a10a7af737693a3e8f1d03faef7e6ca0cc99aae5066f533766de545" +dependencies = [ + "futures-core", + "tokio", + "tokio-stream", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +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]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.6.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d71a593cc5c42ad7876e2c1fda56f314f3754c084128833e64f1345ff8a03a" +dependencies = [ + "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]] +name = "xy" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "etcetera", + "humantime", + "nix", + "serde", + "serde_json", + "tempfile", + "tokio", + "tracing", + "tracing-subscriber", + "xy-ipc", + "xy-protocol", + "xy-supervisor", +] + +[[package]] +name = "xy-ipc" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "tempfile", + "thiserror", + "tokio", + "tracing", + "xy-protocol", +] + +[[package]] +name = "xy-protocol" +version = "0.1.0" +dependencies = [ + "humantime", + "humantime-serde", + "kdl", + "serde", + "serde_json", + "tempfile", + "thiserror", +] + +[[package]] +name = "xy-supervisor" +version = "0.1.0" +dependencies = [ + "async-trait", + "nix", + "tempfile", + "thiserror", + "tokio", + "tokio-test", + "tracing", + "xy-protocol", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 955634f..d6abd6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,35 @@ -[package] -name = "xy" -version = "0.1.0" -edition = "2024" +[workspace] +resolver = "2" +members = [ + "crates/xy-protocol", + "crates/xy-supervisor", + "crates/xy-ipc", + "crates/xy", +] -[dependencies] +[workspace.package] +edition = "2024" +version = "0.1.0" +license = "MIT OR Apache-2.0" + +[workspace.dependencies] +xy-protocol = { path = "crates/xy-protocol" } +xy-supervisor = { path = "crates/xy-supervisor" } +xy-ipc = { path = "crates/xy-ipc" } + +tokio = { version = "1", features = ["rt-multi-thread", "net", "process", "signal", "sync", "fs", "io-util", "macros", "time"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +anyhow = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +clap = { version = "4", features = ["derive"] } +kdl = "6" +etcetera = "0.10" +nix = { version = "0.30", features = ["signal", "process"] } +humantime = "2" +humantime-serde = "1" +async-trait = "0.1" +tempfile = "3" +tokio-test = "0.4" diff --git a/crates/xy-ipc/Cargo.toml b/crates/xy-ipc/Cargo.toml new file mode 100644 index 0000000..f69b7d0 --- /dev/null +++ b/crates/xy-ipc/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "xy-ipc" +edition.workspace = true +version.workspace = true +license.workspace = true + +[dependencies] +xy-protocol.workspace = true +tokio.workspace = true +serde.workspace = true +serde_json.workspace = true +tracing.workspace = true +thiserror.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/xy-ipc/src/lib.rs b/crates/xy-ipc/src/lib.rs new file mode 100644 index 0000000..2ad7623 --- /dev/null +++ b/crates/xy-ipc/src/lib.rs @@ -0,0 +1 @@ +//! JSON-RPC 2.0 over newline-delimited JSON on a Unix socket. diff --git a/crates/xy-protocol/Cargo.toml b/crates/xy-protocol/Cargo.toml new file mode 100644 index 0000000..3540503 --- /dev/null +++ b/crates/xy-protocol/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "xy-protocol" +edition.workspace = true +version.workspace = true +license.workspace = true + +[dependencies] +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +kdl.workspace = true +humantime.workspace = true +humantime-serde.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/xy-protocol/src/lib.rs b/crates/xy-protocol/src/lib.rs new file mode 100644 index 0000000..a344f10 --- /dev/null +++ b/crates/xy-protocol/src/lib.rs @@ -0,0 +1 @@ +//! Wire types and config schema shared between the xy daemon and CLI. diff --git a/crates/xy-supervisor/Cargo.toml b/crates/xy-supervisor/Cargo.toml new file mode 100644 index 0000000..1bd5a5f --- /dev/null +++ b/crates/xy-supervisor/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "xy-supervisor" +edition.workspace = true +version.workspace = true +license.workspace = true + +[dependencies] +xy-protocol.workspace = true +tokio.workspace = true +tracing.workspace = true +thiserror.workspace = true +nix.workspace = true +async-trait.workspace = true + +[dev-dependencies] +tokio-test.workspace = true +tempfile.workspace = true diff --git a/crates/xy-supervisor/src/lib.rs b/crates/xy-supervisor/src/lib.rs new file mode 100644 index 0000000..1747681 --- /dev/null +++ b/crates/xy-supervisor/src/lib.rs @@ -0,0 +1 @@ +//! Process-supervision primitives for the xy daemon. diff --git a/crates/xy/Cargo.toml b/crates/xy/Cargo.toml new file mode 100644 index 0000000..bd6609b --- /dev/null +++ b/crates/xy/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "xy" +edition.workspace = true +version.workspace = true +license.workspace = true + +[[bin]] +name = "xy" +path = "src/main.rs" + +[dependencies] +xy-protocol.workspace = true +xy-supervisor.workspace = true +xy-ipc.workspace = true +tokio.workspace = true +clap.workspace = true +serde.workspace = true +serde_json.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +anyhow.workspace = true +etcetera.workspace = true +nix.workspace = true +humantime.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/xy/src/main.rs b/crates/xy/src/main.rs new file mode 100644 index 0000000..662ece1 --- /dev/null +++ b/crates/xy/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("xy: not implemented yet"); +} diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index e7a11a9..0000000 --- a/src/main.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - println!("Hello, world!"); -} From 1b76378b370cc10066f0e89f4ca4501ac62aa180 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 25 May 2026 11:20:54 +0200 Subject: [PATCH 02/42] chore: bump workspace resolver to "3" cargo 1.95 supports resolver 3; align with plan spec. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index d6abd6c..e7d606a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -resolver = "2" +resolver = "3" members = [ "crates/xy-protocol", "crates/xy-supervisor", From 0e49834c936c06fbdebfff140f8aaa328ccb5b01 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 25 May 2026 11:21:43 +0200 Subject: [PATCH 03/42] feat(protocol): ServerState enum --- crates/xy-protocol/src/lib.rs | 4 ++++ crates/xy-protocol/src/state.rs | 31 +++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 crates/xy-protocol/src/state.rs diff --git a/crates/xy-protocol/src/lib.rs b/crates/xy-protocol/src/lib.rs index a344f10..33f24e9 100644 --- a/crates/xy-protocol/src/lib.rs +++ b/crates/xy-protocol/src/lib.rs @@ -1 +1,5 @@ //! Wire types and config schema shared between the xy daemon and CLI. + +pub mod state; + +pub use state::ServerState; diff --git a/crates/xy-protocol/src/state.rs b/crates/xy-protocol/src/state.rs new file mode 100644 index 0000000..3fced82 --- /dev/null +++ b/crates/xy-protocol/src/state.rs @@ -0,0 +1,31 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ServerState { + Stopped, + Starting, + Running, + Restarting, + Failed, + Stopping, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serializes_to_kebab_case() { + assert_eq!( + serde_json::to_string(&ServerState::Restarting).unwrap(), + "\"restarting\"" + ); + } + + #[test] + fn deserializes_from_kebab_case() { + let s: ServerState = serde_json::from_str("\"failed\"").unwrap(); + assert_eq!(s, ServerState::Failed); + } +} From 5a0963665d7b1fad5ab13c6507be4cb531c66830 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 25 May 2026 11:22:52 +0200 Subject: [PATCH 04/42] feat(protocol): RestartPolicy/RestartConfig/StopConfig with defaults --- crates/xy-protocol/src/config.rs | 88 ++++++++++++++++++++++++++++++++ crates/xy-protocol/src/lib.rs | 2 + 2 files changed, 90 insertions(+) create mode 100644 crates/xy-protocol/src/config.rs diff --git a/crates/xy-protocol/src/config.rs b/crates/xy-protocol/src/config.rs new file mode 100644 index 0000000..f520339 --- /dev/null +++ b/crates/xy-protocol/src/config.rs @@ -0,0 +1,88 @@ +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum RestartPolicy { + Always, + OnFailure, + Never, +} + +impl Default for RestartPolicy { + fn default() -> Self { + RestartPolicy::OnFailure + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RestartConfig { + #[serde(default)] + pub policy: RestartPolicy, + #[serde(default = "default_backoff_initial", with = "humantime_serde")] + pub backoff_initial: Duration, + #[serde(default = "default_backoff_max", with = "humantime_serde")] + pub backoff_max: Duration, + #[serde(default = "default_max_retries_per_minute")] + pub max_retries_per_minute: u32, +} + +fn default_backoff_initial() -> Duration { + Duration::from_secs(1) +} + +fn default_backoff_max() -> Duration { + Duration::from_secs(30) +} + +fn default_max_retries_per_minute() -> u32 { + 5 +} + +impl Default for RestartConfig { + fn default() -> Self { + Self { + policy: RestartPolicy::default(), + backoff_initial: default_backoff_initial(), + backoff_max: default_backoff_max(), + max_retries_per_minute: default_max_retries_per_minute(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct StopConfig { + #[serde(default = "default_grace", with = "humantime_serde")] + pub grace: Duration, +} + +fn default_grace() -> Duration { + Duration::from_secs(10) +} + +impl Default for StopConfig { + fn default() -> Self { + Self { + grace: default_grace(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn restart_config_defaults() { + let c = RestartConfig::default(); + assert_eq!(c.policy, RestartPolicy::OnFailure); + assert_eq!(c.backoff_initial, Duration::from_secs(1)); + assert_eq!(c.backoff_max, Duration::from_secs(30)); + assert_eq!(c.max_retries_per_minute, 5); + } + + #[test] + fn stop_config_defaults() { + assert_eq!(StopConfig::default().grace, Duration::from_secs(10)); + } +} diff --git a/crates/xy-protocol/src/lib.rs b/crates/xy-protocol/src/lib.rs index 33f24e9..246cdfd 100644 --- a/crates/xy-protocol/src/lib.rs +++ b/crates/xy-protocol/src/lib.rs @@ -1,5 +1,7 @@ //! Wire types and config schema shared between the xy daemon and CLI. +pub mod config; pub mod state; +pub use config::{RestartConfig, RestartPolicy, StopConfig}; pub use state::ServerState; From 355d0debda453ebd85fbbd1c4da681b0a9b3e8fe Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 25 May 2026 11:23:57 +0200 Subject: [PATCH 05/42] feat(protocol): ServerConfig + ConfigError + RpcErrorCode --- crates/xy-protocol/src/config.rs | 20 +++++++++++++++++ crates/xy-protocol/src/error.rs | 37 ++++++++++++++++++++++++++++++++ crates/xy-protocol/src/lib.rs | 4 +++- 3 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 crates/xy-protocol/src/error.rs diff --git a/crates/xy-protocol/src/config.rs b/crates/xy-protocol/src/config.rs index f520339..a4cab81 100644 --- a/crates/xy-protocol/src/config.rs +++ b/crates/xy-protocol/src/config.rs @@ -68,6 +68,26 @@ impl Default for StopConfig { } } +use std::collections::BTreeMap; +use std::path::PathBuf; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ServerConfig { + pub name: String, + pub command: PathBuf, + #[serde(default)] + pub args: Vec, + pub port: u16, + #[serde(default)] + pub env: BTreeMap, + #[serde(default)] + pub working_dir: Option, + #[serde(default)] + pub restart: RestartConfig, + #[serde(default)] + pub stop: StopConfig, +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/xy-protocol/src/error.rs b/crates/xy-protocol/src/error.rs new file mode 100644 index 0000000..4a0ffba --- /dev/null +++ b/crates/xy-protocol/src/error.rs @@ -0,0 +1,37 @@ +use std::path::PathBuf; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ConfigError { + #[error("failed to read {path}: {source}")] + Io { path: PathBuf, #[source] source: std::io::Error }, + + #[error("failed to parse KDL in {path}: {message}")] + Parse { path: PathBuf, message: String }, + + #[error("missing required field `{field}` in {path}")] + MissingField { path: PathBuf, field: &'static str }, + + #[error("invalid value for `{field}` in {path}: {message}")] + InvalidValue { path: PathBuf, field: &'static str, message: String }, + + #[error("duplicate port {port} declared by both `{name_a}` and `{name_b}`")] + DuplicatePort { name_a: String, name_b: String, port: u16 }, + + #[error("server name `{name}` contains invalid characters (allowed: a-z, 0-9, '-', '_')")] + InvalidName { name: String }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RpcErrorCode { + ServerNotFound = -32001, + PortConflict = -32002, + ConfigInvalid = -32003, + AlreadyRunning = -32004, + NotRunning = -32005, + SpawnFailed = -32006, +} + +impl RpcErrorCode { + pub fn as_i32(self) -> i32 { self as i32 } +} diff --git a/crates/xy-protocol/src/lib.rs b/crates/xy-protocol/src/lib.rs index 246cdfd..6c933ad 100644 --- a/crates/xy-protocol/src/lib.rs +++ b/crates/xy-protocol/src/lib.rs @@ -1,7 +1,9 @@ //! Wire types and config schema shared between the xy daemon and CLI. pub mod config; +pub mod error; pub mod state; -pub use config::{RestartConfig, RestartPolicy, StopConfig}; +pub use config::{RestartConfig, RestartPolicy, ServerConfig, StopConfig}; +pub use error::{ConfigError, RpcErrorCode}; pub use state::ServerState; From 7e59d7d0502999a714f066af18bc4c9c86a61555 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 25 May 2026 11:29:05 +0200 Subject: [PATCH 06/42] feat(protocol): KDL parser for ServerConfig Adds kdl_parse module with parse_server_config() that deserialises a KDL document into ServerConfig, with full validation of name, types, durations, and restart/stop blocks. Also derives Default on RestartPolicy to satisfy clippy. --- crates/xy-protocol/src/config.rs | 9 +- crates/xy-protocol/src/error.rs | 22 +- crates/xy-protocol/src/kdl_parse.rs | 360 ++++++++++++++++++++++++++++ crates/xy-protocol/src/lib.rs | 2 + 4 files changed, 382 insertions(+), 11 deletions(-) create mode 100644 crates/xy-protocol/src/kdl_parse.rs diff --git a/crates/xy-protocol/src/config.rs b/crates/xy-protocol/src/config.rs index a4cab81..7780918 100644 --- a/crates/xy-protocol/src/config.rs +++ b/crates/xy-protocol/src/config.rs @@ -1,20 +1,15 @@ use serde::{Deserialize, Serialize}; use std::time::Duration; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum RestartPolicy { Always, + #[default] OnFailure, Never, } -impl Default for RestartPolicy { - fn default() -> Self { - RestartPolicy::OnFailure - } -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RestartConfig { #[serde(default)] diff --git a/crates/xy-protocol/src/error.rs b/crates/xy-protocol/src/error.rs index 4a0ffba..3141096 100644 --- a/crates/xy-protocol/src/error.rs +++ b/crates/xy-protocol/src/error.rs @@ -4,7 +4,11 @@ use thiserror::Error; #[derive(Debug, Error)] pub enum ConfigError { #[error("failed to read {path}: {source}")] - Io { path: PathBuf, #[source] source: std::io::Error }, + Io { + path: PathBuf, + #[source] + source: std::io::Error, + }, #[error("failed to parse KDL in {path}: {message}")] Parse { path: PathBuf, message: String }, @@ -13,10 +17,18 @@ pub enum ConfigError { MissingField { path: PathBuf, field: &'static str }, #[error("invalid value for `{field}` in {path}: {message}")] - InvalidValue { path: PathBuf, field: &'static str, message: String }, + InvalidValue { + path: PathBuf, + field: &'static str, + message: String, + }, #[error("duplicate port {port} declared by both `{name_a}` and `{name_b}`")] - DuplicatePort { name_a: String, name_b: String, port: u16 }, + DuplicatePort { + name_a: String, + name_b: String, + port: u16, + }, #[error("server name `{name}` contains invalid characters (allowed: a-z, 0-9, '-', '_')")] InvalidName { name: String }, @@ -33,5 +45,7 @@ pub enum RpcErrorCode { } impl RpcErrorCode { - pub fn as_i32(self) -> i32 { self as i32 } + pub fn as_i32(self) -> i32 { + self as i32 + } } diff --git a/crates/xy-protocol/src/kdl_parse.rs b/crates/xy-protocol/src/kdl_parse.rs new file mode 100644 index 0000000..66f8264 --- /dev/null +++ b/crates/xy-protocol/src/kdl_parse.rs @@ -0,0 +1,360 @@ +use crate::{ConfigError, RestartConfig, RestartPolicy, ServerConfig, StopConfig}; +use kdl::{KdlDocument, KdlNode}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +pub fn parse_server_config( + name: &str, + text: &str, + source_path: &Path, +) -> Result { + validate_name(name).map_err(|_| ConfigError::InvalidName { + name: name.to_string(), + })?; + + let doc: KdlDocument = text + .parse() + .map_err(|err: kdl::KdlError| ConfigError::Parse { + path: source_path.to_path_buf(), + message: err.to_string(), + })?; + + let command = require_string_arg(&doc, "command", source_path)?; + let args = optional_string_args(&doc, "args"); + let port = require_u16_arg(&doc, "port", source_path)?; + let env = optional_string_map(&doc, "env"); + let working_dir = optional_string_arg(&doc, "working-dir").map(PathBuf::from); + let restart = parse_restart(&doc, source_path)?; + let stop = parse_stop(&doc, source_path)?; + + Ok(ServerConfig { + name: name.to_string(), + command: PathBuf::from(command), + args, + port, + env, + working_dir, + restart, + stop, + }) +} + +fn validate_name(name: &str) -> Result<(), ()> { + if name.is_empty() { + return Err(()); + } + + if name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') + { + Ok(()) + } else { + Err(()) + } +} + +fn require_string_arg( + doc: &KdlDocument, + name: &'static str, + path: &Path, +) -> Result { + let node = find_node(doc, name).ok_or(ConfigError::MissingField { + path: path.to_path_buf(), + field: name, + })?; + + node.entries() + .first() + .and_then(|e| e.value().as_string().map(str::to_string)) + .ok_or(ConfigError::InvalidValue { + path: path.to_path_buf(), + field: name, + message: "expected string argument".into(), + }) +} + +fn require_u16_arg(doc: &KdlDocument, name: &'static str, path: &Path) -> Result { + let node = find_node(doc, name).ok_or(ConfigError::MissingField { + path: path.to_path_buf(), + field: name, + })?; + + let v = node + .entries() + .first() + .and_then(|e| e.value().as_integer()) + .ok_or(ConfigError::InvalidValue { + path: path.to_path_buf(), + field: name, + message: "expected integer".into(), + })?; + + u16::try_from(v).map_err(|_| ConfigError::InvalidValue { + path: path.to_path_buf(), + field: name, + message: format!("port {v} out of u16 range"), + }) +} + +fn optional_string_arg(doc: &KdlDocument, name: &str) -> Option { + find_node(doc, name) + .and_then(|n| n.entries().first()) + .and_then(|e| e.value().as_string().map(str::to_string)) +} + +fn optional_string_args(doc: &KdlDocument, name: &str) -> Vec { + find_node(doc, name) + .map(|n| { + n.entries() + .iter() + .filter_map(|e| e.value().as_string().map(str::to_string)) + .collect() + }) + .unwrap_or_default() +} + +fn optional_string_map(doc: &KdlDocument, name: &str) -> BTreeMap { + let Some(node) = find_node(doc, name) else { + return BTreeMap::new(); + }; + + let mut out = BTreeMap::new(); + + if let Some(children) = node.children() { + for child in children.nodes() { + let key = child.name().value().to_string(); + + if let Some(val) = child.entries().first().and_then(|e| e.value().as_string()) { + out.insert(key, val.to_string()); + } + } + } + + out +} + +fn parse_restart(doc: &KdlDocument, path: &Path) -> Result { + let Some(node) = find_node(doc, "restart") else { + return Ok(RestartConfig::default()); + }; + + let Some(children) = node.children() else { + return Ok(RestartConfig::default()); + }; + + let mut out = RestartConfig::default(); + + for child in children.nodes() { + match child.name().value() { + "policy" => { + let s = string_arg(child, "policy", path)?; + + out.policy = match s.as_str() { + "always" => RestartPolicy::Always, + "on-failure" => RestartPolicy::OnFailure, + "never" => RestartPolicy::Never, + other => { + return Err(ConfigError::InvalidValue { + path: path.to_path_buf(), + field: "restart.policy", + message: format!("unknown policy `{other}`"), + }); + } + }; + } + "backoff-initial" => { + out.backoff_initial = parse_duration_arg(child, "restart.backoff-initial", path)?; + } + "backoff-max" => { + out.backoff_max = parse_duration_arg(child, "restart.backoff-max", path)?; + } + "max-retries-per-minute" => { + let v = child + .entries() + .first() + .and_then(|e| e.value().as_integer()) + .ok_or(ConfigError::InvalidValue { + path: path.to_path_buf(), + field: "restart.max-retries-per-minute", + message: "expected integer".into(), + })?; + + out.max_retries_per_minute = + u32::try_from(v).map_err(|_| ConfigError::InvalidValue { + path: path.to_path_buf(), + field: "restart.max-retries-per-minute", + message: format!("out of u32 range: {v}"), + })?; + } + other => { + return Err(ConfigError::InvalidValue { + path: path.to_path_buf(), + field: "restart", + message: format!("unknown key `{other}`"), + }); + } + } + } + + Ok(out) +} + +fn parse_stop(doc: &KdlDocument, path: &Path) -> Result { + let Some(node) = find_node(doc, "stop") else { + return Ok(StopConfig::default()); + }; + + let Some(children) = node.children() else { + return Ok(StopConfig::default()); + }; + + let mut out = StopConfig::default(); + + for child in children.nodes() { + match child.name().value() { + "grace" => { + out.grace = parse_duration_arg(child, "stop.grace", path)?; + } + other => { + return Err(ConfigError::InvalidValue { + path: path.to_path_buf(), + field: "stop", + message: format!("unknown key `{other}`"), + }); + } + } + } + + Ok(out) +} + +fn string_arg(node: &KdlNode, field: &'static str, path: &Path) -> Result { + node.entries() + .first() + .and_then(|e| e.value().as_string().map(str::to_string)) + .ok_or(ConfigError::InvalidValue { + path: path.to_path_buf(), + field, + message: "expected string".into(), + }) +} + +fn parse_duration_arg( + node: &KdlNode, + field: &'static str, + path: &Path, +) -> Result { + let s = string_arg(node, field, path)?; + + humantime::parse_duration(&s).map_err(|err| ConfigError::InvalidValue { + path: path.to_path_buf(), + field, + message: format!("invalid duration `{s}`: {err}"), + }) +} + +fn find_node<'a>(doc: &'a KdlDocument, name: &str) -> Option<&'a KdlNode> { + doc.nodes().iter().find(|n| n.name().value() == name) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + fn p() -> &'static Path { + Path::new("/tmp/test.kdl") + } + + #[test] + fn parses_minimal_config() { + let text = "command \"/usr/local/bin/foo\"\nport 8421\n"; + let cfg = parse_server_config("foo", text, p()).unwrap(); + + assert_eq!(cfg.name, "foo"); + assert_eq!(cfg.command, PathBuf::from("/usr/local/bin/foo")); + assert_eq!(cfg.port, 8421); + assert!(cfg.args.is_empty()); + assert!(cfg.env.is_empty()); + } + + #[test] + fn parses_full_config() { + let text = r#" +command "/usr/local/bin/foo" +args "--http" "--port" "8421" +port 8421 +env { + RUST_LOG "info" + FOO_BAR "baz" +} +working-dir "/tmp/work" +restart { + policy "always" + backoff-initial "2s" + backoff-max "1m" + max-retries-per-minute 10 +} +stop { + grace "30s" +} +"#; + let cfg = parse_server_config("foo", text, p()).unwrap(); + + assert_eq!(cfg.args, vec!["--http", "--port", "8421"]); + assert_eq!(cfg.env.get("RUST_LOG").map(String::as_str), Some("info")); + assert_eq!(cfg.working_dir, Some(PathBuf::from("/tmp/work"))); + assert_eq!(cfg.restart.policy, RestartPolicy::Always); + assert_eq!(cfg.restart.backoff_initial, Duration::from_secs(2)); + assert_eq!(cfg.restart.backoff_max, Duration::from_secs(60)); + assert_eq!(cfg.restart.max_retries_per_minute, 10); + assert_eq!(cfg.stop.grace, Duration::from_secs(30)); + } + + #[test] + fn missing_command_fails() { + let err = parse_server_config("foo", "port 8421", p()).unwrap_err(); + + assert!(matches!( + err, + ConfigError::MissingField { + field: "command", + .. + } + )); + } + + #[test] + fn missing_port_fails() { + let err = parse_server_config("foo", "command \"/bin/x\"", p()).unwrap_err(); + + assert!(matches!( + err, + ConfigError::MissingField { field: "port", .. } + )); + } + + #[test] + fn unknown_restart_policy_fails() { + let text = "command \"/bin/x\"\nport 1\nrestart { policy \"maybe\" }"; + let err = parse_server_config("foo", text, p()).unwrap_err(); + + assert!(matches!( + err, + ConfigError::InvalidValue { + field: "restart.policy", + .. + } + )); + } + + #[test] + fn invalid_name_rejected() { + let text = "command \"/bin/x\"\nport 1"; + let err = parse_server_config("Foo Bar", text, p()).unwrap_err(); + + assert!(matches!(err, ConfigError::InvalidName { .. })); + } +} diff --git a/crates/xy-protocol/src/lib.rs b/crates/xy-protocol/src/lib.rs index 6c933ad..a5e3ed0 100644 --- a/crates/xy-protocol/src/lib.rs +++ b/crates/xy-protocol/src/lib.rs @@ -2,8 +2,10 @@ pub mod config; pub mod error; +pub mod kdl_parse; pub mod state; pub use config::{RestartConfig, RestartPolicy, ServerConfig, StopConfig}; pub use error::{ConfigError, RpcErrorCode}; +pub use kdl_parse::parse_server_config; pub use state::ServerState; From e8f5846cec1eb28ea3bc827f2fee9d6bc1de82dd Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 25 May 2026 11:30:38 +0200 Subject: [PATCH 07/42] feat(protocol): load_all_configs from dir with duplicate port detection --- crates/xy-protocol/src/kdl_parse.rs | 87 +++++++++++++++++++++++++++++ crates/xy-protocol/src/lib.rs | 2 +- 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/crates/xy-protocol/src/kdl_parse.rs b/crates/xy-protocol/src/kdl_parse.rs index 66f8264..22d3a1f 100644 --- a/crates/xy-protocol/src/kdl_parse.rs +++ b/crates/xy-protocol/src/kdl_parse.rs @@ -259,6 +259,58 @@ fn find_node<'a>(doc: &'a KdlDocument, name: &str) -> Option<&'a KdlNode> { doc.nodes().iter().find(|n| n.name().value() == name) } +pub fn load_all_configs(dir: &Path) -> Result, ConfigError> { + if !dir.exists() { + return Ok(Vec::new()); + } + + let entries = std::fs::read_dir(dir).map_err(|e| ConfigError::Io { + path: dir.to_path_buf(), + source: e, + })?; + + let mut configs = Vec::new(); + for entry in entries { + let entry = entry.map_err(|e| ConfigError::Io { + path: dir.to_path_buf(), + source: e, + })?; + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("kdl") { + continue; + } + let name = path + .file_stem() + .and_then(|s| s.to_str()) + .ok_or(ConfigError::InvalidName { + name: path.display().to_string(), + })? + .to_string(); + let text = std::fs::read_to_string(&path).map_err(|e| ConfigError::Io { + path: path.clone(), + source: e, + })?; + configs.push(parse_server_config(&name, &text, &path)?); + } + + check_duplicate_ports(&configs)?; + Ok(configs) +} + +fn check_duplicate_ports(configs: &[ServerConfig]) -> Result<(), ConfigError> { + let mut seen: std::collections::HashMap = std::collections::HashMap::new(); + for c in configs { + if let Some(other) = seen.insert(c.port, c.name.clone()) { + return Err(ConfigError::DuplicatePort { + name_a: other, + name_b: c.name.clone(), + port: c.port, + }); + } + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -357,4 +409,39 @@ stop { assert!(matches!(err, ConfigError::InvalidName { .. })); } + + use std::fs; + use tempfile::tempdir; + + #[test] + fn load_all_finds_and_parses_files() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("a.kdl"), "command \"/bin/a\"\nport 8001").unwrap(); + fs::write(dir.path().join("b.kdl"), "command \"/bin/b\"\nport 8002").unwrap(); + fs::write(dir.path().join("ignored.txt"), "not a config").unwrap(); + let mut configs = load_all_configs(dir.path()).unwrap(); + configs.sort_by(|x, y| x.name.cmp(&y.name)); + assert_eq!(configs.len(), 2); + assert_eq!(configs[0].name, "a"); + assert_eq!(configs[1].port, 8002); + } + + #[test] + fn load_all_returns_empty_for_missing_dir() { + let dir = tempdir().unwrap(); + let configs = load_all_configs(&dir.path().join("does-not-exist")).unwrap(); + assert!(configs.is_empty()); + } + + #[test] + fn duplicate_ports_detected() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("a.kdl"), "command \"/bin/a\"\nport 8001").unwrap(); + fs::write(dir.path().join("b.kdl"), "command \"/bin/b\"\nport 8001").unwrap(); + let err = load_all_configs(dir.path()).unwrap_err(); + match err { + ConfigError::DuplicatePort { port, .. } => assert_eq!(port, 8001), + other => panic!("unexpected error: {other:?}"), + } + } } diff --git a/crates/xy-protocol/src/lib.rs b/crates/xy-protocol/src/lib.rs index a5e3ed0..64259ab 100644 --- a/crates/xy-protocol/src/lib.rs +++ b/crates/xy-protocol/src/lib.rs @@ -7,5 +7,5 @@ pub mod state; pub use config::{RestartConfig, RestartPolicy, ServerConfig, StopConfig}; pub use error::{ConfigError, RpcErrorCode}; -pub use kdl_parse::parse_server_config; +pub use kdl_parse::{load_all_configs, parse_server_config}; pub use state::ServerState; From bd926061bfbaf1afb94b5973c2e01ca0afce8079 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 25 May 2026 11:31:56 +0200 Subject: [PATCH 08/42] feat(protocol): JSON-RPC method param/result types --- crates/xy-protocol/src/lib.rs | 1 + crates/xy-protocol/src/rpc.rs | 148 ++++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 crates/xy-protocol/src/rpc.rs diff --git a/crates/xy-protocol/src/lib.rs b/crates/xy-protocol/src/lib.rs index 64259ab..bf09517 100644 --- a/crates/xy-protocol/src/lib.rs +++ b/crates/xy-protocol/src/lib.rs @@ -3,6 +3,7 @@ pub mod config; pub mod error; pub mod kdl_parse; +pub mod rpc; pub mod state; pub use config::{RestartConfig, RestartPolicy, ServerConfig, StopConfig}; diff --git a/crates/xy-protocol/src/rpc.rs b/crates/xy-protocol/src/rpc.rs new file mode 100644 index 0000000..6a17a82 --- /dev/null +++ b/crates/xy-protocol/src/rpc.rs @@ -0,0 +1,148 @@ +use crate::ServerState; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerSummary { + pub name: String, + pub state: ServerState, + #[serde(skip_serializing_if = "Option::is_none")] + pub pid: Option, + pub port: u16, + #[serde(skip_serializing_if = "Option::is_none")] + pub uptime_secs: Option, + pub restart_count: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_exit: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StatusDetail { + #[serde(flatten)] + pub summary: ServerSummary, + pub recent_transitions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StateTransition { + pub from: ServerState, + pub to: ServerState, + pub at_unix_ms: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum NameOrAll { + All { all: bool }, + Name { name: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StatusParams { + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StartResult { + pub started: Vec, + pub already_running: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StopResult { + pub stopped: Vec, + pub not_running: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RestartResult { + pub restarted: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReloadResult { + pub added: Vec, + pub removed: Vec, + pub changed: Vec, + pub unchanged: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LogsParams { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tail: Option, + #[serde(default)] + pub follow: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LogsSubscribed { + pub subscription_id: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LogsCancelParams { + pub subscription_id: u64, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum LogStream { + Stdout, + Stderr, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LogLine { + pub subscription_id: u64, + pub name: String, + pub stream: LogStream, + pub line: String, + pub ts_unix_ms: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LogEnd { + pub subscription_id: u64, +} + +pub mod methods { + pub const LIST: &str = "list"; + pub const STATUS: &str = "status"; + pub const START: &str = "start"; + pub const STOP: &str = "stop"; + pub const RESTART: &str = "restart"; + pub const RELOAD: &str = "reload"; + pub const LOGS: &str = "logs"; + pub const LOGS_CANCEL: &str = "logs_cancel"; +} + +pub mod notifications { + pub const LOG: &str = "log"; + pub const LOG_END: &str = "log_end"; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn name_or_all_round_trips_name() { + let v: NameOrAll = serde_json::from_str(r#"{"name":"foo"}"#).unwrap(); + match v { + NameOrAll::Name { name } => assert_eq!(name, "foo"), + _ => panic!("expected Name variant"), + } + } + + #[test] + fn name_or_all_round_trips_all() { + let v: NameOrAll = serde_json::from_str(r#"{"all":true}"#).unwrap(); + match v { + NameOrAll::All { all } => assert!(all), + _ => panic!("expected All variant"), + } + } +} From 1d2848f03aaa05acbd5bde30c2fa3ec1d1d4c824 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 25 May 2026 11:33:23 +0200 Subject: [PATCH 09/42] feat(supervisor): ChildHandle trait + MockChild --- crates/xy-supervisor/src/child.rs | 92 +++++++++++++++++++++++++++++++ crates/xy-supervisor/src/lib.rs | 4 ++ 2 files changed, 96 insertions(+) create mode 100644 crates/xy-supervisor/src/child.rs diff --git a/crates/xy-supervisor/src/child.rs b/crates/xy-supervisor/src/child.rs new file mode 100644 index 0000000..fd9cc15 --- /dev/null +++ b/crates/xy-supervisor/src/child.rs @@ -0,0 +1,92 @@ +use std::sync::Arc; +use tokio::sync::{Mutex, oneshot}; + +#[async_trait::async_trait] +pub trait ChildHandle: Send + 'static { + fn pid(&self) -> u32; + async fn wait(&mut self) -> std::io::Result>; + fn terminate(&mut self) -> std::io::Result<()>; + fn kill(&mut self) -> std::io::Result<()>; +} + +pub struct MockChild { + pid: u32, + exit_rx: Arc>>>, + terminate_tx: Option>, + kill_tx: Option>, +} + +pub struct MockChildController { + pub exit_tx: Option>>, + pub terminate_rx: oneshot::Receiver<()>, + pub kill_rx: oneshot::Receiver<()>, +} + +impl MockChild { + pub fn new(pid: u32) -> (Self, MockChildController) { + let (exit_tx, exit_rx) = oneshot::channel(); + let (terminate_tx, terminate_rx) = oneshot::channel(); + let (kill_tx, kill_rx) = oneshot::channel(); + let child = Self { + pid, + exit_rx: Arc::new(Mutex::new(exit_rx)), + terminate_tx: Some(terminate_tx), + kill_tx: Some(kill_tx), + }; + let ctl = MockChildController { + exit_tx: Some(exit_tx), + terminate_rx, + kill_rx, + }; + (child, ctl) + } +} + +#[async_trait::async_trait] +impl ChildHandle for MockChild { + fn pid(&self) -> u32 { + self.pid + } + + async fn wait(&mut self) -> std::io::Result> { + let mut rx = self.exit_rx.lock().await; + match (&mut *rx).await { + Ok(code) => Ok(code), + Err(_) => Err(std::io::Error::other("exit_tx dropped")), + } + } + + fn terminate(&mut self) -> std::io::Result<()> { + if let Some(tx) = self.terminate_tx.take() { + let _ = tx.send(()); + } + Ok(()) + } + + fn kill(&mut self) -> std::io::Result<()> { + if let Some(tx) = self.kill_tx.take() { + let _ = tx.send(()); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn mock_child_exit() { + let (mut child, mut ctl) = MockChild::new(123); + assert_eq!(child.pid(), 123); + ctl.exit_tx.take().unwrap().send(Some(0)).unwrap(); + assert_eq!(child.wait().await.unwrap(), Some(0)); + } + + #[tokio::test] + async fn mock_child_terminate() { + let (mut child, mut ctl) = MockChild::new(1); + child.terminate().unwrap(); + ctl.terminate_rx.try_recv().unwrap(); + } +} diff --git a/crates/xy-supervisor/src/lib.rs b/crates/xy-supervisor/src/lib.rs index 1747681..e536cee 100644 --- a/crates/xy-supervisor/src/lib.rs +++ b/crates/xy-supervisor/src/lib.rs @@ -1 +1,5 @@ //! Process-supervision primitives for the xy daemon. + +pub mod child; + +pub use child::{ChildHandle, MockChild, MockChildController}; From 4837a73167d16f18e7484acde5a4c93f1ff4d9b0 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 25 May 2026 11:34:50 +0200 Subject: [PATCH 10/42] feat(supervisor): restart-policy decision logic --- crates/xy-supervisor/src/lib.rs | 6 ++ crates/xy-supervisor/src/policy.rs | 102 +++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 crates/xy-supervisor/src/policy.rs diff --git a/crates/xy-supervisor/src/lib.rs b/crates/xy-supervisor/src/lib.rs index e536cee..be9d9e6 100644 --- a/crates/xy-supervisor/src/lib.rs +++ b/crates/xy-supervisor/src/lib.rs @@ -1,5 +1,11 @@ //! Process-supervision primitives for the xy daemon. +pub mod backoff; pub mod child; +pub mod policy; +pub mod retry_window; +pub use backoff::Backoff; pub use child::{ChildHandle, MockChild, MockChildController}; +pub use policy::{RestartDecision, decide}; +pub use retry_window::RetryWindow; diff --git a/crates/xy-supervisor/src/policy.rs b/crates/xy-supervisor/src/policy.rs new file mode 100644 index 0000000..c2d41d9 --- /dev/null +++ b/crates/xy-supervisor/src/policy.rs @@ -0,0 +1,102 @@ +use xy_protocol::RestartPolicy; + +#[derive(Debug, PartialEq, Eq)] +pub enum RestartDecision { + Restart, + StayStopped, + MarkFailed, +} + +pub fn decide( + policy: RestartPolicy, + exit_code: Option, + retry_cap_reached: bool, +) -> RestartDecision { + let clean = matches!(exit_code, Some(0)); + let want = match policy { + RestartPolicy::Never => false, + RestartPolicy::OnFailure => !clean, + RestartPolicy::Always => true, + }; + if !want { + return RestartDecision::StayStopped; + } + if retry_cap_reached { + RestartDecision::MarkFailed + } else { + RestartDecision::Restart + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn never_never_restarts() { + assert_eq!( + decide(RestartPolicy::Never, Some(0), false), + RestartDecision::StayStopped + ); + assert_eq!( + decide(RestartPolicy::Never, Some(1), false), + RestartDecision::StayStopped + ); + assert_eq!( + decide(RestartPolicy::Never, None, false), + RestartDecision::StayStopped + ); + } + + #[test] + fn on_failure_skips_clean() { + assert_eq!( + decide(RestartPolicy::OnFailure, Some(0), false), + RestartDecision::StayStopped + ); + } + + #[test] + fn on_failure_restarts_nonzero() { + assert_eq!( + decide(RestartPolicy::OnFailure, Some(1), false), + RestartDecision::Restart + ); + } + + #[test] + fn on_failure_restarts_signal() { + assert_eq!( + decide(RestartPolicy::OnFailure, None, false), + RestartDecision::Restart + ); + } + + #[test] + fn always_restarts_on_clean() { + assert_eq!( + decide(RestartPolicy::Always, Some(0), false), + RestartDecision::Restart + ); + } + + #[test] + fn cap_reached_marks_failed() { + assert_eq!( + decide(RestartPolicy::Always, Some(0), true), + RestartDecision::MarkFailed + ); + assert_eq!( + decide(RestartPolicy::OnFailure, Some(1), true), + RestartDecision::MarkFailed + ); + } + + #[test] + fn cap_reached_never_still_stopped() { + assert_eq!( + decide(RestartPolicy::Never, Some(1), true), + RestartDecision::StayStopped + ); + } +} From 54045da2df447afbeb3c711f7ccc53510061a348 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 25 May 2026 11:34:53 +0200 Subject: [PATCH 11/42] feat(supervisor): exponential backoff calculator --- crates/xy-supervisor/src/backoff.rs | 70 +++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 crates/xy-supervisor/src/backoff.rs diff --git a/crates/xy-supervisor/src/backoff.rs b/crates/xy-supervisor/src/backoff.rs new file mode 100644 index 0000000..e4909a5 --- /dev/null +++ b/crates/xy-supervisor/src/backoff.rs @@ -0,0 +1,70 @@ +use std::time::Duration; + +#[derive(Debug, Clone)] +pub struct Backoff { + initial: Duration, + max: Duration, + current: Option, +} + +impl Backoff { + pub fn new(initial: Duration, max: Duration) -> Self { + Self { + initial, + max, + current: None, + } + } + + pub fn next(&mut self) -> Duration { + let next = match self.current { + None => self.initial, + Some(d) => (d * 2).min(self.max), + }; + self.current = Some(next); + next + } + + pub fn reset(&mut self) { + self.current = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn starts_at_initial() { + let mut b = Backoff::new(Duration::from_secs(1), Duration::from_secs(30)); + assert_eq!(b.next(), Duration::from_secs(1)); + } + + #[test] + fn doubles_each_call() { + let mut b = Backoff::new(Duration::from_secs(1), Duration::from_secs(30)); + assert_eq!(b.next(), Duration::from_secs(1)); + assert_eq!(b.next(), Duration::from_secs(2)); + assert_eq!(b.next(), Duration::from_secs(4)); + assert_eq!(b.next(), Duration::from_secs(8)); + } + + #[test] + fn caps_at_max() { + let mut b = Backoff::new(Duration::from_secs(1), Duration::from_secs(5)); + for _ in 0..10 { + b.next(); + } + assert_eq!(b.next(), Duration::from_secs(5)); + } + + #[test] + fn reset_starts_over() { + let mut b = Backoff::new(Duration::from_secs(1), Duration::from_secs(30)); + b.next(); + b.next(); + b.next(); + b.reset(); + assert_eq!(b.next(), Duration::from_secs(1)); + } +} From d237e980e9f9d3b196cd31e1ed59f632aa082448 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 25 May 2026 11:34:55 +0200 Subject: [PATCH 12/42] feat(supervisor): sliding retry-window tracker --- crates/xy-supervisor/src/retry_window.rs | 80 ++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 crates/xy-supervisor/src/retry_window.rs diff --git a/crates/xy-supervisor/src/retry_window.rs b/crates/xy-supervisor/src/retry_window.rs new file mode 100644 index 0000000..0ac14dd --- /dev/null +++ b/crates/xy-supervisor/src/retry_window.rs @@ -0,0 +1,80 @@ +use std::collections::VecDeque; +use std::time::{Duration, Instant}; + +#[derive(Debug, Clone)] +pub struct RetryWindow { + window: Duration, + cap: u32, + events: VecDeque, +} + +impl RetryWindow { + pub fn new(window: Duration, cap: u32) -> Self { + Self { + window, + cap, + events: VecDeque::new(), + } + } + + pub fn record(&mut self, now: Instant) { + self.events.push_back(now); + self.prune(now); + } + + pub fn cap_reached(&mut self, now: Instant) -> bool { + self.prune(now); + self.events.len() as u32 >= self.cap + } + + pub fn count(&mut self, now: Instant) -> u32 { + self.prune(now); + self.events.len() as u32 + } + + fn prune(&mut self, now: Instant) { + while let Some(&front) = self.events.front() { + if now.duration_since(front) > self.window { + self.events.pop_front(); + } else { + break; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn below_cap_not_reached() { + let mut w = RetryWindow::new(Duration::from_secs(60), 3); + let t = Instant::now(); + w.record(t); + w.record(t); + assert!(!w.cap_reached(t)); + } + + #[test] + fn at_cap_reached() { + let mut w = RetryWindow::new(Duration::from_secs(60), 3); + let t = Instant::now(); + w.record(t); + w.record(t); + w.record(t); + assert!(w.cap_reached(t)); + } + + #[test] + fn old_events_pruned() { + let mut w = RetryWindow::new(Duration::from_secs(60), 3); + let t0 = Instant::now(); + w.record(t0); + w.record(t0); + w.record(t0); + let t1 = t0 + Duration::from_secs(61); + assert_eq!(w.count(t1), 0); + assert!(!w.cap_reached(t1)); + } +} From d51f25350c2eb3710b274bd333dce25e2e34134e Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 25 May 2026 11:36:23 +0200 Subject: [PATCH 13/42] feat(supervisor): rotating log writer --- crates/xy-supervisor/src/lib.rs | 2 + crates/xy-supervisor/src/logs.rs | 104 +++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 crates/xy-supervisor/src/logs.rs diff --git a/crates/xy-supervisor/src/lib.rs b/crates/xy-supervisor/src/lib.rs index be9d9e6..1aa7124 100644 --- a/crates/xy-supervisor/src/lib.rs +++ b/crates/xy-supervisor/src/lib.rs @@ -2,10 +2,12 @@ pub mod backoff; pub mod child; +pub mod logs; pub mod policy; pub mod retry_window; pub use backoff::Backoff; pub use child::{ChildHandle, MockChild, MockChildController}; +pub use logs::RotatingLogWriter; pub use policy::{RestartDecision, decide}; pub use retry_window::RetryWindow; diff --git a/crates/xy-supervisor/src/logs.rs b/crates/xy-supervisor/src/logs.rs new file mode 100644 index 0000000..0c66204 --- /dev/null +++ b/crates/xy-supervisor/src/logs.rs @@ -0,0 +1,104 @@ +use std::fs::{File, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +pub struct RotatingLogWriter { + base: PathBuf, + max_bytes: u64, + keep: usize, + file: File, + written: u64, +} + +impl RotatingLogWriter { + pub fn open(base: &Path, max_bytes: u64, keep: usize) -> std::io::Result { + if let Some(parent) = base.parent() { + std::fs::create_dir_all(parent)?; + } + let file = OpenOptions::new().create(true).append(true).open(base)?; + let written = file.metadata()?.len(); + Ok(Self { + base: base.to_path_buf(), + max_bytes, + keep, + file, + written, + }) + } + + pub fn write_line(&mut self, tag: &str, line: &str) -> std::io::Result<()> { + let bytes = format!("{tag} {line}\n"); + self.file.write_all(bytes.as_bytes())?; + self.written += bytes.len() as u64; + if self.written >= self.max_bytes { + self.rotate()?; + } + Ok(()) + } + + fn rotate(&mut self) -> std::io::Result<()> { + // Drop the current handle by replacing with /dev/null briefly. + self.file = OpenOptions::new().read(true).open("/dev/null")?; + for i in (1..self.keep).rev() { + let src = self.gen_path(i); + let dst = self.gen_path(i + 1); + if src.exists() { + let _ = std::fs::rename(&src, &dst); + } + } + if self.base.exists() { + let _ = std::fs::rename(&self.base, self.gen_path(1)); + } + self.file = OpenOptions::new() + .create(true) + .append(true) + .open(&self.base)?; + self.written = 0; + Ok(()) + } + + fn gen_path(&self, n: usize) -> PathBuf { + let mut s = self.base.as_os_str().to_os_string(); + s.push(format!(".{n}")); + PathBuf::from(s) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + use std::io::Read; + + #[test] + fn writes_lines_with_tags() { + let dir = tempdir().unwrap(); + let base = dir.path().join("x.log"); + let mut w = RotatingLogWriter::open(&base, 1024, 3).unwrap(); + w.write_line("[out]", "hello").unwrap(); + w.write_line("[err]", "boom").unwrap(); + let mut s = String::new(); + File::open(&base) + .unwrap() + .read_to_string(&mut s) + .unwrap(); + assert_eq!(s, "[out] hello\n[err] boom\n"); + } + + #[test] + fn rotates_at_threshold() { + let dir = tempdir().unwrap(); + let base = dir.path().join("x.log"); + let mut w = RotatingLogWriter::open(&base, 20, 3).unwrap(); + for _ in 0..5 { + w.write_line("[out]", "0123456789").unwrap(); + } + assert!(base.exists()); + let rotated = dir.path().join("x.log.1"); + assert!( + rotated.exists(), + "expected rotated file at {}", + rotated.display() + ); + } +} From 7995a53e829aff56cb383b6904b1a9ec8f6ee003 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 25 May 2026 11:36:52 +0200 Subject: [PATCH 14/42] feat(supervisor): ring buffer for recent log lines --- crates/xy-supervisor/src/lib.rs | 2 +- crates/xy-supervisor/src/logs.rs | 91 ++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/crates/xy-supervisor/src/lib.rs b/crates/xy-supervisor/src/lib.rs index 1aa7124..e8f7fb1 100644 --- a/crates/xy-supervisor/src/lib.rs +++ b/crates/xy-supervisor/src/lib.rs @@ -8,6 +8,6 @@ pub mod retry_window; pub use backoff::Backoff; pub use child::{ChildHandle, MockChild, MockChildController}; -pub use logs::RotatingLogWriter; +pub use logs::{RecordedLine, RingBuffer, RotatingLogWriter}; pub use policy::{RestartDecision, decide}; pub use retry_window::RetryWindow; diff --git a/crates/xy-supervisor/src/logs.rs b/crates/xy-supervisor/src/logs.rs index 0c66204..a2c80f7 100644 --- a/crates/xy-supervisor/src/logs.rs +++ b/crates/xy-supervisor/src/logs.rs @@ -1,6 +1,8 @@ +use std::collections::VecDeque; use std::fs::{File, OpenOptions}; use std::io::Write; use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; pub struct RotatingLogWriter { base: PathBuf, @@ -64,6 +66,61 @@ impl RotatingLogWriter { } } +#[derive(Clone)] +pub struct RingBuffer { + inner: Arc>, + capacity_bytes: usize, +} + +struct RingBufferInner { + lines: VecDeque, + bytes: usize, +} + +#[derive(Debug, Clone)] +pub struct RecordedLine { + pub stream: xy_protocol::rpc::LogStream, + pub line: String, + pub ts_unix_ms: u64, +} + +impl RingBuffer { + pub fn new(capacity_bytes: usize) -> Self { + Self { + inner: Arc::new(Mutex::new(RingBufferInner { + lines: VecDeque::new(), + bytes: 0, + })), + capacity_bytes, + } + } + + pub fn push(&self, line: RecordedLine) { + let mut g = self.inner.lock().unwrap(); + g.bytes += line.line.len(); + g.lines.push_back(line); + while g.bytes > self.capacity_bytes { + if let Some(removed) = g.lines.pop_front() { + g.bytes -= removed.line.len(); + } else { + break; + } + } + } + + pub fn snapshot_tail(&self, n: Option) -> Vec { + let g = self.inner.lock().unwrap(); + match n { + None => g.lines.iter().cloned().collect(), + Some(n) => { + let take = (n as usize).min(g.lines.len()); + let start = g.lines.len() - take; + g.lines.iter().skip(start).cloned().collect() + } + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -101,4 +158,38 @@ mod tests { rotated.display() ); } + + use xy_protocol::rpc::LogStream; + + fn recorded(s: &str) -> RecordedLine { + RecordedLine { + stream: LogStream::Stdout, + line: s.to_string(), + ts_unix_ms: 0, + } + } + + #[test] + fn ring_buffer_drops_oldest_when_full() { + let rb = RingBuffer::new(10); + rb.push(recorded("aaaaa")); + rb.push(recorded("bbbbb")); + rb.push(recorded("ccc")); + let snap = rb.snapshot_tail(None); + assert_eq!(snap.len(), 2); + assert_eq!(snap[0].line, "bbbbb"); + assert_eq!(snap[1].line, "ccc"); + } + + #[test] + fn ring_buffer_tail_n() { + let rb = RingBuffer::new(1024); + for i in 0..5 { + rb.push(recorded(&format!("line{i}"))); + } + let snap = rb.snapshot_tail(Some(2)); + assert_eq!(snap.len(), 2); + assert_eq!(snap[0].line, "line3"); + assert_eq!(snap[1].line, "line4"); + } } From e121fe28bb82a155e51918c07cf7af8f84461b6a Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 25 May 2026 11:37:19 +0200 Subject: [PATCH 15/42] feat(supervisor): LogSink fans out to file, ring buffer, broadcast --- crates/xy-supervisor/src/lib.rs | 2 +- crates/xy-supervisor/src/logs.rs | 75 ++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/crates/xy-supervisor/src/lib.rs b/crates/xy-supervisor/src/lib.rs index e8f7fb1..9a6df14 100644 --- a/crates/xy-supervisor/src/lib.rs +++ b/crates/xy-supervisor/src/lib.rs @@ -8,6 +8,6 @@ pub mod retry_window; pub use backoff::Backoff; pub use child::{ChildHandle, MockChild, MockChildController}; -pub use logs::{RecordedLine, RingBuffer, RotatingLogWriter}; +pub use logs::{LogSink, RecordedLine, RingBuffer, RotatingLogWriter}; pub use policy::{RestartDecision, decide}; pub use retry_window::RetryWindow; diff --git a/crates/xy-supervisor/src/logs.rs b/crates/xy-supervisor/src/logs.rs index a2c80f7..a343585 100644 --- a/crates/xy-supervisor/src/logs.rs +++ b/crates/xy-supervisor/src/logs.rs @@ -4,6 +4,9 @@ use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; +use tokio::sync::broadcast; +use xy_protocol::rpc::{LogLine, LogStream}; + pub struct RotatingLogWriter { base: PathBuf, max_bytes: u64, @@ -121,6 +124,62 @@ impl RingBuffer { } } +const LOG_BROADCAST_CAP: usize = 256; + +#[derive(Clone)] +pub struct LogSink { + pub server_name: String, + writer: Arc>, + pub ring: RingBuffer, + pub broadcast: broadcast::Sender, +} + +impl LogSink { + pub fn new( + server_name: String, + writer: RotatingLogWriter, + ring_capacity_bytes: usize, + ) -> Self { + let (tx, _) = broadcast::channel(LOG_BROADCAST_CAP); + Self { + server_name, + writer: Arc::new(Mutex::new(writer)), + ring: RingBuffer::new(ring_capacity_bytes), + broadcast: tx, + } + } + + pub fn record(&self, stream: LogStream, line: String) { + let ts = now_unix_ms(); + let tag = match stream { + LogStream::Stdout => "[out]", + LogStream::Stderr => "[err]", + }; + if let Err(e) = self.writer.lock().unwrap().write_line(tag, &line) { + tracing::warn!(server = %self.server_name, error = %e, "log file write failed"); + } + self.ring.push(RecordedLine { + stream, + line: line.clone(), + ts_unix_ms: ts, + }); + let _ = self.broadcast.send(LogLine { + subscription_id: 0, + name: self.server_name.clone(), + stream, + line, + ts_unix_ms: ts, + }); + } +} + +fn now_unix_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + #[cfg(test)] mod tests { use super::*; @@ -192,4 +251,20 @@ mod tests { assert_eq!(snap[0].line, "line3"); assert_eq!(snap[1].line, "line4"); } + + #[tokio::test] + async fn log_sink_records_and_broadcasts() { + let dir = tempdir().unwrap(); + let writer = RotatingLogWriter::open(&dir.path().join("s.log"), 1024, 3).unwrap(); + let sink = LogSink::new("s".to_string(), writer, 1024); + let mut rx = sink.broadcast.subscribe(); + sink.record(LogStream::Stdout, "hello".to_string()); + let got = tokio::time::timeout(std::time::Duration::from_millis(100), rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(got.line, "hello"); + assert_eq!(got.stream, LogStream::Stdout); + assert_eq!(sink.ring.snapshot_tail(None).len(), 1); + } } From f1b230615691f37c53403d0a87b929c2a18dc659 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 25 May 2026 11:40:19 +0200 Subject: [PATCH 16/42] feat(supervisor): RealChild + spawn_with_logs Append RealChild (real tokio::process::Child wrapper) and spawn_with_logs to child.rs. Uses nix::unistd::setpgid via tokio's re-exported pre_exec to create an own process group, and fires per-stream log pump tasks that drain stdout/stderr into the provided LogSink. terminate/kill signal the whole process group via kill(-pgid, SIG*). Co-Authored-By: Claude Sonnet 4.6 --- crates/xy-supervisor/src/child.rs | 119 ++++++++++++++++++++++++++++++ crates/xy-supervisor/src/lib.rs | 2 +- 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/crates/xy-supervisor/src/child.rs b/crates/xy-supervisor/src/child.rs index fd9cc15..eb9c361 100644 --- a/crates/xy-supervisor/src/child.rs +++ b/crates/xy-supervisor/src/child.rs @@ -71,6 +71,125 @@ impl ChildHandle for MockChild { } } +use crate::logs::LogSink; +use nix::sys::signal::{Signal, kill}; +use nix::unistd::Pid; +use std::process::Stdio; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::{Child as TokioChild, Command}; +use xy_protocol::{ServerConfig, rpc::LogStream}; + +pub struct RealChild { + pid: u32, + pgid: Pid, + child: Option, +} + +impl RealChild { + pub fn pgid(&self) -> Pid { + self.pgid + } +} + +#[async_trait::async_trait] +impl ChildHandle for RealChild { + fn pid(&self) -> u32 { + self.pid + } + + async fn wait(&mut self) -> std::io::Result> { + let child = self + .child + .as_mut() + .ok_or_else(|| std::io::Error::other("already waited"))?; + + let status = child.wait().await?; + + Ok(status.code()) + } + + fn terminate(&mut self) -> std::io::Result<()> { + kill(Pid::from_raw(-self.pgid.as_raw()), Signal::SIGTERM) + .map_err(|err| std::io::Error::other(err.to_string())) + } + + fn kill(&mut self) -> std::io::Result<()> { + kill(Pid::from_raw(-self.pgid.as_raw()), Signal::SIGKILL) + .map_err(|err| std::io::Error::other(err.to_string())) + } +} + +pub fn spawn_with_logs(cfg: &ServerConfig, sink: LogSink) -> std::io::Result { + let mut cmd = Command::new(&cfg.command); + + cmd.args(&cfg.args); + + for (k, v) in &cfg.env { + cmd.env(k, v); + } + + if let Some(dir) = &cfg.working_dir { + cmd.current_dir(dir); + } + + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + cmd.kill_on_drop(true); + + // Own process group so signals reach the whole tree. + unsafe { + cmd.pre_exec(|| { + nix::unistd::setpgid(Pid::from_raw(0), Pid::from_raw(0)) + .map_err(|err| std::io::Error::other(err.to_string())) + }); + } + + let mut child = cmd.spawn()?; + + let pid = child.id().ok_or_else(|| std::io::Error::other("no pid"))?; + let pgid = Pid::from_raw(pid as i32); + + if let Some(out) = child.stdout.take() { + spawn_pump(out, sink.clone(), LogStream::Stdout); + } + + if let Some(err) = child.stderr.take() { + spawn_pump(err, sink.clone(), LogStream::Stderr); + } + + Ok(RealChild { + pid, + pgid, + child: Some(child), + }) +} + +fn spawn_pump( + reader: R, + sink: LogSink, + stream: LogStream, +) { + tokio::spawn(async move { + let mut lines = BufReader::new(reader).lines(); + + loop { + match lines.next_line().await { + Ok(Some(line)) => sink.record(stream, line), + Ok(None) => break, + Err(err) => { + tracing::warn!( + server = %sink.server_name, + error = %err, + ?stream, + "log pump read error" + ); + break; + } + } + } + }); +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/xy-supervisor/src/lib.rs b/crates/xy-supervisor/src/lib.rs index 9a6df14..2241d41 100644 --- a/crates/xy-supervisor/src/lib.rs +++ b/crates/xy-supervisor/src/lib.rs @@ -7,7 +7,7 @@ pub mod policy; pub mod retry_window; pub use backoff::Backoff; -pub use child::{ChildHandle, MockChild, MockChildController}; +pub use child::{ChildHandle, MockChild, MockChildController, RealChild, spawn_with_logs}; pub use logs::{LogSink, RecordedLine, RingBuffer, RotatingLogWriter}; pub use policy::{RestartDecision, decide}; pub use retry_window::RetryWindow; From a3c979511e3e73a61375d1af829a6c6dae0f2632 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 25 May 2026 11:44:12 +0200 Subject: [PATCH 17/42] feat(supervisor): supervisor task with state machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One async task per managed server owns all state transitions via a tokio::select! loop over cmd_rx and wait_child. Includes RealSpawner and a smoke test covering the Start → Running → exit → Stopped → Shutdown happy path. Co-Authored-By: Claude Sonnet 4.6 --- crates/xy-supervisor/src/lib.rs | 4 + crates/xy-supervisor/src/logs.rs | 13 +- crates/xy-supervisor/src/supervisor.rs | 369 +++++++++++++++++++++++++ 3 files changed, 376 insertions(+), 10 deletions(-) create mode 100644 crates/xy-supervisor/src/supervisor.rs diff --git a/crates/xy-supervisor/src/lib.rs b/crates/xy-supervisor/src/lib.rs index 2241d41..10882e0 100644 --- a/crates/xy-supervisor/src/lib.rs +++ b/crates/xy-supervisor/src/lib.rs @@ -5,9 +5,13 @@ pub mod child; pub mod logs; pub mod policy; pub mod retry_window; +pub mod supervisor; pub use backoff::Backoff; pub use child::{ChildHandle, MockChild, MockChildController, RealChild, spawn_with_logs}; pub use logs::{LogSink, RecordedLine, RingBuffer, RotatingLogWriter}; pub use policy::{RestartDecision, decide}; pub use retry_window::RetryWindow; +pub use supervisor::{ + RealSpawner, Spawner, StartAck, StopAck, SupervisorCmd, SupervisorHandle, SupervisorTask, +}; diff --git a/crates/xy-supervisor/src/logs.rs b/crates/xy-supervisor/src/logs.rs index a343585..f80276a 100644 --- a/crates/xy-supervisor/src/logs.rs +++ b/crates/xy-supervisor/src/logs.rs @@ -135,11 +135,7 @@ pub struct LogSink { } impl LogSink { - pub fn new( - server_name: String, - writer: RotatingLogWriter, - ring_capacity_bytes: usize, - ) -> Self { + pub fn new(server_name: String, writer: RotatingLogWriter, ring_capacity_bytes: usize) -> Self { let (tx, _) = broadcast::channel(LOG_BROADCAST_CAP); Self { server_name, @@ -183,8 +179,8 @@ fn now_unix_ms() -> u64 { #[cfg(test)] mod tests { use super::*; - use tempfile::tempdir; use std::io::Read; + use tempfile::tempdir; #[test] fn writes_lines_with_tags() { @@ -194,10 +190,7 @@ mod tests { w.write_line("[out]", "hello").unwrap(); w.write_line("[err]", "boom").unwrap(); let mut s = String::new(); - File::open(&base) - .unwrap() - .read_to_string(&mut s) - .unwrap(); + File::open(&base).unwrap().read_to_string(&mut s).unwrap(); assert_eq!(s, "[out] hello\n[err] boom\n"); } diff --git a/crates/xy-supervisor/src/supervisor.rs b/crates/xy-supervisor/src/supervisor.rs new file mode 100644 index 0000000..e30a051 --- /dev/null +++ b/crates/xy-supervisor/src/supervisor.rs @@ -0,0 +1,369 @@ +use crate::{ + backoff::Backoff, + child::ChildHandle, + logs::LogSink, + policy::{RestartDecision, decide}, + retry_window::RetryWindow, +}; +use std::time::{Duration, Instant}; +use tokio::sync::{mpsc, oneshot, watch}; +use tokio::time::sleep; +use tracing::{debug, info, warn}; +use xy_protocol::{ServerConfig, ServerState}; + +pub enum SupervisorCmd { + Start { + ack: oneshot::Sender, + }, + Stop { + ack: oneshot::Sender, + }, + Restart { + ack: oneshot::Sender<()>, + }, + Reconfigure { + new: ServerConfig, + ack: oneshot::Sender<()>, + }, + Shutdown { + ack: oneshot::Sender<()>, + }, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum StartAck { + Started, + AlreadyRunning, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum StopAck { + Stopped, + NotRunning, +} + +#[derive(Clone)] +pub struct SupervisorHandle { + pub name: String, + pub tx: mpsc::Sender, + pub state: watch::Receiver, + pub log_sink: LogSink, +} + +#[async_trait::async_trait] +pub trait Spawner: Send + 'static { + type Child: ChildHandle; + async fn spawn(&self, cfg: &ServerConfig, sink: LogSink) -> std::io::Result; +} + +pub struct SupervisorTask { + cfg: ServerConfig, + log_sink: LogSink, + spawner: S, + state_tx: watch::Sender, + cmd_rx: mpsc::Receiver, + backoff: Backoff, + retry_window: RetryWindow, + restart_count: u32, + last_exit: Option, + started_at: Option, +} + +impl SupervisorTask { + pub fn new( + cfg: ServerConfig, + log_sink: LogSink, + spawner: S, + state_tx: watch::Sender, + cmd_rx: mpsc::Receiver, + ) -> Self { + let backoff = Backoff::new(cfg.restart.backoff_initial, cfg.restart.backoff_max); + let retry_window = + RetryWindow::new(Duration::from_secs(60), cfg.restart.max_retries_per_minute); + + Self { + cfg, + log_sink, + spawner, + state_tx, + cmd_rx, + backoff, + retry_window, + restart_count: 0, + last_exit: None, + started_at: None, + } + } + + fn set_state(&self, s: ServerState) { + let _ = self.state_tx.send(s); + } + + pub async fn run(mut self) { + let mut child: Option = None; + + loop { + tokio::select! { + cmd = self.cmd_rx.recv() => { + let Some(cmd) = cmd else { break; }; + + match cmd { + SupervisorCmd::Start { ack } => { + if child.is_some() { + let _ = ack.send(StartAck::AlreadyRunning); + } else { + match self.do_start().await { + Ok(c) => { + child = Some(c); + let _ = ack.send(StartAck::Started); + } + Err(err) => { + warn!(name = %self.cfg.name, error = %err, "spawn failed"); + self.set_state(ServerState::Failed); + let _ = ack.send(StartAck::Started); + } + } + } + } + SupervisorCmd::Stop { ack } => { + if let Some(c) = child.take() { + self.do_stop(c).await; + let _ = ack.send(StopAck::Stopped); + } else { + let _ = ack.send(StopAck::NotRunning); + } + } + SupervisorCmd::Restart { ack } => { + if let Some(c) = child.take() { + self.set_state(ServerState::Restarting); + self.do_stop(c).await; + } + + match self.do_start().await { + Ok(c) => child = Some(c), + Err(err) => { + warn!(name = %self.cfg.name, error = %err, "restart spawn failed"); + self.set_state(ServerState::Failed); + } + } + + let _ = ack.send(()); + } + SupervisorCmd::Reconfigure { new, ack } => { + self.cfg = new; + self.backoff = + Backoff::new(self.cfg.restart.backoff_initial, self.cfg.restart.backoff_max); + self.retry_window = RetryWindow::new( + Duration::from_secs(60), + self.cfg.restart.max_retries_per_minute, + ); + + let _ = ack.send(()); + } + SupervisorCmd::Shutdown { ack } => { + if let Some(c) = child.take() { + self.do_stop(c).await; + } + + let _ = ack.send(()); + return; + } + } + } + code = wait_child(&mut child) => { + child = None; + + self.last_exit = code; + + let now = Instant::now(); + + self.retry_window.record(now); + + let cap = self.retry_window.cap_reached(now); + let decision = decide(self.cfg.restart.policy, code, cap); + + debug!(name = %self.cfg.name, ?code, ?decision, "child exited"); + + match decision { + RestartDecision::StayStopped => { + self.started_at = None; + self.set_state(ServerState::Stopped); + } + RestartDecision::MarkFailed => { + self.started_at = None; + self.set_state(ServerState::Failed); + } + RestartDecision::Restart => { + self.set_state(ServerState::Restarting); + + let delay = self.backoff.next(); + + sleep(delay).await; + + match self.do_start().await { + Ok(c) => child = Some(c), + Err(err) => { + warn!(name = %self.cfg.name, error = %err, "restart spawn failed"); + self.set_state(ServerState::Failed); + } + } + } + } + } + } + } + } + + async fn do_start(&mut self) -> std::io::Result { + self.set_state(ServerState::Starting); + + let c = self.spawner.spawn(&self.cfg, self.log_sink.clone()).await?; + + self.restart_count = self.restart_count.saturating_add(1); + self.started_at = Some(Instant::now()); + self.backoff.reset(); + self.set_state(ServerState::Running); + + info!(name = %self.cfg.name, pid = c.pid(), "started"); + + Ok(c) + } + + async fn do_stop(&mut self, mut c: S::Child) { + self.set_state(ServerState::Stopping); + + let _ = c.terminate(); + + let grace = self.cfg.stop.grace; + + match tokio::time::timeout(grace, c.wait()).await { + Ok(_) => {} + Err(_) => { + let _ = c.kill(); + let _ = c.wait().await; + } + } + + self.started_at = None; + self.set_state(ServerState::Stopped); + } +} + +async fn wait_child(slot: &mut Option) -> Option { + match slot.as_mut() { + Some(c) => c.wait().await.ok().flatten(), + None => std::future::pending().await, + } +} + +pub struct RealSpawner; + +#[async_trait::async_trait] +impl Spawner for RealSpawner { + type Child = crate::child::RealChild; + + async fn spawn(&self, cfg: &ServerConfig, sink: LogSink) -> std::io::Result { + crate::child::spawn_with_logs(cfg, sink) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::child::MockChild; + use crate::logs::{LogSink, RotatingLogWriter}; + use std::sync::{Arc, Mutex}; + use tempfile::tempdir; + use xy_protocol::{RestartConfig, RestartPolicy, StopConfig}; + + struct QueueSpawner { + queue: Arc>>, + } + + #[async_trait::async_trait] + impl Spawner for QueueSpawner { + type Child = MockChild; + + async fn spawn(&self, _cfg: &ServerConfig, _sink: LogSink) -> std::io::Result { + let mut q = self.queue.lock().unwrap(); + Ok(q.remove(0)) + } + } + + fn cfg(name: &str, policy: RestartPolicy, max_retries: u32) -> ServerConfig { + ServerConfig { + name: name.to_string(), + command: "/bin/true".into(), + args: vec![], + port: 1, + env: Default::default(), + working_dir: None, + restart: RestartConfig { + policy, + backoff_initial: Duration::from_millis(1), + backoff_max: Duration::from_millis(1), + max_retries_per_minute: max_retries, + }, + stop: StopConfig { + grace: Duration::from_millis(50), + }, + } + } + + fn sink(name: &str) -> LogSink { + let dir = tempdir().unwrap(); + let writer = RotatingLogWriter::open(&dir.path().join("s.log"), 1024, 3).unwrap(); + std::mem::forget(dir); + LogSink::new(name.to_string(), writer, 1024) + } + + async fn wait_for(rx: &mut watch::Receiver, want: ServerState) { + let deadline = tokio::time::Instant::now() + Duration::from_secs(2); + loop { + if *rx.borrow() == want { + return; + } + tokio::select! { + _ = rx.changed() => {} + _ = tokio::time::sleep_until(deadline) => panic!("never reached {want:?}, last={:?}", *rx.borrow()), + } + } + } + + #[tokio::test] + async fn start_runs_to_running_and_stop_to_stopped() { + let (mock, mut ctl) = MockChild::new(1); + let queue = Arc::new(Mutex::new(vec![mock])); + let spawner = QueueSpawner { queue }; + + let (state_tx, mut state_rx) = watch::channel(ServerState::Stopped); + let (cmd_tx, cmd_rx) = mpsc::channel(8); + let task = SupervisorTask::new( + cfg("x", RestartPolicy::Never, 5), + sink("x"), + spawner, + state_tx, + cmd_rx, + ); + let h = tokio::spawn(task.run()); + + let (ack_tx, ack_rx) = oneshot::channel(); + cmd_tx + .send(SupervisorCmd::Start { ack: ack_tx }) + .await + .unwrap(); + assert_eq!(ack_rx.await.unwrap(), StartAck::Started); + wait_for(&mut state_rx, ServerState::Running).await; + + ctl.exit_tx.take().unwrap().send(Some(0)).unwrap(); + wait_for(&mut state_rx, ServerState::Stopped).await; + + let (ack_tx, ack_rx) = oneshot::channel(); + cmd_tx + .send(SupervisorCmd::Shutdown { ack: ack_tx }) + .await + .unwrap(); + ack_rx.await.unwrap(); + h.await.unwrap(); + } +} From 53f6b82f2b5a51023f3a8b052e49807f52e04ce0 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 25 May 2026 11:45:37 +0200 Subject: [PATCH 18/42] feat(ipc): JSON-RPC envelope types --- crates/xy-ipc/src/envelope.rs | 114 ++++++++++++++++++++++++++++++++++ crates/xy-ipc/src/lib.rs | 7 +++ 2 files changed, 121 insertions(+) create mode 100644 crates/xy-ipc/src/envelope.rs diff --git a/crates/xy-ipc/src/envelope.rs b/crates/xy-ipc/src/envelope.rs new file mode 100644 index 0000000..fa7d579 --- /dev/null +++ b/crates/xy-ipc/src/envelope.rs @@ -0,0 +1,114 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Request { + pub jsonrpc: String, + pub id: serde_json::Value, + pub method: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Notification { + pub jsonrpc: String, + pub method: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Response { + pub jsonrpc: String, + pub id: serde_json::Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RpcError { + pub code: i32, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum Incoming { + Request(Request), + Response(Response), + Notification(Notification), +} + +pub const JSONRPC_VERSION: &str = "2.0"; + +pub fn request(id: u64, method: &str, params: Option) -> Request { + Request { + jsonrpc: JSONRPC_VERSION.into(), + id: serde_json::json!(id), + method: method.into(), + params, + } +} + +pub fn notification(method: &str, params: Option) -> Notification { + Notification { + jsonrpc: JSONRPC_VERSION.into(), + method: method.into(), + params, + } +} + +pub fn ok_response(id: serde_json::Value, result: Value) -> Response { + Response { + jsonrpc: JSONRPC_VERSION.into(), + id, + result: Some(result), + error: None, + } +} + +pub fn err_response(id: serde_json::Value, code: i32, message: String) -> Response { + Response { + jsonrpc: JSONRPC_VERSION.into(), + id, + result: None, + error: Some(RpcError { + code, + message, + data: None, + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn request_round_trip() { + let r = request(7, "list", None); + let s = serde_json::to_string(&r).unwrap(); + let back: Request = serde_json::from_str(&s).unwrap(); + assert_eq!(back.method, "list"); + assert_eq!(back.id, serde_json::json!(7)); + } + + #[test] + fn incoming_is_response() { + let s = r#"{"jsonrpc":"2.0","id":1,"result":{"ok":true}}"#; + let i: Incoming = serde_json::from_str(s).unwrap(); + assert!(matches!(i, Incoming::Response(_))); + } + + #[test] + fn incoming_is_notification() { + let s = r#"{"jsonrpc":"2.0","method":"log","params":{}}"#; + let i: Incoming = serde_json::from_str(s).unwrap(); + assert!(matches!(i, Incoming::Notification(_))); + } +} diff --git a/crates/xy-ipc/src/lib.rs b/crates/xy-ipc/src/lib.rs index 2ad7623..59c3f78 100644 --- a/crates/xy-ipc/src/lib.rs +++ b/crates/xy-ipc/src/lib.rs @@ -1 +1,8 @@ //! JSON-RPC 2.0 over newline-delimited JSON on a Unix socket. + +pub mod envelope; + +pub use envelope::{ + err_response, notification, ok_response, request, Incoming, Notification, Request, Response, + RpcError, +}; From e58b6866eff975b41116ca741feb0a2924501be0 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 25 May 2026 11:45:50 +0200 Subject: [PATCH 19/42] feat(ipc): newline-delimited JSON framing --- crates/xy-ipc/src/framing.rs | 78 ++++++++++++++++++++++++++++++++++++ crates/xy-ipc/src/lib.rs | 2 + 2 files changed, 80 insertions(+) create mode 100644 crates/xy-ipc/src/framing.rs diff --git a/crates/xy-ipc/src/framing.rs b/crates/xy-ipc/src/framing.rs new file mode 100644 index 0000000..5deb74e --- /dev/null +++ b/crates/xy-ipc/src/framing.rs @@ -0,0 +1,78 @@ +use serde::Serialize; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::UnixStream; + +pub struct JsonFramed { + reader: BufReader, + writer: tokio::net::unix::OwnedWriteHalf, +} + +impl JsonFramed { + pub fn new(stream: UnixStream) -> Self { + let (r, w) = stream.into_split(); + Self { + reader: BufReader::new(r), + writer: w, + } + } + + pub async fn read( + &mut self, + ) -> std::io::Result> { + let mut buf = String::new(); + let n = self.reader.read_line(&mut buf).await?; + if n == 0 { + return Ok(None); + } + let v: T = serde_json::from_str(buf.trim_end()) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + Ok(Some(v)) + } + + pub async fn write(&mut self, value: &T) -> std::io::Result<()> { + let mut bytes = serde_json::to_vec(value) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + bytes.push(b'\n'); + self.writer.write_all(&bytes).await?; + self.writer.flush().await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::Deserialize; + + #[derive(Debug, Serialize, Deserialize, PartialEq)] + struct M { + x: u32, + name: String, + } + + #[tokio::test] + async fn round_trip_over_socket_pair() { + let (a, b) = UnixStream::pair().unwrap(); + let mut sa = JsonFramed::new(a); + let mut sb = JsonFramed::new(b); + sa.write(&M { + x: 1, + name: "hi".into(), + }) + .await + .unwrap(); + let got: Option = sb.read().await.unwrap(); + assert_eq!(got, Some(M { + x: 1, + name: "hi".into() + })); + } + + #[tokio::test] + async fn eof_returns_none() { + let (a, b) = UnixStream::pair().unwrap(); + drop(a); + let mut sb = JsonFramed::new(b); + let got: Option = sb.read().await.unwrap(); + assert!(got.is_none()); + } +} diff --git a/crates/xy-ipc/src/lib.rs b/crates/xy-ipc/src/lib.rs index 59c3f78..97963c7 100644 --- a/crates/xy-ipc/src/lib.rs +++ b/crates/xy-ipc/src/lib.rs @@ -1,8 +1,10 @@ //! JSON-RPC 2.0 over newline-delimited JSON on a Unix socket. pub mod envelope; +pub mod framing; pub use envelope::{ err_response, notification, ok_response, request, Incoming, Notification, Request, Response, RpcError, }; +pub use framing::JsonFramed; From fbfb1db4270857e870f979f844cadd0a454bf785 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 25 May 2026 11:47:11 +0200 Subject: [PATCH 20/42] feat(ipc): client with call + notification reader --- crates/xy-ipc/src/client.rs | 127 ++++++++++++++++++++++++++++++++++++ crates/xy-ipc/src/lib.rs | 2 + 2 files changed, 129 insertions(+) create mode 100644 crates/xy-ipc/src/client.rs diff --git a/crates/xy-ipc/src/client.rs b/crates/xy-ipc/src/client.rs new file mode 100644 index 0000000..3125247 --- /dev/null +++ b/crates/xy-ipc/src/client.rs @@ -0,0 +1,127 @@ +use crate::envelope::{Incoming, Notification, Request}; +use crate::framing::JsonFramed; +use serde::de::DeserializeOwned; +use serde::Serialize; +use std::path::Path; +use thiserror::Error; +use tokio::net::UnixStream; + +#[derive(Debug, Error)] +pub enum ClientError { + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("rpc error {code}: {message}")] + Rpc { code: i32, message: String }, + #[error("unexpected message kind from daemon")] + Unexpected, + #[error("daemon unreachable: {0}")] + Unreachable(std::io::Error), + #[error("serialization: {0}")] + Serde(#[from] serde_json::Error), +} + +pub struct Client { + framed: JsonFramed, + next_id: u64, +} + +impl Client { + pub async fn connect(socket_path: &Path) -> Result { + let stream = UnixStream::connect(socket_path) + .await + .map_err(ClientError::Unreachable)?; + Ok(Self { + framed: JsonFramed::new(stream), + next_id: 1, + }) + } + + pub async fn call( + &mut self, + method: &str, + params: &P, + ) -> Result { + let id = self.next_id; + self.next_id += 1; + + let params_val = serde_json::to_value(params)?; + let req = crate::envelope::request(id, method, Some(params_val)); + self.framed.write(&req).await?; + + loop { + let msg: Option = self.framed.read().await?; + let Some(msg) = msg else { + return Err(ClientError::Unreachable(std::io::Error::from( + std::io::ErrorKind::UnexpectedEof, + ))); + }; + + match msg { + Incoming::Response(r) => { + if r.id != serde_json::json!(id) { + return Err(ClientError::Unexpected); + } + if let Some(err) = r.error { + return Err(ClientError::Rpc { + code: err.code, + message: err.message, + }); + } + let result = r.result.unwrap_or(serde_json::Value::Null); + return Ok(serde_json::from_value(result)?); + } + Incoming::Notification(_) => continue, + Incoming::Request(_) => return Err(ClientError::Unexpected), + } + } + } + + pub async fn call_no_params( + &mut self, + method: &str, + ) -> Result { + let id = self.next_id; + self.next_id += 1; + + let req = Request { + jsonrpc: "2.0".into(), + id: serde_json::json!(id), + method: method.into(), + params: None, + }; + self.framed.write(&req).await?; + + let msg: Option = self.framed.read().await?; + let Some(Incoming::Response(r)) = msg else { + return Err(ClientError::Unexpected); + }; + + if let Some(err) = r.error { + return Err(ClientError::Rpc { + code: err.code, + message: err.message, + }); + } + + Ok(serde_json::from_value( + r.result.unwrap_or(serde_json::Value::Null), + )?) + } + + pub async fn read_notification(&mut self) -> Result, ClientError> { + loop { + let msg: Option = self.framed.read().await?; + match msg { + None => return Ok(None), + Some(Incoming::Notification(n)) => return Ok(Some(n)), + Some(Incoming::Response(_)) => continue, + Some(Incoming::Request(_)) => return Err(ClientError::Unexpected), + } + } + } + + pub async fn send_notification(&mut self, n: &Notification) -> Result<(), ClientError> { + self.framed.write(n).await?; + Ok(()) + } +} diff --git a/crates/xy-ipc/src/lib.rs b/crates/xy-ipc/src/lib.rs index 97963c7..bc716a6 100644 --- a/crates/xy-ipc/src/lib.rs +++ b/crates/xy-ipc/src/lib.rs @@ -1,8 +1,10 @@ //! JSON-RPC 2.0 over newline-delimited JSON on a Unix socket. +pub mod client; pub mod envelope; pub mod framing; +pub use client::{Client, ClientError}; pub use envelope::{ err_response, notification, ok_response, request, Incoming, Notification, Request, Response, RpcError, From b137f85a0cf0b85a5eb14e912e8a19e3999fc823 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 25 May 2026 11:47:24 +0200 Subject: [PATCH 21/42] feat(ipc): server bind + Connection wrapper --- crates/xy-ipc/src/lib.rs | 2 ++ crates/xy-ipc/src/server.rs | 67 +++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 crates/xy-ipc/src/server.rs diff --git a/crates/xy-ipc/src/lib.rs b/crates/xy-ipc/src/lib.rs index bc716a6..8629b3b 100644 --- a/crates/xy-ipc/src/lib.rs +++ b/crates/xy-ipc/src/lib.rs @@ -3,6 +3,7 @@ pub mod client; pub mod envelope; pub mod framing; +pub mod server; pub use client::{Client, ClientError}; pub use envelope::{ @@ -10,3 +11,4 @@ pub use envelope::{ RpcError, }; pub use framing::JsonFramed; +pub use server::{bind, Connection}; diff --git a/crates/xy-ipc/src/server.rs b/crates/xy-ipc/src/server.rs new file mode 100644 index 0000000..fcead9b --- /dev/null +++ b/crates/xy-ipc/src/server.rs @@ -0,0 +1,67 @@ +use crate::envelope::{Incoming, Notification, Response}; +use crate::framing::JsonFramed; +use std::path::Path; +use std::sync::Arc; +use tokio::net::{UnixListener, UnixStream}; +use tokio::sync::Mutex; + +pub struct Connection { + inner: Arc>, +} + +impl Connection { + pub fn new(stream: UnixStream) -> Self { + Self { + inner: Arc::new(Mutex::new(JsonFramed::new(stream))), + } + } + + pub async fn read_incoming(&self) -> std::io::Result> { + let mut g = self.inner.lock().await; + g.read::().await + } + + pub async fn write_response(&self, r: &Response) -> std::io::Result<()> { + let mut g = self.inner.lock().await; + g.write(r).await + } + + pub async fn write_notification(&self, n: &Notification) -> std::io::Result<()> { + let mut g = self.inner.lock().await; + g.write(n).await + } +} + +pub fn bind(socket_path: &Path) -> std::io::Result { + if socket_path.exists() { + std::fs::remove_file(socket_path)?; + } + + if let Some(parent) = socket_path.parent() { + std::fs::create_dir_all(parent)?; + } + + let listener = UnixListener::bind(socket_path)?; + + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(socket_path, std::fs::Permissions::from_mode(0o600))?; + + Ok(listener) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[tokio::test] + async fn bind_creates_socket_with_0600() { + let dir = tempdir().unwrap(); + let path = dir.path().join("x.sock"); + let _listener = bind(&path).unwrap(); + + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + } +} From 58c44e0b48cff1f115b551deef5a6977f9585664 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 25 May 2026 11:48:36 +0200 Subject: [PATCH 22/42] feat(xy): XDG path resolution --- crates/xy/src/main.rs | 7 ++++++- crates/xy/src/paths.rs | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 crates/xy/src/paths.rs diff --git a/crates/xy/src/main.rs b/crates/xy/src/main.rs index 662ece1..303b93b 100644 --- a/crates/xy/src/main.rs +++ b/crates/xy/src/main.rs @@ -1,3 +1,8 @@ +mod paths; +#[allow(dead_code)] +mod pidfile; + fn main() { - println!("xy: not implemented yet"); + let p = paths::Paths::resolve().unwrap(); + eprintln!("xy: socket would be at {}", p.socket.display()); } diff --git a/crates/xy/src/paths.rs b/crates/xy/src/paths.rs new file mode 100644 index 0000000..15df94d --- /dev/null +++ b/crates/xy/src/paths.rs @@ -0,0 +1,42 @@ +use etcetera::base_strategy::{BaseStrategy, Xdg}; +use std::path::PathBuf; + +#[derive(Debug, Clone)] +pub struct Paths { + pub config_dir: PathBuf, + pub state_dir: PathBuf, + pub log_dir: PathBuf, + pub socket: PathBuf, + pub pidfile: PathBuf, +} + +impl Paths { + pub fn resolve() -> std::io::Result { + let xdg = Xdg::new().map_err(std::io::Error::other)?; + let config_dir = xdg.config_dir().join("xy").join("servers"); + let state_dir = xdg.state_dir().unwrap_or_else(|| xdg.data_dir()).join("xy"); + let log_dir = state_dir.join("logs"); + let socket = std::env::var_os("XDG_RUNTIME_DIR") + .map(|p| PathBuf::from(p).join("xy.sock")) + .unwrap_or_else(|| state_dir.join("xy.sock")); + let pidfile = state_dir.join("xy.pid"); + Ok(Self { config_dir, state_dir, log_dir, socket, pidfile }) + } + + pub fn ensure_dirs(&self) -> std::io::Result<()> { + std::fs::create_dir_all(&self.state_dir)?; + std::fs::create_dir_all(&self.log_dir)?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolves_without_panicking() { + let p = Paths::resolve().unwrap(); + assert!(p.pidfile.starts_with(&p.state_dir)); + } +} From 49c006df106ef8ebc72c78617c783ed83a0b6237 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 25 May 2026 11:48:38 +0200 Subject: [PATCH 23/42] feat(xy): exclusive pidfile guard --- crates/xy/src/pidfile.rs | 46 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 crates/xy/src/pidfile.rs diff --git a/crates/xy/src/pidfile.rs b/crates/xy/src/pidfile.rs new file mode 100644 index 0000000..3bf6ee0 --- /dev/null +++ b/crates/xy/src/pidfile.rs @@ -0,0 +1,46 @@ +use std::fs::{File, OpenOptions}; +use std::io::Write; +use std::os::unix::fs::OpenOptionsExt; +use std::path::{Path, PathBuf}; + +#[derive(Debug)] +pub struct PidFile { + path: PathBuf, + _file: File, +} + +impl PidFile { + pub fn acquire(path: &Path) -> std::io::Result { + let mut f = OpenOptions::new() + .write(true).create_new(true).mode(0o600).open(path)?; + writeln!(f, "{}", std::process::id())?; + Ok(Self { path: path.to_path_buf(), _file: f }) + } +} + +impl Drop for PidFile { + fn drop(&mut self) { let _ = std::fs::remove_file(&self.path); } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn acquire_then_second_fails() { + let dir = tempdir().unwrap(); + let p = dir.path().join("x.pid"); + let _g = PidFile::acquire(&p).unwrap(); + let err = PidFile::acquire(&p).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists); + } + + #[test] + fn drop_removes_file() { + let dir = tempdir().unwrap(); + let p = dir.path().join("x.pid"); + { let _g = PidFile::acquire(&p).unwrap(); assert!(p.exists()); } + assert!(!p.exists()); + } +} From 71808783c45c16f97a75c22e850a10f608fe5981 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 25 May 2026 11:49:47 +0200 Subject: [PATCH 24/42] feat(xy): clap CLI scaffold --- crates/xy/src/cli/mod.rs | 10 +++++ crates/xy/src/daemon/mod.rs | 4 ++ crates/xy/src/main.rs | 77 +++++++++++++++++++++++++++++++++++-- 3 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 crates/xy/src/cli/mod.rs create mode 100644 crates/xy/src/daemon/mod.rs diff --git a/crates/xy/src/cli/mod.rs b/crates/xy/src/cli/mod.rs new file mode 100644 index 0000000..0b13e58 --- /dev/null +++ b/crates/xy/src/cli/mod.rs @@ -0,0 +1,10 @@ +use crate::paths::Paths; +use anyhow::{bail, Result}; + +pub async fn list(_p: Paths) -> Result { bail!("not implemented") } +pub async fn status(_p: Paths, _name: String) -> Result { bail!("not implemented") } +pub async fn start(_p: Paths, _all: bool, _name: Option) -> Result { bail!("not implemented") } +pub async fn stop(_p: Paths, _all: bool, _name: Option) -> Result { bail!("not implemented") } +pub async fn restart(_p: Paths, _all: bool, _name: Option) -> Result { bail!("not implemented") } +pub async fn reload(_p: Paths) -> Result { bail!("not implemented") } +pub async fn logs(_p: Paths, _name: String, _tail: Option, _follow: bool) -> Result { bail!("not implemented") } diff --git a/crates/xy/src/daemon/mod.rs b/crates/xy/src/daemon/mod.rs new file mode 100644 index 0000000..5e01e5c --- /dev/null +++ b/crates/xy/src/daemon/mod.rs @@ -0,0 +1,4 @@ +use crate::paths::Paths; +use anyhow::{bail, Result}; + +pub async fn run(_paths: Paths) -> Result<()> { bail!("not implemented") } diff --git a/crates/xy/src/main.rs b/crates/xy/src/main.rs index 303b93b..3145272 100644 --- a/crates/xy/src/main.rs +++ b/crates/xy/src/main.rs @@ -1,8 +1,77 @@ +use clap::{Parser, Subcommand}; + +mod cli; +mod daemon; mod paths; -#[allow(dead_code)] mod pidfile; -fn main() { - let p = paths::Paths::resolve().unwrap(); - eprintln!("xy: socket would be at {}", p.socket.display()); +#[derive(Debug, Parser)] +#[command(name = "xy", version, about = "HTTP MCP server supervisor")] +struct Cli { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Debug, Subcommand)] +enum Cmd { + /// Run the daemon in the foreground. + Daemon, + /// List all configured servers with state. + List, + /// Show detailed status for a single server. + Status { name: String }, + /// Start a server (or all configured servers with --all). + Start { + #[arg(long, conflicts_with = "name")] all: bool, + #[arg(required_unless_present = "all")] name: Option, + }, + /// Stop a server (or --all). + Stop { + #[arg(long, conflicts_with = "name")] all: bool, + #[arg(required_unless_present = "all")] name: Option, + }, + /// Restart a server (or --all). + Restart { + #[arg(long, conflicts_with = "name")] all: bool, + #[arg(required_unless_present = "all")] name: Option, + }, + /// Re-read config dir and reconcile running servers. + Reload, + /// Stream a server's log. + Logs { + name: String, + #[arg(long)] tail: Option, + #[arg(short = 'f', long)] follow: bool, + }, +} + +#[tokio::main] +async fn main() -> std::process::ExitCode { + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"))) + .with_writer(std::io::stderr) + .init(); + + let cli = Cli::parse(); + let paths = match paths::Paths::resolve() { + Ok(p) => p, + Err(e) => { eprintln!("xy: failed to resolve XDG paths: {e}"); return std::process::ExitCode::from(3); } + }; + + let result: anyhow::Result = match cli.cmd { + Cmd::Daemon => daemon::run(paths).await.map(|_| 0), + Cmd::List => cli::list(paths).await, + Cmd::Status { name } => cli::status(paths, name).await, + Cmd::Start { all, name } => cli::start(paths, all, name).await, + Cmd::Stop { all, name } => cli::stop(paths, all, name).await, + Cmd::Restart { all, name } => cli::restart(paths, all, name).await, + Cmd::Reload => cli::reload(paths).await, + Cmd::Logs { name, tail, follow } => cli::logs(paths, name, tail, follow).await, + }; + + match result { + Ok(code) => std::process::ExitCode::from(code as u8), + Err(e) => { eprintln!("xy: {e:#}"); std::process::ExitCode::from(1) } + } } From d7aa543ac0c49a2605332c138ee3042ac5a85782 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 25 May 2026 11:49:58 +0200 Subject: [PATCH 25/42] feat(xy): daemon Registry with config-hash entries --- crates/xy/src/daemon/mod.rs | 2 ++ crates/xy/src/daemon/registry.rs | 39 ++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 crates/xy/src/daemon/registry.rs diff --git a/crates/xy/src/daemon/mod.rs b/crates/xy/src/daemon/mod.rs index 5e01e5c..3e003fe 100644 --- a/crates/xy/src/daemon/mod.rs +++ b/crates/xy/src/daemon/mod.rs @@ -1,4 +1,6 @@ use crate::paths::Paths; use anyhow::{bail, Result}; +pub mod registry; + pub async fn run(_paths: Paths) -> Result<()> { bail!("not implemented") } diff --git a/crates/xy/src/daemon/registry.rs b/crates/xy/src/daemon/registry.rs new file mode 100644 index 0000000..0fd9b45 --- /dev/null +++ b/crates/xy/src/daemon/registry.rs @@ -0,0 +1,39 @@ +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; +use xy_supervisor::SupervisorHandle; + +#[derive(Clone)] +pub struct Entry { + pub handle: SupervisorHandle, + pub config_hash: u64, +} + +#[derive(Clone, Default)] +pub struct Registry { + inner: Arc>>, +} + +impl Registry { + pub fn new() -> Self { Self::default() } + pub async fn insert(&self, name: String, entry: Entry) { + self.inner.write().await.insert(name, entry); + } + pub async fn remove(&self, name: &str) -> Option { + self.inner.write().await.remove(name) + } + pub async fn get(&self, name: &str) -> Option { + self.inner.read().await.get(name).cloned() + } + pub async fn names(&self) -> Vec { + let g = self.inner.read().await; + let mut v: Vec = g.keys().cloned().collect(); + v.sort(); v + } + pub async fn snapshot(&self) -> Vec<(String, Entry)> { + let g = self.inner.read().await; + let mut v: Vec<_> = g.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); + v.sort_by(|a, b| a.0.cmp(&b.0)); + v + } +} From 3ab982aea188409939931607df04ac19dcfaea60 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 25 May 2026 11:52:41 +0200 Subject: [PATCH 26/42] feat(xy): daemon boot + accept loop + graceful shutdown Co-Authored-By: Claude Sonnet 4.6 --- crates/xy/src/daemon/handlers.rs | 8 ++ crates/xy/src/daemon/mod.rs | 136 ++++++++++++++++++++++++++++++- crates/xy/src/daemon/shutdown.rs | 45 ++++++++++ 3 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 crates/xy/src/daemon/handlers.rs create mode 100644 crates/xy/src/daemon/shutdown.rs diff --git a/crates/xy/src/daemon/handlers.rs b/crates/xy/src/daemon/handlers.rs new file mode 100644 index 0000000..02e743f --- /dev/null +++ b/crates/xy/src/daemon/handlers.rs @@ -0,0 +1,8 @@ +use crate::daemon::registry::Registry; +use crate::paths::Paths; +use std::sync::Arc; +use xy_ipc::Connection; + +pub async fn serve(_conn: Arc, _reg: Registry, _paths: Paths) -> std::io::Result<()> { + Ok(()) +} diff --git a/crates/xy/src/daemon/mod.rs b/crates/xy/src/daemon/mod.rs index 3e003fe..98bbb22 100644 --- a/crates/xy/src/daemon/mod.rs +++ b/crates/xy/src/daemon/mod.rs @@ -1,6 +1,138 @@ use crate::paths::Paths; -use anyhow::{bail, Result}; +use crate::pidfile::PidFile; +use anyhow::{Context, Result}; +use std::sync::{Arc, OnceLock}; +use tokio::sync::{mpsc, oneshot, watch}; +use tracing::{error, info}; +use xy_ipc::{Connection, bind}; +use xy_protocol::{ServerConfig, ServerState, kdl_parse::load_all_configs}; +use xy_supervisor::{ + logs::{LogSink, RotatingLogWriter}, + supervisor::{RealSpawner, SupervisorCmd, SupervisorHandle, SupervisorTask}, +}; +pub mod handlers; pub mod registry; +pub mod shutdown; -pub async fn run(_paths: Paths) -> Result<()> { bail!("not implemented") } +const LOG_FILE_MAX_BYTES: u64 = 10 * 1024 * 1024; +const LOG_FILE_KEEP: usize = 5; +const RING_BUFFER_BYTES: usize = 1024 * 1024; + +pub static PATHS: OnceLock = OnceLock::new(); + +pub fn config_hash(cfg: &ServerConfig) -> u64 { + use std::hash::{Hash, Hasher}; + + let mut h = std::collections::hash_map::DefaultHasher::new(); + + serde_json::to_string(cfg).unwrap().hash(&mut h); + + h.finish() +} + +pub fn spawn_supervisor(paths: &Paths, cfg: ServerConfig) -> Result { + let log_path = paths.log_dir.join(format!("{}.log", cfg.name)); + + let writer = RotatingLogWriter::open(&log_path, LOG_FILE_MAX_BYTES, LOG_FILE_KEEP) + .with_context(|| format!("open log file {}", log_path.display()))?; + + let sink = LogSink::new(cfg.name.clone(), writer, RING_BUFFER_BYTES); + + let (state_tx, state_rx) = watch::channel(ServerState::Stopped); + let (cmd_tx, cmd_rx) = mpsc::channel(16); + + let name = cfg.name.clone(); + + let task = SupervisorTask::new(cfg, sink.clone(), RealSpawner, state_tx, cmd_rx); + + tokio::spawn(task.run()); + + Ok(SupervisorHandle { + name, + tx: cmd_tx, + state: state_rx, + log_sink: sink, + }) +} + +pub async fn run(paths: Paths) -> Result<()> { + paths.ensure_dirs().context("create state dirs")?; + + let _pid = + PidFile::acquire(&paths.pidfile).context("another xy daemon appears to be running")?; + + let listener = bind(&paths.socket).context("bind unix socket")?; + + let _ = PATHS.set(paths.clone()); + + info!(socket = %paths.socket.display(), "daemon listening"); + + let configs = load_all_configs(&paths.config_dir).context("load configs")?; + + let registry = registry::Registry::new(); + + for cfg in configs { + let hash = config_hash(&cfg); + + let handle = spawn_supervisor(&paths, cfg)?; + + let name = handle.name.clone(); + + registry + .insert( + name.clone(), + registry::Entry { + handle: handle.clone(), + config_hash: hash, + }, + ) + .await; + + let (ack_tx, ack_rx) = oneshot::channel(); + + if handle + .tx + .send(SupervisorCmd::Start { ack: ack_tx }) + .await + .is_ok() + { + let _ = ack_rx.await; + } + } + + let registry_for_shutdown = registry.clone(); + + let shutdown_signal = shutdown::install(); + + let accept = async { + loop { + let (stream, _addr) = match listener.accept().await { + Ok(p) => p, + Err(err) => { + error!(error = %err, "accept failed"); + continue; + } + }; + + let conn = Arc::new(Connection::new(stream)); + let reg = registry.clone(); + let paths_clone = paths.clone(); + + tokio::spawn(async move { + if let Err(err) = handlers::serve(conn, reg, paths_clone).await { + error!(error = %err, "connection ended with error"); + } + }); + } + }; + + tokio::select! { + _ = accept => {} + _ = shutdown_signal => { info!("shutdown signal received"); } + } + + shutdown::shutdown_all(registry_for_shutdown).await; + + Ok(()) +} diff --git a/crates/xy/src/daemon/shutdown.rs b/crates/xy/src/daemon/shutdown.rs new file mode 100644 index 0000000..0b60ac8 --- /dev/null +++ b/crates/xy/src/daemon/shutdown.rs @@ -0,0 +1,45 @@ +use crate::daemon::registry::Registry; +use tokio::signal::unix::{SignalKind, signal}; +use tokio::sync::oneshot; +use xy_supervisor::supervisor::SupervisorCmd; + +pub fn install() -> impl std::future::Future { + let mut term = signal(SignalKind::terminate()).expect("install SIGTERM handler"); + let mut int = signal(SignalKind::interrupt()).expect("install SIGINT handler"); + + async move { + tokio::select! { + _ = term.recv() => {} + _ = int.recv() => {} + } + } +} + +pub async fn shutdown_all(reg: Registry) { + let snapshot = reg.snapshot().await; + + let mut acks = Vec::new(); + + for (_name, entry) in &snapshot { + let (tx, rx) = oneshot::channel(); + + if entry + .handle + .tx + .send(SupervisorCmd::Shutdown { ack: tx }) + .await + .is_ok() + { + acks.push(rx); + } + } + + let deadline = tokio::time::Duration::from_secs(30); + + let _ = tokio::time::timeout(deadline, async { + for rx in acks { + let _ = rx.await; + } + }) + .await; +} From 736e6d18542435934f57d5f58aa6f49532cc462f Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 25 May 2026 11:54:52 +0200 Subject: [PATCH 27/42] feat(xy): RPC handlers for list/status/start/stop/restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-connection JSON-RPC dispatch in daemon/handlers.rs — list, status, start, stop, and restart are fully implemented; reload, logs, and logs_cancel are stubbed with -32601 for later tasks. Co-Authored-By: Claude Sonnet 4.6 --- crates/xy/src/daemon/handlers.rs | 244 ++++++++++++++++++++++++++++++- 1 file changed, 242 insertions(+), 2 deletions(-) diff --git a/crates/xy/src/daemon/handlers.rs b/crates/xy/src/daemon/handlers.rs index 02e743f..0566ead 100644 --- a/crates/xy/src/daemon/handlers.rs +++ b/crates/xy/src/daemon/handlers.rs @@ -1,8 +1,248 @@ use crate::daemon::registry::Registry; use crate::paths::Paths; use std::sync::Arc; +use tokio::sync::oneshot; use xy_ipc::Connection; +use xy_ipc::envelope::{Incoming, Request, Response, err_response, ok_response}; +use xy_protocol::RpcErrorCode; +use xy_protocol::rpc::{ + NameOrAll, RestartResult, ServerSummary, StartResult, StatusDetail, StopResult, methods, +}; +use xy_supervisor::supervisor::{StartAck, StopAck, SupervisorCmd}; -pub async fn serve(_conn: Arc, _reg: Registry, _paths: Paths) -> std::io::Result<()> { - Ok(()) +pub async fn serve(conn: Arc, reg: Registry, _paths: Paths) -> std::io::Result<()> { + loop { + let Some(incoming) = conn.read_incoming().await? else { + return Ok(()); + }; + + match incoming { + Incoming::Request(req) => { + let resp = handle_request(req, ®).await; + + conn.write_response(&resp).await?; + } + _ => continue, + } + } +} + +struct ApiError { + code: i32, + message: String, +} + +impl ApiError { + fn rpc(code: RpcErrorCode, msg: impl Into) -> Self { + Self { + code: code.as_i32(), + message: msg.into(), + } + } +} + +async fn handle_request(req: Request, reg: &Registry) -> Response { + let id = req.id.clone(); + let method = req.method.as_str(); + let params = req.params.unwrap_or(serde_json::Value::Null); + + match method { + methods::LIST => match list(reg).await { + Ok(v) => ok_response(id, serde_json::to_value(v).unwrap()), + Err(err) => err_response(id, err.code, err.message), + }, + methods::STATUS => { + let p: xy_protocol::rpc::StatusParams = match serde_json::from_value(params) { + Ok(p) => p, + Err(err) => return err_response(id, -32602, format!("invalid params: {err}")), + }; + + match status(reg, &p.name).await { + Ok(v) => ok_response(id, serde_json::to_value(v).unwrap()), + Err(err) => err_response(id, err.code, err.message), + } + } + methods::START => dispatch_lifecycle(id, params, reg, Op::Start).await, + methods::STOP => dispatch_lifecycle(id, params, reg, Op::Stop).await, + methods::RESTART => dispatch_lifecycle(id, params, reg, Op::Restart).await, + methods::RELOAD => err_response(id, -32601, "reload not yet implemented".into()), + methods::LOGS => err_response(id, -32601, "logs not yet implemented".into()), + methods::LOGS_CANCEL => err_response(id, -32601, "logs_cancel not yet implemented".into()), + other => err_response(id, -32601, format!("unknown method `{other}`")), + } +} + +async fn list(reg: &Registry) -> Result, ApiError> { + let mut out = Vec::new(); + + for (name, entry) in reg.snapshot().await { + out.push(ServerSummary { + name, + state: *entry.handle.state.borrow(), + pid: None, + port: 0, + uptime_secs: None, + restart_count: 0, + last_exit: None, + }); + } + + Ok(out) +} + +async fn status(reg: &Registry, name: &str) -> Result { + let Some(entry) = reg.get(name).await else { + return Err(ApiError::rpc( + RpcErrorCode::ServerNotFound, + format!("no such server `{name}`"), + )); + }; + + Ok(StatusDetail { + summary: ServerSummary { + name: entry.handle.name.clone(), + state: *entry.handle.state.borrow(), + pid: None, + port: 0, + uptime_secs: None, + restart_count: 0, + last_exit: None, + }, + recent_transitions: Vec::new(), + }) +} + +enum Op { + Start, + Stop, + Restart, +} + +async fn dispatch_lifecycle( + id: serde_json::Value, + params: serde_json::Value, + reg: &Registry, + op: Op, +) -> Response { + let p: NameOrAll = match serde_json::from_value(params) { + Ok(p) => p, + Err(err) => return err_response(id, -32602, format!("invalid params: {err}")), + }; + + let targets: Vec = match p { + NameOrAll::All { all } if all => reg.names().await, + NameOrAll::Name { name } => vec![name], + NameOrAll::All { .. } => return err_response(id, -32602, "must set all=true".into()), + }; + + match op { + Op::Start => { + let mut started = Vec::new(); + let mut already = Vec::new(); + + for name in targets { + let Some(entry) = reg.get(&name).await else { + return err_response( + id, + RpcErrorCode::ServerNotFound.as_i32(), + format!("no such server `{name}`"), + ); + }; + + let (tx, rx) = oneshot::channel(); + + let _ = entry.handle.tx.send(SupervisorCmd::Start { ack: tx }).await; + + match rx.await { + Ok(StartAck::Started) => started.push(name), + Ok(StartAck::AlreadyRunning) => already.push(name), + Err(_) => { + return err_response( + id, + RpcErrorCode::SpawnFailed.as_i32(), + format!("supervisor for `{name}` dropped"), + ); + } + } + } + + ok_response( + id, + serde_json::to_value(StartResult { + started, + already_running: already, + }) + .unwrap(), + ) + } + Op::Stop => { + let mut stopped = Vec::new(); + let mut not_running = Vec::new(); + + for name in targets { + let Some(entry) = reg.get(&name).await else { + return err_response( + id, + RpcErrorCode::ServerNotFound.as_i32(), + format!("no such server `{name}`"), + ); + }; + + let (tx, rx) = oneshot::channel(); + + let _ = entry.handle.tx.send(SupervisorCmd::Stop { ack: tx }).await; + + match rx.await { + Ok(StopAck::Stopped) => stopped.push(name), + Ok(StopAck::NotRunning) => not_running.push(name), + Err(_) => { + return err_response( + id, + RpcErrorCode::SpawnFailed.as_i32(), + format!("supervisor for `{name}` dropped"), + ); + } + } + } + + ok_response( + id, + serde_json::to_value(StopResult { + stopped, + not_running, + }) + .unwrap(), + ) + } + Op::Restart => { + let mut restarted = Vec::new(); + + for name in targets { + let Some(entry) = reg.get(&name).await else { + return err_response( + id, + RpcErrorCode::ServerNotFound.as_i32(), + format!("no such server `{name}`"), + ); + }; + + let (tx, rx) = oneshot::channel(); + + let _ = entry + .handle + .tx + .send(SupervisorCmd::Restart { ack: tx }) + .await; + + let _ = rx.await; + + restarted.push(name); + } + + ok_response( + id, + serde_json::to_value(RestartResult { restarted }).unwrap(), + ) + } + } } From c679465f12b07ab01f04d286721c66ee2aa617f1 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 25 May 2026 11:56:45 +0200 Subject: [PATCH 28/42] feat(xy): reload handler with diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the `reload` JSON-RPC method: diffs the on-disk config dir against the in-memory registry and reconciles — stops removed servers, restarts changed servers (shutdown-then-respawn), and starts new ones. Co-Authored-By: Claude Sonnet 4.6 --- crates/xy/src/cli/mod.rs | 30 +++++--- crates/xy/src/daemon/handlers.rs | 117 ++++++++++++++++++++++++++++++- crates/xy/src/daemon/registry.rs | 7 +- crates/xy/src/main.rs | 40 +++++++---- crates/xy/src/paths.rs | 8 ++- crates/xy/src/pidfile.rs | 19 +++-- 6 files changed, 193 insertions(+), 28 deletions(-) diff --git a/crates/xy/src/cli/mod.rs b/crates/xy/src/cli/mod.rs index 0b13e58..7087111 100644 --- a/crates/xy/src/cli/mod.rs +++ b/crates/xy/src/cli/mod.rs @@ -1,10 +1,24 @@ use crate::paths::Paths; -use anyhow::{bail, Result}; +use anyhow::{Result, bail}; -pub async fn list(_p: Paths) -> Result { bail!("not implemented") } -pub async fn status(_p: Paths, _name: String) -> Result { bail!("not implemented") } -pub async fn start(_p: Paths, _all: bool, _name: Option) -> Result { bail!("not implemented") } -pub async fn stop(_p: Paths, _all: bool, _name: Option) -> Result { bail!("not implemented") } -pub async fn restart(_p: Paths, _all: bool, _name: Option) -> Result { bail!("not implemented") } -pub async fn reload(_p: Paths) -> Result { bail!("not implemented") } -pub async fn logs(_p: Paths, _name: String, _tail: Option, _follow: bool) -> Result { bail!("not implemented") } +pub async fn list(_p: Paths) -> Result { + bail!("not implemented") +} +pub async fn status(_p: Paths, _name: String) -> Result { + bail!("not implemented") +} +pub async fn start(_p: Paths, _all: bool, _name: Option) -> Result { + bail!("not implemented") +} +pub async fn stop(_p: Paths, _all: bool, _name: Option) -> Result { + bail!("not implemented") +} +pub async fn restart(_p: Paths, _all: bool, _name: Option) -> Result { + bail!("not implemented") +} +pub async fn reload(_p: Paths) -> Result { + bail!("not implemented") +} +pub async fn logs(_p: Paths, _name: String, _tail: Option, _follow: bool) -> Result { + bail!("not implemented") +} diff --git a/crates/xy/src/daemon/handlers.rs b/crates/xy/src/daemon/handlers.rs index 0566ead..53059b2 100644 --- a/crates/xy/src/daemon/handlers.rs +++ b/crates/xy/src/daemon/handlers.rs @@ -65,7 +65,10 @@ async fn handle_request(req: Request, reg: &Registry) -> Response { methods::START => dispatch_lifecycle(id, params, reg, Op::Start).await, methods::STOP => dispatch_lifecycle(id, params, reg, Op::Stop).await, methods::RESTART => dispatch_lifecycle(id, params, reg, Op::Restart).await, - methods::RELOAD => err_response(id, -32601, "reload not yet implemented".into()), + methods::RELOAD => match reload(reg).await { + Ok(v) => ok_response(id, serde_json::to_value(v).unwrap()), + Err(e) => err_response(id, e.code, e.message), + }, methods::LOGS => err_response(id, -32601, "logs not yet implemented".into()), methods::LOGS_CANCEL => err_response(id, -32601, "logs_cancel not yet implemented".into()), other => err_response(id, -32601, format!("unknown method `{other}`")), @@ -246,3 +249,115 @@ async fn dispatch_lifecycle( } } } + +use xy_protocol::rpc::ReloadResult; + +async fn reload(reg: &Registry) -> Result { + let paths = crate::daemon::PATHS.get().ok_or_else(|| { + ApiError::rpc(RpcErrorCode::ConfigInvalid, "daemon paths not initialized") + })?; + + let new_configs = xy_protocol::kdl_parse::load_all_configs(&paths.config_dir) + .map_err(|err| ApiError::rpc(RpcErrorCode::ConfigInvalid, err.to_string()))?; + + use std::collections::HashMap; + + let new_by_name: HashMap = new_configs + .into_iter() + .map(|c| (c.name.clone(), c)) + .collect(); + + let existing_names: Vec = reg.names().await; + + let mut added = Vec::new(); + let mut removed = Vec::new(); + let mut changed = Vec::new(); + let mut unchanged = Vec::new(); + + for name in &existing_names { + if !new_by_name.contains_key(name) { + if let Some(entry) = reg.remove(name).await { + let (tx, rx) = oneshot::channel(); + + let _ = entry + .handle + .tx + .send(SupervisorCmd::Shutdown { ack: tx }) + .await; + + let _ = rx.await; + + removed.push(name.clone()); + } + } + } + + for (name, cfg) in new_by_name { + let new_hash = crate::daemon::config_hash(&cfg); + + match reg.get(&name).await { + None => { + let handle = crate::daemon::spawn_supervisor(paths, cfg) + .map_err(|err| ApiError::rpc(RpcErrorCode::SpawnFailed, err.to_string()))?; + + reg.insert( + name.clone(), + crate::daemon::registry::Entry { + handle: handle.clone(), + config_hash: new_hash, + }, + ) + .await; + + let (tx, rx) = oneshot::channel(); + + let _ = handle.tx.send(SupervisorCmd::Start { ack: tx }).await; + + let _ = rx.await; + + added.push(name); + } + Some(entry) if entry.config_hash != new_hash => { + let (tx, rx) = oneshot::channel(); + + let _ = entry + .handle + .tx + .send(SupervisorCmd::Shutdown { ack: tx }) + .await; + + let _ = rx.await; + + reg.remove(&name).await; + + let handle = crate::daemon::spawn_supervisor(paths, cfg) + .map_err(|err| ApiError::rpc(RpcErrorCode::SpawnFailed, err.to_string()))?; + + reg.insert( + name.clone(), + crate::daemon::registry::Entry { + handle: handle.clone(), + config_hash: new_hash, + }, + ) + .await; + + let (tx, rx) = oneshot::channel(); + + let _ = handle.tx.send(SupervisorCmd::Start { ack: tx }).await; + + let _ = rx.await; + + changed.push(name); + } + Some(_) => unchanged.push(name), + } + } + + Ok(ReloadResult { + added, + removed, + changed, + unchanged, + }) +} diff --git a/crates/xy/src/daemon/registry.rs b/crates/xy/src/daemon/registry.rs index 0fd9b45..7861704 100644 --- a/crates/xy/src/daemon/registry.rs +++ b/crates/xy/src/daemon/registry.rs @@ -15,7 +15,9 @@ pub struct Registry { } impl Registry { - pub fn new() -> Self { Self::default() } + pub fn new() -> Self { + Self::default() + } pub async fn insert(&self, name: String, entry: Entry) { self.inner.write().await.insert(name, entry); } @@ -28,7 +30,8 @@ impl Registry { pub async fn names(&self) -> Vec { let g = self.inner.read().await; let mut v: Vec = g.keys().cloned().collect(); - v.sort(); v + v.sort(); + v } pub async fn snapshot(&self) -> Vec<(String, Entry)> { let g = self.inner.read().await; diff --git a/crates/xy/src/main.rs b/crates/xy/src/main.rs index 3145272..53ad0db 100644 --- a/crates/xy/src/main.rs +++ b/crates/xy/src/main.rs @@ -22,41 +22,54 @@ enum Cmd { Status { name: String }, /// Start a server (or all configured servers with --all). Start { - #[arg(long, conflicts_with = "name")] all: bool, - #[arg(required_unless_present = "all")] name: Option, + #[arg(long, conflicts_with = "name")] + all: bool, + #[arg(required_unless_present = "all")] + name: Option, }, /// Stop a server (or --all). Stop { - #[arg(long, conflicts_with = "name")] all: bool, - #[arg(required_unless_present = "all")] name: Option, + #[arg(long, conflicts_with = "name")] + all: bool, + #[arg(required_unless_present = "all")] + name: Option, }, /// Restart a server (or --all). Restart { - #[arg(long, conflicts_with = "name")] all: bool, - #[arg(required_unless_present = "all")] name: Option, + #[arg(long, conflicts_with = "name")] + all: bool, + #[arg(required_unless_present = "all")] + name: Option, }, /// Re-read config dir and reconcile running servers. Reload, /// Stream a server's log. Logs { name: String, - #[arg(long)] tail: Option, - #[arg(short = 'f', long)] follow: bool, + #[arg(long)] + tail: Option, + #[arg(short = 'f', long)] + follow: bool, }, } #[tokio::main] async fn main() -> std::process::ExitCode { tracing_subscriber::fmt() - .with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"))) + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) .with_writer(std::io::stderr) .init(); let cli = Cli::parse(); let paths = match paths::Paths::resolve() { Ok(p) => p, - Err(e) => { eprintln!("xy: failed to resolve XDG paths: {e}"); return std::process::ExitCode::from(3); } + Err(e) => { + eprintln!("xy: failed to resolve XDG paths: {e}"); + return std::process::ExitCode::from(3); + } }; let result: anyhow::Result = match cli.cmd { @@ -72,6 +85,9 @@ async fn main() -> std::process::ExitCode { match result { Ok(code) => std::process::ExitCode::from(code as u8), - Err(e) => { eprintln!("xy: {e:#}"); std::process::ExitCode::from(1) } + Err(e) => { + eprintln!("xy: {e:#}"); + std::process::ExitCode::from(1) + } } } diff --git a/crates/xy/src/paths.rs b/crates/xy/src/paths.rs index 15df94d..fef2d81 100644 --- a/crates/xy/src/paths.rs +++ b/crates/xy/src/paths.rs @@ -20,7 +20,13 @@ impl Paths { .map(|p| PathBuf::from(p).join("xy.sock")) .unwrap_or_else(|| state_dir.join("xy.sock")); let pidfile = state_dir.join("xy.pid"); - Ok(Self { config_dir, state_dir, log_dir, socket, pidfile }) + Ok(Self { + config_dir, + state_dir, + log_dir, + socket, + pidfile, + }) } pub fn ensure_dirs(&self) -> std::io::Result<()> { diff --git a/crates/xy/src/pidfile.rs b/crates/xy/src/pidfile.rs index 3bf6ee0..a7ff1dc 100644 --- a/crates/xy/src/pidfile.rs +++ b/crates/xy/src/pidfile.rs @@ -12,14 +12,22 @@ pub struct PidFile { impl PidFile { pub fn acquire(path: &Path) -> std::io::Result { let mut f = OpenOptions::new() - .write(true).create_new(true).mode(0o600).open(path)?; + .write(true) + .create_new(true) + .mode(0o600) + .open(path)?; writeln!(f, "{}", std::process::id())?; - Ok(Self { path: path.to_path_buf(), _file: f }) + Ok(Self { + path: path.to_path_buf(), + _file: f, + }) } } impl Drop for PidFile { - fn drop(&mut self) { let _ = std::fs::remove_file(&self.path); } + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } } #[cfg(test)] @@ -40,7 +48,10 @@ mod tests { fn drop_removes_file() { let dir = tempdir().unwrap(); let p = dir.path().join("x.pid"); - { let _g = PidFile::acquire(&p).unwrap(); assert!(p.exists()); } + { + let _g = PidFile::acquire(&p).unwrap(); + assert!(p.exists()); + } assert!(!p.exists()); } } From b434c636a6ead1a682108a0b9a6decce5f7a3f42 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 25 May 2026 11:59:46 +0200 Subject: [PATCH 29/42] feat(xy): logs streaming via subscription notifications Implement per-connection ConnState tracking active subscriptions, and the logs/logs_cancel RPC handlers. Snapshot-only streams terminate with a log_end notification; follow streams forward broadcast lines until cancelled or connection close. Co-Authored-By: Claude Sonnet 4.6 --- crates/xy/src/daemon/handlers.rs | 158 +++++++++++++++++++++++++++++-- 1 file changed, 148 insertions(+), 10 deletions(-) diff --git a/crates/xy/src/daemon/handlers.rs b/crates/xy/src/daemon/handlers.rs index 53059b2..a77b707 100644 --- a/crates/xy/src/daemon/handlers.rs +++ b/crates/xy/src/daemon/handlers.rs @@ -1,28 +1,52 @@ use crate::daemon::registry::Registry; use crate::paths::Paths; +use std::collections::HashMap; use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use tokio::sync::Mutex; use tokio::sync::oneshot; +use tokio::task::JoinHandle; use xy_ipc::Connection; use xy_ipc::envelope::{Incoming, Request, Response, err_response, ok_response}; use xy_protocol::RpcErrorCode; use xy_protocol::rpc::{ - NameOrAll, RestartResult, ServerSummary, StartResult, StatusDetail, StopResult, methods, + LogEnd, LogLine, LogsCancelParams, LogsParams, LogsSubscribed, NameOrAll, RestartResult, + ServerSummary, StartResult, StatusDetail, StopResult, methods, notifications, }; use xy_supervisor::supervisor::{StartAck, StopAck, SupervisorCmd}; +pub struct ConnState { + pub subs: Mutex>>, + pub next: AtomicU64, +} + +impl ConnState { + pub fn new() -> Self { + Self { + subs: Mutex::new(HashMap::new()), + next: AtomicU64::new(1), + } + } +} + pub async fn serve(conn: Arc, reg: Registry, _paths: Paths) -> std::io::Result<()> { + let state = Arc::new(ConnState::new()); + loop { let Some(incoming) = conn.read_incoming().await? else { + let mut subs = state.subs.lock().await; + + for (_, h) in subs.drain() { + h.abort(); + } + return Ok(()); }; - match incoming { - Incoming::Request(req) => { - let resp = handle_request(req, ®).await; + if let Incoming::Request(req) = incoming { + let resp = handle_request(req, ®, &conn, &state).await; - conn.write_response(&resp).await?; - } - _ => continue, + conn.write_response(&resp).await?; } } } @@ -41,7 +65,12 @@ impl ApiError { } } -async fn handle_request(req: Request, reg: &Registry) -> Response { +async fn handle_request( + req: Request, + reg: &Registry, + conn: &Arc, + state: &Arc, +) -> Response { let id = req.id.clone(); let method = req.method.as_str(); let params = req.params.unwrap_or(serde_json::Value::Null); @@ -69,8 +98,37 @@ async fn handle_request(req: Request, reg: &Registry) -> Response { Ok(v) => ok_response(id, serde_json::to_value(v).unwrap()), Err(e) => err_response(id, e.code, e.message), }, - methods::LOGS => err_response(id, -32601, "logs not yet implemented".into()), - methods::LOGS_CANCEL => err_response(id, -32601, "logs_cancel not yet implemented".into()), + methods::LOGS => { + let p: LogsParams = match serde_json::from_value(params) { + Ok(p) => p, + Err(err) => return err_response(id, -32602, format!("invalid params: {err}")), + }; + + match start_log_stream(reg, conn.clone(), state.clone(), p).await { + Ok(sub_id) => ok_response( + id, + serde_json::to_value(LogsSubscribed { + subscription_id: sub_id, + }) + .unwrap(), + ), + Err(err) => err_response(id, err.code, err.message), + } + } + methods::LOGS_CANCEL => { + let p: LogsCancelParams = match serde_json::from_value(params) { + Ok(p) => p, + Err(err) => return err_response(id, -32602, format!("invalid params: {err}")), + }; + + let mut subs = state.subs.lock().await; + + if let Some(h) = subs.remove(&p.subscription_id) { + h.abort(); + } + + ok_response(id, serde_json::json!({})) + } other => err_response(id, -32601, format!("unknown method `{other}`")), } } @@ -361,3 +419,83 @@ async fn reload(reg: &Registry) -> Result { unchanged, }) } + +async fn start_log_stream( + reg: &Registry, + conn: Arc, + state: Arc, + p: LogsParams, +) -> Result { + let Some(entry) = reg.get(&p.name).await else { + return Err(ApiError::rpc( + RpcErrorCode::ServerNotFound, + format!("no such server `{}`", p.name), + )); + }; + + let sub_id = state.next.fetch_add(1, Ordering::Relaxed); + let sink = entry.handle.log_sink.clone(); + let conn2 = conn.clone(); + let state2 = state.clone(); + let follow = p.follow; + let tail = p.tail; + let name = p.name.clone(); + + let task = tokio::spawn(async move { + for line in sink.ring.snapshot_tail(tail) { + let n = xy_ipc::envelope::notification( + notifications::LOG, + Some( + serde_json::to_value(LogLine { + subscription_id: sub_id, + name: name.clone(), + stream: line.stream, + line: line.line, + ts_unix_ms: line.ts_unix_ms, + }) + .unwrap(), + ), + ); + + if conn2.write_notification(&n).await.is_err() { + return; + } + } + + if !follow { + let end = xy_ipc::envelope::notification( + notifications::LOG_END, + Some( + serde_json::to_value(LogEnd { + subscription_id: sub_id, + }) + .unwrap(), + ), + ); + + let _ = conn2.write_notification(&end).await; + + state2.subs.lock().await.remove(&sub_id); + + return; + } + + let mut rx = sink.broadcast.subscribe(); + + while let Ok(mut line) = rx.recv().await { + line.subscription_id = sub_id; + let n = xy_ipc::envelope::notification( + notifications::LOG, + Some(serde_json::to_value(&line).unwrap()), + ); + + if conn2.write_notification(&n).await.is_err() { + break; + } + } + }); + + state.subs.lock().await.insert(sub_id, task); + + Ok(sub_id) +} From c1f6225e2658c01a33514add5dc398aece6e5fa2 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 25 May 2026 12:02:09 +0200 Subject: [PATCH 30/42] feat(xy): CLI client commands Replace bail!("not implemented") stubs with real RPC calls over the Unix socket; add format::list_table for fixed-width list output. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/xy/src/cli/format.rs | 27 +++++ crates/xy/src/cli/mod.rs | 200 +++++++++++++++++++++++++++++++++--- 2 files changed, 212 insertions(+), 15 deletions(-) create mode 100644 crates/xy/src/cli/format.rs diff --git a/crates/xy/src/cli/format.rs b/crates/xy/src/cli/format.rs new file mode 100644 index 0000000..851a81c --- /dev/null +++ b/crates/xy/src/cli/format.rs @@ -0,0 +1,27 @@ +use xy_protocol::rpc::ServerSummary; + +pub fn list_table(rows: &[ServerSummary]) -> String { + let mut out = String::new(); + + out.push_str("NAME STATE PID PORT UPTIME RESTARTS\n"); + + for r in rows { + let pid = r.pid.map(|p| p.to_string()).unwrap_or_else(|| "-".into()); + let up = r + .uptime_secs + .map(|s| format!("{}s", s)) + .unwrap_or_else(|| "-".into()); + + out.push_str(&format!( + "{:<20}{:<12}{:<8}{:<8}{:<10}{}\n", + r.name, + format!("{:?}", r.state).to_lowercase(), + pid, + r.port, + up, + r.restart_count + )); + } + + out +} diff --git a/crates/xy/src/cli/mod.rs b/crates/xy/src/cli/mod.rs index 7087111..057a6fa 100644 --- a/crates/xy/src/cli/mod.rs +++ b/crates/xy/src/cli/mod.rs @@ -1,24 +1,194 @@ use crate::paths::Paths; -use anyhow::{Result, bail}; +use anyhow::Result; +use serde_json::json; +use xy_ipc::{Client, ClientError}; +use xy_protocol::rpc::{ + LogLine, LogStream, LogsParams, LogsSubscribed, ReloadResult, RestartResult, ServerSummary, + StartResult, StatusDetail, StopResult, methods, notifications, +}; -pub async fn list(_p: Paths) -> Result { - bail!("not implemented") +mod format; + +async fn connect(paths: &Paths) -> Result { + match Client::connect(&paths.socket).await { + Ok(c) => Ok(c), + Err(err) => { + eprintln!( + "xy: cannot reach daemon at {}: {err}", + paths.socket.display() + ); + std::process::exit(2); + } + } } -pub async fn status(_p: Paths, _name: String) -> Result { - bail!("not implemented") + +fn rpc_to_exit(err: &ClientError) -> i32 { + match err { + ClientError::Unreachable(_) => 2, + ClientError::Rpc { .. } => 1, + _ => 1, + } } -pub async fn start(_p: Paths, _all: bool, _name: Option) -> Result { - bail!("not implemented") + +pub async fn list(paths: Paths) -> Result { + let mut c = connect(&paths).await?; + + let rows: Vec = match c.call_no_params(methods::LIST).await { + Ok(v) => v, + Err(err) => { + eprintln!("xy: {err}"); + return Ok(rpc_to_exit(&err)); + } + }; + + print!("{}", format::list_table(&rows)); + + Ok(0) } -pub async fn stop(_p: Paths, _all: bool, _name: Option) -> Result { - bail!("not implemented") + +pub async fn status(paths: Paths, name: String) -> Result { + let mut c = connect(&paths).await?; + + let d: StatusDetail = match c.call(methods::STATUS, &json!({"name": name})).await { + Ok(v) => v, + Err(err) => { + eprintln!("xy: {err}"); + return Ok(rpc_to_exit(&err)); + } + }; + + println!("{:#?}", d); + + Ok(0) } -pub async fn restart(_p: Paths, _all: bool, _name: Option) -> Result { - bail!("not implemented") + +fn name_or_all(all: bool, name: Option) -> serde_json::Value { + if all { + json!({"all": true}) + } else { + json!({"name": name.unwrap()}) + } } -pub async fn reload(_p: Paths) -> Result { - bail!("not implemented") + +pub async fn start(paths: Paths, all: bool, name: Option) -> Result { + let mut c = connect(&paths).await?; + + let r: StartResult = match c.call(methods::START, &name_or_all(all, name)).await { + Ok(v) => v, + Err(err) => { + eprintln!("xy: {err}"); + return Ok(rpc_to_exit(&err)); + } + }; + + if !r.started.is_empty() { + println!("started: {}", r.started.join(", ")); + } + + if !r.already_running.is_empty() { + println!("already running: {}", r.already_running.join(", ")); + } + + Ok(0) } -pub async fn logs(_p: Paths, _name: String, _tail: Option, _follow: bool) -> Result { - bail!("not implemented") + +pub async fn stop(paths: Paths, all: bool, name: Option) -> Result { + let mut c = connect(&paths).await?; + + let r: StopResult = match c.call(methods::STOP, &name_or_all(all, name)).await { + Ok(v) => v, + Err(err) => { + eprintln!("xy: {err}"); + return Ok(rpc_to_exit(&err)); + } + }; + + if !r.stopped.is_empty() { + println!("stopped: {}", r.stopped.join(", ")); + } + + if !r.not_running.is_empty() { + println!("not running: {}", r.not_running.join(", ")); + } + + Ok(0) +} + +pub async fn restart(paths: Paths, all: bool, name: Option) -> Result { + let mut c = connect(&paths).await?; + + let r: RestartResult = match c.call(methods::RESTART, &name_or_all(all, name)).await { + Ok(v) => v, + Err(err) => { + eprintln!("xy: {err}"); + return Ok(rpc_to_exit(&err)); + } + }; + + println!("restarted: {}", r.restarted.join(", ")); + + Ok(0) +} + +pub async fn reload(paths: Paths) -> Result { + let mut c = connect(&paths).await?; + + let r: ReloadResult = match c.call_no_params(methods::RELOAD).await { + Ok(v) => v, + Err(err) => { + eprintln!("xy: {err}"); + return Ok(rpc_to_exit(&err)); + } + }; + + println!("added: {}", r.added.join(", ")); + println!("removed: {}", r.removed.join(", ")); + println!("changed: {}", r.changed.join(", ")); + println!("unchanged: {}", r.unchanged.join(", ")); + + Ok(0) +} + +pub async fn logs(paths: Paths, name: String, tail: Option, follow: bool) -> Result { + let mut c = connect(&paths).await?; + + let p = LogsParams { + name: name.clone(), + tail, + follow, + }; + + let _sub: LogsSubscribed = match c.call(methods::LOGS, &p).await { + Ok(v) => v, + Err(err) => { + eprintln!("xy: {err}"); + return Ok(rpc_to_exit(&err)); + } + }; + + loop { + match c.read_notification().await { + Ok(None) => return Ok(0), + Ok(Some(n)) => match n.method.as_str() { + notifications::LOG => { + if let Some(params) = n.params + && let Ok(line) = serde_json::from_value::(params) + { + let tag = match line.stream { + LogStream::Stdout => "out", + LogStream::Stderr => "err", + }; + + println!("[{tag}] {}", line.line); + } + } + notifications::LOG_END => return Ok(0), + _ => {} + }, + Err(err) => { + eprintln!("xy: {err}"); + return Ok(rpc_to_exit(&err)); + } + } + } } From 7107977637366c55f42335a9ca1095d36b825a45 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 25 May 2026 12:03:13 +0200 Subject: [PATCH 31/42] test(xy): helper binaries for integration tests --- crates/xy/Cargo.toml | 8 ++++++++ crates/xy/src/bin/xy_test_exit_failure.rs | 4 ++++ crates/xy/src/bin/xy_test_sleep_server.rs | 12 ++++++++++++ 3 files changed, 24 insertions(+) create mode 100644 crates/xy/src/bin/xy_test_exit_failure.rs create mode 100644 crates/xy/src/bin/xy_test_sleep_server.rs diff --git a/crates/xy/Cargo.toml b/crates/xy/Cargo.toml index bd6609b..f458b5e 100644 --- a/crates/xy/Cargo.toml +++ b/crates/xy/Cargo.toml @@ -8,6 +8,14 @@ license.workspace = true name = "xy" path = "src/main.rs" +[[bin]] +name = "xy-test-sleep-server" +path = "src/bin/xy_test_sleep_server.rs" + +[[bin]] +name = "xy-test-exit-failure" +path = "src/bin/xy_test_exit_failure.rs" + [dependencies] xy-protocol.workspace = true xy-supervisor.workspace = true diff --git a/crates/xy/src/bin/xy_test_exit_failure.rs b/crates/xy/src/bin/xy_test_exit_failure.rs new file mode 100644 index 0000000..3dd55d4 --- /dev/null +++ b/crates/xy/src/bin/xy_test_exit_failure.rs @@ -0,0 +1,4 @@ +fn main() { + eprintln!("exit_failure dying immediately"); + std::process::exit(7); +} diff --git a/crates/xy/src/bin/xy_test_sleep_server.rs b/crates/xy/src/bin/xy_test_sleep_server.rs new file mode 100644 index 0000000..e5110d9 --- /dev/null +++ b/crates/xy/src/bin/xy_test_sleep_server.rs @@ -0,0 +1,12 @@ +fn main() { + use std::io::Write; + let pid = std::process::id(); + eprintln!("sleep_server start pid={pid}"); + println!("ready"); + std::io::stdout().flush().ok(); + loop { + std::thread::sleep(std::time::Duration::from_secs(60)); + println!("tick"); + std::io::stdout().flush().ok(); + } +} From 48d63a0549c4a5997aa42b497e8f810471e0dd56 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 25 May 2026 12:03:38 +0200 Subject: [PATCH 32/42] test(xy): integration test harness --- crates/xy/tests/common/mod.rs | 82 +++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 crates/xy/tests/common/mod.rs diff --git a/crates/xy/tests/common/mod.rs b/crates/xy/tests/common/mod.rs new file mode 100644 index 0000000..d85b375 --- /dev/null +++ b/crates/xy/tests/common/mod.rs @@ -0,0 +1,82 @@ +#![allow(dead_code)] // not all helpers are used by every test file + +use std::path::PathBuf; +use std::process::Stdio; +use std::time::Duration; +use tokio::process::{Child, Command}; +use tempfile::TempDir; + +pub struct Harness { + pub tmp: TempDir, + pub config_dir: PathBuf, + pub state_dir: PathBuf, + pub socket: PathBuf, + pub daemon: Option, +} + +impl Harness { + pub fn new() -> Self { + let tmp = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir_all(tmp.path().join("config")).unwrap(); + std::fs::create_dir_all(tmp.path().join("state")).unwrap(); + std::fs::create_dir_all(tmp.path().join("run")).unwrap(); + let config_dir = tmp.path().join("config/xy/servers"); + let state_dir = tmp.path().join("state/xy"); + std::fs::create_dir_all(&config_dir).unwrap(); + std::fs::create_dir_all(&state_dir).unwrap(); + let socket = tmp.path().join("run/xy.sock"); + Self { tmp, config_dir, state_dir, socket, daemon: None } + } + + pub fn write_server(&self, name: &str, command: &str, port: u16, restart_policy: &str) { + let body = format!( + "command \"{command}\"\nport {port}\nrestart {{ policy \"{restart_policy}\" backoff-initial \"10ms\" backoff-max \"50ms\" max-retries-per-minute 3 }}\nstop {{ grace \"500ms\" }}\n" + ); + std::fs::write(self.config_dir.join(format!("{name}.kdl")), body).unwrap(); + } + + pub async fn start_daemon(&mut self, xy_bin: &PathBuf) { + let child = Command::new(xy_bin) + .arg("daemon") + .env("XDG_CONFIG_HOME", self.tmp.path().join("config")) + .env("XDG_STATE_HOME", self.tmp.path().join("state")) + .env("XDG_RUNTIME_DIR", self.tmp.path().join("run")) + .stdout(Stdio::null()) + .stderr(Stdio::inherit()) + .kill_on_drop(true) + .spawn() + .expect("spawn daemon"); + self.daemon = Some(child); + let deadline = std::time::Instant::now() + Duration::from_secs(5); + while !self.socket.exists() { + if std::time::Instant::now() > deadline { panic!("daemon socket never appeared"); } + tokio::time::sleep(Duration::from_millis(25)).await; + } + } + + pub async fn run_cli(&self, xy_bin: &PathBuf, args: &[&str]) -> (i32, String, String) { + let out = Command::new(xy_bin) + .args(args) + .env("XDG_CONFIG_HOME", self.tmp.path().join("config")) + .env("XDG_STATE_HOME", self.tmp.path().join("state")) + .env("XDG_RUNTIME_DIR", self.tmp.path().join("run")) + .output().await.expect("run cli"); + let code = out.status.code().unwrap_or(-1); + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); + (code, stdout, stderr) + } +} + +pub fn xy_bin() -> PathBuf { artifact("xy") } +pub fn sleep_server_bin() -> PathBuf { artifact("xy-test-sleep-server") } +pub fn exit_failure_bin() -> PathBuf { artifact("xy-test-exit-failure") } + +fn artifact(name: &str) -> PathBuf { + let mut p = std::env::current_exe().unwrap(); + p.pop(); + if p.ends_with("deps") { p.pop(); } + p.push(name); + if !p.exists() { panic!("artifact `{}` not found at {}", name, p.display()); } + p +} From 434828c14efe6153293f1bf15097445f06e73f68 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 25 May 2026 12:05:28 +0200 Subject: [PATCH 33/42] test(xy): auto-start + stop/start lifecycle --- crates/xy/tests/lifecycle.rs | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 crates/xy/tests/lifecycle.rs diff --git a/crates/xy/tests/lifecycle.rs b/crates/xy/tests/lifecycle.rs new file mode 100644 index 0000000..4c1ab9a --- /dev/null +++ b/crates/xy/tests/lifecycle.rs @@ -0,0 +1,31 @@ +mod common; +use common::*; + +#[tokio::test] +async fn auto_starts_on_boot_then_stop_and_start() { + let xy = xy_bin(); + let sleeper = sleep_server_bin(); + let mut h = Harness::new(); + h.write_server("alpha", sleeper.to_str().unwrap(), 19_001, "always"); + h.start_daemon(&xy).await; + + let mut last_stdout = String::new(); + for _ in 0..40 { + let (_c, out, _e) = h.run_cli(&xy, &["list"]).await; + last_stdout = out; + if last_stdout.contains("alpha") && last_stdout.contains("running") { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + assert!(last_stdout.contains("alpha"), "stdout: {last_stdout}"); + assert!(last_stdout.contains("running"), "stdout: {last_stdout}"); + + let (code, out, _e) = h.run_cli(&xy, &["stop", "alpha"]).await; + assert_eq!(code, 0); + assert!(out.contains("stopped: alpha"), "stdout: {out}"); + + let (code, out, _e) = h.run_cli(&xy, &["start", "alpha"]).await; + assert_eq!(code, 0); + assert!(out.contains("started: alpha"), "stdout: {out}"); +} From 284b6e74024473eec1830d523925fb1dddebfae8 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 25 May 2026 12:05:45 +0200 Subject: [PATCH 34/42] test(xy): restart cap escalates to failed --- crates/xy/tests/restart_policy.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 crates/xy/tests/restart_policy.rs diff --git a/crates/xy/tests/restart_policy.rs b/crates/xy/tests/restart_policy.rs new file mode 100644 index 0000000..21ce924 --- /dev/null +++ b/crates/xy/tests/restart_policy.rs @@ -0,0 +1,22 @@ +mod common; +use common::*; + +#[tokio::test] +async fn restart_cap_marks_failed() { + let xy = xy_bin(); + let bad = exit_failure_bin(); + let mut h = Harness::new(); + h.write_server("flaky", bad.to_str().unwrap(), 19_010, "always"); + h.start_daemon(&xy).await; + + let mut saw_failed = false; + for _ in 0..60 { + let (_c, out, _e) = h.run_cli(&xy, &["list"]).await; + if out.contains("flaky") && out.contains("failed") { + saw_failed = true; + break; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + assert!(saw_failed, "flaky never reached failed state"); +} From 15791c628b1afc5bf23e402718143022e357e0eb Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 25 May 2026 12:05:58 +0200 Subject: [PATCH 35/42] test(xy): reload diff --- crates/xy/tests/reload.rs | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 crates/xy/tests/reload.rs diff --git a/crates/xy/tests/reload.rs b/crates/xy/tests/reload.rs new file mode 100644 index 0000000..13c8f61 --- /dev/null +++ b/crates/xy/tests/reload.rs @@ -0,0 +1,37 @@ +mod common; +use common::*; + +#[tokio::test] +async fn reload_adds_removes_and_changes() { + let xy = xy_bin(); + let sleeper = sleep_server_bin(); + let mut h = Harness::new(); + h.write_server("a", sleeper.to_str().unwrap(), 19_020, "always"); + h.start_daemon(&xy).await; + + for _ in 0..40 { + let (_c, out, _e) = h.run_cli(&xy, &["list"]).await; + if out.contains("a") && out.contains("running") { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + + h.write_server("a", sleeper.to_str().unwrap(), 19_021, "always"); + h.write_server("b", sleeper.to_str().unwrap(), 19_022, "always"); + let (code, out, _e) = h.run_cli(&xy, &["reload"]).await; + assert_eq!(code, 0); + assert!(out.contains("added:") && out.contains("b"), "stdout: {out}"); + assert!( + out.contains("changed:") && out.contains("a"), + "stdout: {out}" + ); + + std::fs::remove_file(h.config_dir.join("a.kdl")).unwrap(); + let (code, out, _e) = h.run_cli(&xy, &["reload"]).await; + assert_eq!(code, 0); + assert!( + out.contains("removed:") && out.contains("a"), + "stdout: {out}" + ); +} From b1e7dea739ab4e4a17d95def71da3fd3774edeb6 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 25 May 2026 12:17:32 +0200 Subject: [PATCH 36/42] test(xy): logs --tail and --follow Fix a deadlock in the log-stream handler that caused all logs requests to hang: Connection used a single Mutex for both reads and writes, so the serve loop holding the read lock blocked the spawned notification task from writing. Split Connection into separate reader and writer mutexes. Also fix a response/notification ordering race: the log task now waits for an explicit ready signal sent by serve after writing the LOGS response, ensuring notifications never arrive at the client before their initiating response. --- crates/xy-ipc/src/client.rs | 2 +- crates/xy-ipc/src/framing.rs | 56 +++++++++++++++++++++++---- crates/xy-ipc/src/lib.rs | 6 +-- crates/xy-ipc/src/server.rs | 15 +++++--- crates/xy/src/daemon/handlers.rs | 66 ++++++++++++++++++++++++-------- crates/xy/tests/common/mod.rs | 40 ++++++++++++++----- crates/xy/tests/logs.rs | 49 ++++++++++++++++++++++++ 7 files changed, 191 insertions(+), 43 deletions(-) create mode 100644 crates/xy/tests/logs.rs diff --git a/crates/xy-ipc/src/client.rs b/crates/xy-ipc/src/client.rs index 3125247..560442b 100644 --- a/crates/xy-ipc/src/client.rs +++ b/crates/xy-ipc/src/client.rs @@ -1,7 +1,7 @@ use crate::envelope::{Incoming, Notification, Request}; use crate::framing::JsonFramed; -use serde::de::DeserializeOwned; use serde::Serialize; +use serde::de::DeserializeOwned; use std::path::Path; use thiserror::Error; use tokio::net::UnixStream; diff --git a/crates/xy-ipc/src/framing.rs b/crates/xy-ipc/src/framing.rs index 5deb74e..3b4155d 100644 --- a/crates/xy-ipc/src/framing.rs +++ b/crates/xy-ipc/src/framing.rs @@ -16,9 +16,7 @@ impl JsonFramed { } } - pub async fn read( - &mut self, - ) -> std::io::Result> { + pub async fn read(&mut self) -> std::io::Result> { let mut buf = String::new(); let n = self.reader.read_line(&mut buf).await?; if n == 0 { @@ -38,6 +36,47 @@ impl JsonFramed { } } +pub struct JsonFramedReader { + inner: BufReader, +} + +impl JsonFramedReader { + pub async fn read(&mut self) -> std::io::Result> { + let mut buf = String::new(); + let n = self.inner.read_line(&mut buf).await?; + if n == 0 { + return Ok(None); + } + let v: T = serde_json::from_str(buf.trim_end()) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + Ok(Some(v)) + } +} + +pub struct JsonFramedWriter { + inner: tokio::net::unix::OwnedWriteHalf, +} + +impl JsonFramedWriter { + pub async fn write(&mut self, value: &T) -> std::io::Result<()> { + let mut bytes = serde_json::to_vec(value) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + bytes.push(b'\n'); + self.inner.write_all(&bytes).await?; + self.inner.flush().await + } +} + +pub fn split(stream: UnixStream) -> (JsonFramedReader, JsonFramedWriter) { + let (r, w) = stream.into_split(); + ( + JsonFramedReader { + inner: BufReader::new(r), + }, + JsonFramedWriter { inner: w }, + ) +} + #[cfg(test)] mod tests { use super::*; @@ -61,10 +100,13 @@ mod tests { .await .unwrap(); let got: Option = sb.read().await.unwrap(); - assert_eq!(got, Some(M { - x: 1, - name: "hi".into() - })); + assert_eq!( + got, + Some(M { + x: 1, + name: "hi".into() + }) + ); } #[tokio::test] diff --git a/crates/xy-ipc/src/lib.rs b/crates/xy-ipc/src/lib.rs index 8629b3b..3685f6d 100644 --- a/crates/xy-ipc/src/lib.rs +++ b/crates/xy-ipc/src/lib.rs @@ -7,8 +7,8 @@ pub mod server; pub use client::{Client, ClientError}; pub use envelope::{ - err_response, notification, ok_response, request, Incoming, Notification, Request, Response, - RpcError, + Incoming, Notification, Request, Response, RpcError, err_response, notification, ok_response, + request, }; pub use framing::JsonFramed; -pub use server::{bind, Connection}; +pub use server::{Connection, bind}; diff --git a/crates/xy-ipc/src/server.rs b/crates/xy-ipc/src/server.rs index fcead9b..16f4b7e 100644 --- a/crates/xy-ipc/src/server.rs +++ b/crates/xy-ipc/src/server.rs @@ -1,33 +1,36 @@ use crate::envelope::{Incoming, Notification, Response}; -use crate::framing::JsonFramed; +use crate::framing::{JsonFramedReader, JsonFramedWriter}; use std::path::Path; use std::sync::Arc; use tokio::net::{UnixListener, UnixStream}; use tokio::sync::Mutex; pub struct Connection { - inner: Arc>, + reader: Mutex, + writer: Arc>, } impl Connection { pub fn new(stream: UnixStream) -> Self { + let (reader, writer) = crate::framing::split(stream); Self { - inner: Arc::new(Mutex::new(JsonFramed::new(stream))), + reader: Mutex::new(reader), + writer: Arc::new(Mutex::new(writer)), } } pub async fn read_incoming(&self) -> std::io::Result> { - let mut g = self.inner.lock().await; + let mut g = self.reader.lock().await; g.read::().await } pub async fn write_response(&self, r: &Response) -> std::io::Result<()> { - let mut g = self.inner.lock().await; + let mut g = self.writer.lock().await; g.write(r).await } pub async fn write_notification(&self, n: &Notification) -> std::io::Result<()> { - let mut g = self.inner.lock().await; + let mut g = self.writer.lock().await; g.write(n).await } } diff --git a/crates/xy/src/daemon/handlers.rs b/crates/xy/src/daemon/handlers.rs index a77b707..7fae441 100644 --- a/crates/xy/src/daemon/handlers.rs +++ b/crates/xy/src/daemon/handlers.rs @@ -44,9 +44,13 @@ pub async fn serve(conn: Arc, reg: Registry, _paths: Paths) -> std:: }; if let Incoming::Request(req) = incoming { - let resp = handle_request(req, ®, &conn, &state).await; + let (resp, log_ready) = handle_request(req, ®, &conn, &state).await; conn.write_response(&resp).await?; + + if let Some(tx) = log_ready { + let _ = tx.send(()); + } } } } @@ -65,17 +69,19 @@ impl ApiError { } } +type LogReadyTx = tokio::sync::oneshot::Sender<()>; + async fn handle_request( req: Request, reg: &Registry, conn: &Arc, state: &Arc, -) -> Response { +) -> (Response, Option) { let id = req.id.clone(); let method = req.method.as_str(); let params = req.params.unwrap_or(serde_json::Value::Null); - match method { + let resp = match method { methods::LIST => match list(reg).await { Ok(v) => ok_response(id, serde_json::to_value(v).unwrap()), Err(err) => err_response(id, err.code, err.message), @@ -83,7 +89,12 @@ async fn handle_request( methods::STATUS => { let p: xy_protocol::rpc::StatusParams = match serde_json::from_value(params) { Ok(p) => p, - Err(err) => return err_response(id, -32602, format!("invalid params: {err}")), + Err(err) => { + return ( + err_response(id, -32602, format!("invalid params: {err}")), + None, + ); + } }; match status(reg, &p.name).await { @@ -101,24 +112,37 @@ async fn handle_request( methods::LOGS => { let p: LogsParams = match serde_json::from_value(params) { Ok(p) => p, - Err(err) => return err_response(id, -32602, format!("invalid params: {err}")), + Err(err) => { + return ( + err_response(id, -32602, format!("invalid params: {err}")), + None, + ); + } }; match start_log_stream(reg, conn.clone(), state.clone(), p).await { - Ok(sub_id) => ok_response( - id, - serde_json::to_value(LogsSubscribed { - subscription_id: sub_id, - }) - .unwrap(), - ), + Ok((sub_id, ready_tx)) => { + let resp = ok_response( + id, + serde_json::to_value(LogsSubscribed { + subscription_id: sub_id, + }) + .unwrap(), + ); + return (resp, Some(ready_tx)); + } Err(err) => err_response(id, err.code, err.message), } } methods::LOGS_CANCEL => { let p: LogsCancelParams = match serde_json::from_value(params) { Ok(p) => p, - Err(err) => return err_response(id, -32602, format!("invalid params: {err}")), + Err(err) => { + return ( + err_response(id, -32602, format!("invalid params: {err}")), + None, + ); + } }; let mut subs = state.subs.lock().await; @@ -130,7 +154,9 @@ async fn handle_request( ok_response(id, serde_json::json!({})) } other => err_response(id, -32601, format!("unknown method `{other}`")), - } + }; + + (resp, None) } async fn list(reg: &Registry) -> Result, ApiError> { @@ -425,7 +451,7 @@ async fn start_log_stream( conn: Arc, state: Arc, p: LogsParams, -) -> Result { +) -> Result<(u64, LogReadyTx), ApiError> { let Some(entry) = reg.get(&p.name).await else { return Err(ApiError::rpc( RpcErrorCode::ServerNotFound, @@ -441,7 +467,15 @@ async fn start_log_stream( let tail = p.tail; let name = p.name.clone(); + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<()>(); + let task = tokio::spawn(async move { + // Wait until `serve` has written the LOGS response before sending any + // LOG notifications. Without this the spawned task can race ahead of + // `conn.write_response` and emit notifications that the client + // discards while still awaiting the response. + let _ = ready_rx.await; + for line in sink.ring.snapshot_tail(tail) { let n = xy_ipc::envelope::notification( notifications::LOG, @@ -497,5 +531,5 @@ async fn start_log_stream( state.subs.lock().await.insert(sub_id, task); - Ok(sub_id) + Ok((sub_id, ready_tx)) } diff --git a/crates/xy/tests/common/mod.rs b/crates/xy/tests/common/mod.rs index d85b375..665e56a 100644 --- a/crates/xy/tests/common/mod.rs +++ b/crates/xy/tests/common/mod.rs @@ -1,10 +1,10 @@ -#![allow(dead_code)] // not all helpers are used by every test file +#![allow(dead_code)] // not all helpers are used by every test file use std::path::PathBuf; use std::process::Stdio; use std::time::Duration; -use tokio::process::{Child, Command}; use tempfile::TempDir; +use tokio::process::{Child, Command}; pub struct Harness { pub tmp: TempDir, @@ -25,7 +25,13 @@ impl Harness { std::fs::create_dir_all(&config_dir).unwrap(); std::fs::create_dir_all(&state_dir).unwrap(); let socket = tmp.path().join("run/xy.sock"); - Self { tmp, config_dir, state_dir, socket, daemon: None } + Self { + tmp, + config_dir, + state_dir, + socket, + daemon: None, + } } pub fn write_server(&self, name: &str, command: &str, port: u16, restart_policy: &str) { @@ -49,7 +55,9 @@ impl Harness { self.daemon = Some(child); let deadline = std::time::Instant::now() + Duration::from_secs(5); while !self.socket.exists() { - if std::time::Instant::now() > deadline { panic!("daemon socket never appeared"); } + if std::time::Instant::now() > deadline { + panic!("daemon socket never appeared"); + } tokio::time::sleep(Duration::from_millis(25)).await; } } @@ -60,7 +68,9 @@ impl Harness { .env("XDG_CONFIG_HOME", self.tmp.path().join("config")) .env("XDG_STATE_HOME", self.tmp.path().join("state")) .env("XDG_RUNTIME_DIR", self.tmp.path().join("run")) - .output().await.expect("run cli"); + .output() + .await + .expect("run cli"); let code = out.status.code().unwrap_or(-1); let stdout = String::from_utf8_lossy(&out.stdout).to_string(); let stderr = String::from_utf8_lossy(&out.stderr).to_string(); @@ -68,15 +78,25 @@ impl Harness { } } -pub fn xy_bin() -> PathBuf { artifact("xy") } -pub fn sleep_server_bin() -> PathBuf { artifact("xy-test-sleep-server") } -pub fn exit_failure_bin() -> PathBuf { artifact("xy-test-exit-failure") } +pub fn xy_bin() -> PathBuf { + artifact("xy") +} +pub fn sleep_server_bin() -> PathBuf { + artifact("xy-test-sleep-server") +} +pub fn exit_failure_bin() -> PathBuf { + artifact("xy-test-exit-failure") +} fn artifact(name: &str) -> PathBuf { let mut p = std::env::current_exe().unwrap(); p.pop(); - if p.ends_with("deps") { p.pop(); } + if p.ends_with("deps") { + p.pop(); + } p.push(name); - if !p.exists() { panic!("artifact `{}` not found at {}", name, p.display()); } + if !p.exists() { + panic!("artifact `{}` not found at {}", name, p.display()); + } p } diff --git a/crates/xy/tests/logs.rs b/crates/xy/tests/logs.rs new file mode 100644 index 0000000..fb32f11 --- /dev/null +++ b/crates/xy/tests/logs.rs @@ -0,0 +1,49 @@ +mod common; +use common::*; +use std::process::Stdio; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Command; + +#[tokio::test] +async fn logs_tail_prints_existing_lines() { + let xy = xy_bin(); + let sleeper = sleep_server_bin(); + let mut h = Harness::new(); + h.write_server("svc", sleeper.to_str().unwrap(), 19_030, "always"); + h.start_daemon(&xy).await; + + tokio::time::sleep(Duration::from_millis(500)).await; + let (code, out, _e) = h.run_cli(&xy, &["logs", "svc", "--tail", "10"]).await; + assert_eq!(code, 0); + assert!(out.contains("ready"), "stdout: {out}"); +} + +#[tokio::test] +async fn logs_follow_streams_new_lines() { + let xy = xy_bin(); + let sleeper = sleep_server_bin(); + let mut h = Harness::new(); + h.write_server("svc", sleeper.to_str().unwrap(), 19_031, "always"); + h.start_daemon(&xy).await; + + let mut child = Command::new(&xy) + .args(["logs", "svc", "--follow"]) + .env("XDG_CONFIG_HOME", h.tmp.path().join("config")) + .env("XDG_STATE_HOME", h.tmp.path().join("state")) + .env("XDG_RUNTIME_DIR", h.tmp.path().join("run")) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .spawn() + .unwrap(); + + let stdout = child.stdout.take().unwrap(); + let mut lines = BufReader::new(stdout).lines(); + let first = tokio::time::timeout(Duration::from_secs(2), lines.next_line()) + .await + .expect("timeout waiting for first log line") + .unwrap(); + assert!(first.is_some()); + let _ = tokio::time::timeout(Duration::from_secs(2), child.kill()).await; +} From 9d5d8f04a273dc4cf648a353fd0fd43d4669f3f4 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 25 May 2026 12:19:24 +0200 Subject: [PATCH 37/42] chore: clippy fixes - allow should_implement_trait and collapse nested if --- crates/xy-supervisor/src/backoff.rs | 1 + crates/xy/src/daemon/handlers.rs | 22 +++++++++++----------- libnull.rlib | Bin 0 -> 5160 bytes 3 files changed, 12 insertions(+), 11 deletions(-) create mode 100644 libnull.rlib diff --git a/crates/xy-supervisor/src/backoff.rs b/crates/xy-supervisor/src/backoff.rs index e4909a5..3d94fc7 100644 --- a/crates/xy-supervisor/src/backoff.rs +++ b/crates/xy-supervisor/src/backoff.rs @@ -16,6 +16,7 @@ impl Backoff { } } + #[allow(clippy::should_implement_trait)] pub fn next(&mut self) -> Duration { let next = match self.current { None => self.initial, diff --git a/crates/xy/src/daemon/handlers.rs b/crates/xy/src/daemon/handlers.rs index 7fae441..f0333fe 100644 --- a/crates/xy/src/daemon/handlers.rs +++ b/crates/xy/src/daemon/handlers.rs @@ -359,20 +359,20 @@ async fn reload(reg: &Registry) -> Result { let mut unchanged = Vec::new(); for name in &existing_names { - if !new_by_name.contains_key(name) { - if let Some(entry) = reg.remove(name).await { - let (tx, rx) = oneshot::channel(); + if !new_by_name.contains_key(name) + && let Some(entry) = reg.remove(name).await + { + let (tx, rx) = oneshot::channel(); - let _ = entry - .handle - .tx - .send(SupervisorCmd::Shutdown { ack: tx }) - .await; + let _ = entry + .handle + .tx + .send(SupervisorCmd::Shutdown { ack: tx }) + .await; - let _ = rx.await; + let _ = rx.await; - removed.push(name.clone()); - } + removed.push(name.clone()); } } diff --git a/libnull.rlib b/libnull.rlib new file mode 100644 index 0000000000000000000000000000000000000000..d3290082f664392b43762f7d5d2e802276adf591 GIT binary patch literal 5160 zcmcII3vd(HwJWXg%Ac$RHXj8^Z-oiZX<}!!`s^;U6Iwb_uO;u`Ruu8Whxc?-r9R}3eESJ8R}-EZ+59ve<7qOhRZP2 z4{v*L-TDn!s^Mvj(C3wFw_1||88egWP&GOAb)FJ`gA@=z&YK@!{A(VDDUeJARPd}w zjt~XIbnx^gd$$JHvr=>}y(TjzfQ+7us2dxm#kk$;zFe_6qgk2=D6>8z@8%4qT+qiD z7_5LNB|rDZ6kyB`00_#7EJ}$hLg-L^m@+xsbAWsH-Dt6a{mr7a;#alSZ$2m>JiKqQ3S?=%D}{d{bwTsUb8=7dwf?% z1TiwK%t$t?&1|vqj9~Ks;~~{{{`i5G_X7o$vt>U#+?=RJjG|x@yf%w$70jGPW^GZ0 zvQJ$TR{i$5x<@y?`_hjlM(*5wUs9rIwlWrmr)9GwS{N=0W=^W?ZV8C)wkH4MEk403 zxxGz(Df*N6=!eR+&2N;vzGUCZjkZ1{faN$l>+uS#U^R2JnSmqi(`L^epE&V9AOGol z1z)@PRqoGsAV%6IS_O#}JPa@LtSCnnSZ+gIV_m1asl85=qL)srd?mi|n}^;C@31bt z{`NRpV`I&tnfKakvM2~1NrW|7f>66HA~1Cf++f#`npCbIxb$jE0?@$g0$Tid%0~eX+ECy2oQ&&;AQQC-C~6mi+0pC z)YnCq{$~BIYr5i>p*g2>Sp#NHq^cx(O9x9)gg+rclsx~=uVw@(&dSpOJe=Q)d= zm8>2MbVR{|0(ds3|9`)6>aI1BHO*kud;LC3C_XlmF({YTK~UWGOW^mQlfLEujkLbuTuGtQmKL;oObt3hoiJpEH#S7 za$UW&P#Dx9=?a5NwtrOL+^9o1Ce2cl)3~#~z9b2(kb@vOh%(1=HbyG;?$}vEm-rEC zO6_*5I^_s5K9!nBb;>PjhSVfLAzcrQqcsc0nK~uoL$^C1wWFIo-Q4ai>$h&nj6RDq z@)pc=E$Cxzx2G=PZ4xs^bGxgm9(iyfN2*`5;C%lqP~2{BgNQP7TEySW%g9NqiE*C^ z>`(9guj;6BSdU7a2;fS<3+pIEM-OTloEm+8cxGsMvUr0uiBwApj^|SB^o2d&dDs_SQZSYC~~fvbQVw#!#Wcb zwL@TpT%{>lIIaR35iESRBCfhWOUv;46npaSSfth&RnK_3)a#YbeYwtcTBd+guXrGb zS=C)RD4JY*yHy+YjB|B!T)lYpo#lIbv=1m4agTbRo+@;9&tSU6PL=KpMPG<)+D;X| zTD~_=7bD(Ss$135vzpK`p1b*YWN~49NjGU&E3VrZ*RFDHQ0i{So~AG?8_u&zR%s78 zcR#J&3?R}xTwR%yy~a7R$hm5bdQY&6SJD`kKj@2uTL@(Owhw*M?fv;pX;m z$55E-#@R0Tg>E}gO*V{?o%3V^rt$j-Uq8`85uG#vwwkAiHf^$>B&Hbr{X}ay(OE+T z#2LwX$>%XQDVVdUyY$DV z^m2istlP!yiS;mumrLQ?}7SmkRl2^M(OP

95tLA(986qBwJ`@0xyfZ%6jm! zM0-!bM?9e>Ix53HAJOT8bHD~9^`D)UlJ!Uv#(vO=A!-7$! zpLQG#ImTT4VZ8KcsB{nRNX!?s)(}tB5^OD)m|(lYV0T{)(ON{bURwlW+q%MTu=|Rz zzc1X;7rgt@Jb7u7{BVezqj67sKtDa8PbA@lpdU5qj|B~*Le|?67aw==eMxvE#`U_u zWsct1J+SHZ-Hx|#$6ze)wYsoxYuN7#1!|Fl11rLT{;=-k!SuHs?M9x%`AJB!-(-p?)spaEShBI)0N}XdcRCYMDa!-uwaq)>*>5(WOkAaS* ziOR#xY64Ex3%8uZCZ-`H9Jqq3jWjo=VmEksDSog*` z@-nTNnj|ljYcBM|E-?k(@*)AcxceYfkc9(d;L6~h3Vt#MrYi$aCCZQW8%75VlNH7@ zQN6s{OOsQ`zZwuSL%U3&ohyiz%CNgX{A3rbJ=m)#?TMB4#<(%pS_r2ESS+v7wA3jW z;ixC0hRwkOxoQVZUZymcC$lbBYpxR8cMO8_yQhoAcgld)iEp<(e z|NPsxP_3YMYgLv!S9wCKW|s8z_l%Fvy|ewDGx7JwN4F=&hfmIK$nM^fB%Y3sPMqJ6 zjjpbrLatx|=jQl};2k77xBB|P@5p8RSZw#Keq`Xb^A#vXfX!2}z07*HhCEjd_km^_ zy*Qe4)#TN|t{^fFdZXLJ5RE9FxB8xsafcO-7eZydG3Dcs9P|@Y`Uy%uEg*P4XdD*w zV*~nlDhP$BLUR=y2Y&1#bGuW)u;@(=Tt5ykcnm_(0g(@RDULhtE`qo{4bp_@I&ubL z8{)7=GX)Ux23%JW%|(j5JcO<|>=YzRRHqS%(icLG=OIDhW&NR2NEVS8^3ebVbx8sa=I?g=cae{nBsq$z#7*_6K{>iSHhN$Ta(xCo9j62Y!!X65NBRHd=!B z9szWtR&_$Z3LYeHD?F(EJw`lWe(WpofZMT5S(M=e(x=*2VJ`|Ou-8>I!7wG}5~es= zJEr~^DQ*b}o_dKgdp%ak!dWa{GY{YGjAWNRHkKC{i{Pat#=|f+j)f03!QXJBqHhqq zO)c$aGZcpOKQ0>4zPD=k0+B?@rW*&CL`F?Fq~F3Zs*6Y{3K!N>3&&W!I~n~YQUm Date: Mon, 25 May 2026 12:19:34 +0200 Subject: [PATCH 38/42] docs: README and example KDL config --- README.md | 26 ++++++++++++++++++++++++++ examples/insikt.kdl | 15 +++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 README.md create mode 100644 examples/insikt.kdl diff --git a/README.md b/README.md new file mode 100644 index 0000000..c0bb419 --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +# xy — HTTP MCP server supervisor + +Daemon + CLI that launches and supervises HTTP-based MCP servers. + +## Build + + cargo build --release + +## Run + + target/release/xy daemon # foreground + +Drop a server definition into `$XDG_CONFIG_HOME/xy/servers/.kdl` +(see `examples/insikt.kdl`) and `xy reload`. + +Commands: + + xy list + xy status + xy start + xy stop + xy restart + xy reload + xy logs [--tail N] [--follow] + +Exit codes: 0 success, 1 operational error, 2 daemon unreachable, 3 config invalid. diff --git a/examples/insikt.kdl b/examples/insikt.kdl new file mode 100644 index 0000000..42c59dc --- /dev/null +++ b/examples/insikt.kdl @@ -0,0 +1,15 @@ +command "/Users/you/.cargo/bin/insikt-mcp" +args "--http" "--port" "8421" +port 8421 + +env { + RUST_LOG "info" +} + +restart { + policy "on-failure" +} + +stop { + grace "10s" +} From ae6ed1cf0a2a582c2aba40fbac13a9cc3087c27b Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 25 May 2026 12:25:02 +0200 Subject: [PATCH 39/42] chore: remove stray libnull.rlib and gitignore *.rlib Accidentally committed in 9d5d8f0 during the polish task. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 1 + libnull.rlib | Bin 5160 -> 0 bytes 2 files changed, 1 insertion(+) delete mode 100644 libnull.rlib diff --git a/.gitignore b/.gitignore index ea8c4bf..73623c2 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +*.rlib diff --git a/libnull.rlib b/libnull.rlib deleted file mode 100644 index d3290082f664392b43762f7d5d2e802276adf591..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5160 zcmcII3vd(HwJWXg%Ac$RHXj8^Z-oiZX<}!!`s^;U6Iwb_uO;u`Ruu8Whxc?-r9R}3eESJ8R}-EZ+59ve<7qOhRZP2 z4{v*L-TDn!s^Mvj(C3wFw_1||88egWP&GOAb)FJ`gA@=z&YK@!{A(VDDUeJARPd}w zjt~XIbnx^gd$$JHvr=>}y(TjzfQ+7us2dxm#kk$;zFe_6qgk2=D6>8z@8%4qT+qiD z7_5LNB|rDZ6kyB`00_#7EJ}$hLg-L^m@+xsbAWsH-Dt6a{mr7a;#alSZ$2m>JiKqQ3S?=%D}{d{bwTsUb8=7dwf?% z1TiwK%t$t?&1|vqj9~Ks;~~{{{`i5G_X7o$vt>U#+?=RJjG|x@yf%w$70jGPW^GZ0 zvQJ$TR{i$5x<@y?`_hjlM(*5wUs9rIwlWrmr)9GwS{N=0W=^W?ZV8C)wkH4MEk403 zxxGz(Df*N6=!eR+&2N;vzGUCZjkZ1{faN$l>+uS#U^R2JnSmqi(`L^epE&V9AOGol z1z)@PRqoGsAV%6IS_O#}JPa@LtSCnnSZ+gIV_m1asl85=qL)srd?mi|n}^;C@31bt z{`NRpV`I&tnfKakvM2~1NrW|7f>66HA~1Cf++f#`npCbIxb$jE0?@$g0$Tid%0~eX+ECy2oQ&&;AQQC-C~6mi+0pC z)YnCq{$~BIYr5i>p*g2>Sp#NHq^cx(O9x9)gg+rclsx~=uVw@(&dSpOJe=Q)d= zm8>2MbVR{|0(ds3|9`)6>aI1BHO*kud;LC3C_XlmF({YTK~UWGOW^mQlfLEujkLbuTuGtQmKL;oObt3hoiJpEH#S7 za$UW&P#Dx9=?a5NwtrOL+^9o1Ce2cl)3~#~z9b2(kb@vOh%(1=HbyG;?$}vEm-rEC zO6_*5I^_s5K9!nBb;>PjhSVfLAzcrQqcsc0nK~uoL$^C1wWFIo-Q4ai>$h&nj6RDq z@)pc=E$Cxzx2G=PZ4xs^bGxgm9(iyfN2*`5;C%lqP~2{BgNQP7TEySW%g9NqiE*C^ z>`(9guj;6BSdU7a2;fS<3+pIEM-OTloEm+8cxGsMvUr0uiBwApj^|SB^o2d&dDs_SQZSYC~~fvbQVw#!#Wcb zwL@TpT%{>lIIaR35iESRBCfhWOUv;46npaSSfth&RnK_3)a#YbeYwtcTBd+guXrGb zS=C)RD4JY*yHy+YjB|B!T)lYpo#lIbv=1m4agTbRo+@;9&tSU6PL=KpMPG<)+D;X| zTD~_=7bD(Ss$135vzpK`p1b*YWN~49NjGU&E3VrZ*RFDHQ0i{So~AG?8_u&zR%s78 zcR#J&3?R}xTwR%yy~a7R$hm5bdQY&6SJD`kKj@2uTL@(Owhw*M?fv;pX;m z$55E-#@R0Tg>E}gO*V{?o%3V^rt$j-Uq8`85uG#vwwkAiHf^$>B&Hbr{X}ay(OE+T z#2LwX$>%XQDVVdUyY$DV z^m2istlP!yiS;mumrLQ?}7SmkRl2^M(OP

95tLA(986qBwJ`@0xyfZ%6jm! zM0-!bM?9e>Ix53HAJOT8bHD~9^`D)UlJ!Uv#(vO=A!-7$! zpLQG#ImTT4VZ8KcsB{nRNX!?s)(}tB5^OD)m|(lYV0T{)(ON{bURwlW+q%MTu=|Rz zzc1X;7rgt@Jb7u7{BVezqj67sKtDa8PbA@lpdU5qj|B~*Le|?67aw==eMxvE#`U_u zWsct1J+SHZ-Hx|#$6ze)wYsoxYuN7#1!|Fl11rLT{;=-k!SuHs?M9x%`AJB!-(-p?)spaEShBI)0N}XdcRCYMDa!-uwaq)>*>5(WOkAaS* ziOR#xY64Ex3%8uZCZ-`H9Jqq3jWjo=VmEksDSog*` z@-nTNnj|ljYcBM|E-?k(@*)AcxceYfkc9(d;L6~h3Vt#MrYi$aCCZQW8%75VlNH7@ zQN6s{OOsQ`zZwuSL%U3&ohyiz%CNgX{A3rbJ=m)#?TMB4#<(%pS_r2ESS+v7wA3jW z;ixC0hRwkOxoQVZUZymcC$lbBYpxR8cMO8_yQhoAcgld)iEp<(e z|NPsxP_3YMYgLv!S9wCKW|s8z_l%Fvy|ewDGx7JwN4F=&hfmIK$nM^fB%Y3sPMqJ6 zjjpbrLatx|=jQl};2k77xBB|P@5p8RSZw#Keq`Xb^A#vXfX!2}z07*HhCEjd_km^_ zy*Qe4)#TN|t{^fFdZXLJ5RE9FxB8xsafcO-7eZydG3Dcs9P|@Y`Uy%uEg*P4XdD*w zV*~nlDhP$BLUR=y2Y&1#bGuW)u;@(=Tt5ykcnm_(0g(@RDULhtE`qo{4bp_@I&ubL z8{)7=GX)Ux23%JW%|(j5JcO<|>=YzRRHqS%(icLG=OIDhW&NR2NEVS8^3ebVbx8sa=I?g=cae{nBsq$z#7*_6K{>iSHhN$Ta(xCo9j62Y!!X65NBRHd=!B z9szWtR&_$Z3LYeHD?F(EJw`lWe(WpofZMT5S(M=e(x=*2VJ`|Ou-8>I!7wG}5~es= zJEr~^DQ*b}o_dKgdp%ak!dWa{GY{YGjAWNRHkKC{i{Pat#=|f+j)f03!QXJBqHhqq zO)c$aGZcpOKQ0>4zPD=k0+B?@rW*&CL`F?Fq~F3Zs*6Y{3K!N>3&&W!I~n~YQUm Date: Mon, 25 May 2026 12:30:56 +0200 Subject: [PATCH 40/42] fix(supervisor): publish full status (pid, port, uptime, restart_count, last_exit) via watch channel Replace watch::Receiver on SupervisorHandle with watch::Receiver, a richer snapshot type that carries pid, port, uptime_secs, restart_count and last_exit. SupervisorTask maintains current_pid and publishes a fresh Status on every state transition; handlers.rs reads the full Status so list/status no longer return zeroed/None fields. Co-Authored-By: Claude Sonnet 4.6 --- crates/xy-supervisor/src/lib.rs | 3 +- crates/xy-supervisor/src/supervisor.rs | 68 +++++++++++++++++++------- crates/xy/src/daemon/handlers.rs | 28 ++++++----- crates/xy/src/daemon/mod.rs | 17 +++++-- 4 files changed, 80 insertions(+), 36 deletions(-) diff --git a/crates/xy-supervisor/src/lib.rs b/crates/xy-supervisor/src/lib.rs index 10882e0..4807b59 100644 --- a/crates/xy-supervisor/src/lib.rs +++ b/crates/xy-supervisor/src/lib.rs @@ -13,5 +13,6 @@ pub use logs::{LogSink, RecordedLine, RingBuffer, RotatingLogWriter}; pub use policy::{RestartDecision, decide}; pub use retry_window::RetryWindow; pub use supervisor::{ - RealSpawner, Spawner, StartAck, StopAck, SupervisorCmd, SupervisorHandle, SupervisorTask, + RealSpawner, Spawner, StartAck, Status, StopAck, SupervisorCmd, SupervisorHandle, + SupervisorTask, }; diff --git a/crates/xy-supervisor/src/supervisor.rs b/crates/xy-supervisor/src/supervisor.rs index e30a051..c1bdf64 100644 --- a/crates/xy-supervisor/src/supervisor.rs +++ b/crates/xy-supervisor/src/supervisor.rs @@ -42,11 +42,21 @@ pub enum StopAck { NotRunning, } +#[derive(Debug, Clone)] +pub struct Status { + pub state: ServerState, + pub pid: Option, + pub port: u16, + pub uptime_secs: Option, + pub restart_count: u32, + pub last_exit: Option, +} + #[derive(Clone)] pub struct SupervisorHandle { pub name: String, pub tx: mpsc::Sender, - pub state: watch::Receiver, + pub status: watch::Receiver, pub log_sink: LogSink, } @@ -60,13 +70,14 @@ pub struct SupervisorTask { cfg: ServerConfig, log_sink: LogSink, spawner: S, - state_tx: watch::Sender, + status_tx: watch::Sender, cmd_rx: mpsc::Receiver, backoff: Backoff, retry_window: RetryWindow, restart_count: u32, last_exit: Option, started_at: Option, + current_pid: Option, } impl SupervisorTask { @@ -74,7 +85,7 @@ impl SupervisorTask { cfg: ServerConfig, log_sink: LogSink, spawner: S, - state_tx: watch::Sender, + status_tx: watch::Sender, cmd_rx: mpsc::Receiver, ) -> Self { let backoff = Backoff::new(cfg.restart.backoff_initial, cfg.restart.backoff_max); @@ -85,18 +96,28 @@ impl SupervisorTask { cfg, log_sink, spawner, - state_tx, + status_tx, cmd_rx, backoff, retry_window, restart_count: 0, last_exit: None, started_at: None, + current_pid: None, } } - fn set_state(&self, s: ServerState) { - let _ = self.state_tx.send(s); + fn set_state(&mut self, s: ServerState) { + let uptime_secs = self.started_at.map(|t| t.elapsed().as_secs()); + + let _ = self.status_tx.send(Status { + state: s, + pid: self.current_pid, + port: self.cfg.port, + uptime_secs, + restart_count: self.restart_count, + last_exit: self.last_exit, + }); } pub async fn run(mut self) { @@ -174,6 +195,7 @@ impl SupervisorTask { child = None; self.last_exit = code; + self.current_pid = None; let now = Instant::now(); @@ -221,6 +243,7 @@ impl SupervisorTask { self.restart_count = self.restart_count.saturating_add(1); self.started_at = Some(Instant::now()); + self.current_pid = Some(c.pid()); self.backoff.reset(); self.set_state(ServerState::Running); @@ -244,6 +267,7 @@ impl SupervisorTask { } } + self.current_pid = None; self.started_at = None; self.set_state(ServerState::Stopped); } @@ -317,34 +341,40 @@ mod tests { LogSink::new(name.to_string(), writer, 1024) } - async fn wait_for(rx: &mut watch::Receiver, want: ServerState) { + fn initial_status(cfg: &ServerConfig) -> Status { + Status { + state: ServerState::Stopped, + pid: None, + port: cfg.port, + uptime_secs: None, + restart_count: 0, + last_exit: None, + } + } + + async fn wait_for(rx: &mut watch::Receiver, want: ServerState) { let deadline = tokio::time::Instant::now() + Duration::from_secs(2); loop { - if *rx.borrow() == want { + if rx.borrow().state == want { return; } tokio::select! { _ = rx.changed() => {} - _ = tokio::time::sleep_until(deadline) => panic!("never reached {want:?}, last={:?}", *rx.borrow()), + _ = tokio::time::sleep_until(deadline) => panic!("never reached {want:?}, last={:?}", rx.borrow().state), } } } #[tokio::test] async fn start_runs_to_running_and_stop_to_stopped() { + let cfg = cfg("x", RestartPolicy::Never, 5); let (mock, mut ctl) = MockChild::new(1); let queue = Arc::new(Mutex::new(vec![mock])); let spawner = QueueSpawner { queue }; - let (state_tx, mut state_rx) = watch::channel(ServerState::Stopped); + let (status_tx, mut status_rx) = watch::channel(initial_status(&cfg)); let (cmd_tx, cmd_rx) = mpsc::channel(8); - let task = SupervisorTask::new( - cfg("x", RestartPolicy::Never, 5), - sink("x"), - spawner, - state_tx, - cmd_rx, - ); + let task = SupervisorTask::new(cfg, sink("x"), spawner, status_tx, cmd_rx); let h = tokio::spawn(task.run()); let (ack_tx, ack_rx) = oneshot::channel(); @@ -353,10 +383,10 @@ mod tests { .await .unwrap(); assert_eq!(ack_rx.await.unwrap(), StartAck::Started); - wait_for(&mut state_rx, ServerState::Running).await; + wait_for(&mut status_rx, ServerState::Running).await; ctl.exit_tx.take().unwrap().send(Some(0)).unwrap(); - wait_for(&mut state_rx, ServerState::Stopped).await; + wait_for(&mut status_rx, ServerState::Stopped).await; let (ack_tx, ack_rx) = oneshot::channel(); cmd_tx diff --git a/crates/xy/src/daemon/handlers.rs b/crates/xy/src/daemon/handlers.rs index f0333fe..c5b5938 100644 --- a/crates/xy/src/daemon/handlers.rs +++ b/crates/xy/src/daemon/handlers.rs @@ -163,14 +163,16 @@ async fn list(reg: &Registry) -> Result, ApiError> { let mut out = Vec::new(); for (name, entry) in reg.snapshot().await { + let s = entry.handle.status.borrow(); + out.push(ServerSummary { name, - state: *entry.handle.state.borrow(), - pid: None, - port: 0, - uptime_secs: None, - restart_count: 0, - last_exit: None, + state: s.state, + pid: s.pid, + port: s.port, + uptime_secs: s.uptime_secs, + restart_count: s.restart_count, + last_exit: s.last_exit, }); } @@ -185,15 +187,17 @@ async fn status(reg: &Registry, name: &str) -> Result { )); }; + let s = entry.handle.status.borrow(); + Ok(StatusDetail { summary: ServerSummary { name: entry.handle.name.clone(), - state: *entry.handle.state.borrow(), - pid: None, - port: 0, - uptime_secs: None, - restart_count: 0, - last_exit: None, + state: s.state, + pid: s.pid, + port: s.port, + uptime_secs: s.uptime_secs, + restart_count: s.restart_count, + last_exit: s.last_exit, }, recent_transitions: Vec::new(), }) diff --git a/crates/xy/src/daemon/mod.rs b/crates/xy/src/daemon/mod.rs index 98bbb22..02a546e 100644 --- a/crates/xy/src/daemon/mod.rs +++ b/crates/xy/src/daemon/mod.rs @@ -8,7 +8,7 @@ use xy_ipc::{Connection, bind}; use xy_protocol::{ServerConfig, ServerState, kdl_parse::load_all_configs}; use xy_supervisor::{ logs::{LogSink, RotatingLogWriter}, - supervisor::{RealSpawner, SupervisorCmd, SupervisorHandle, SupervisorTask}, + supervisor::{RealSpawner, Status, SupervisorCmd, SupervisorHandle, SupervisorTask}, }; pub mod handlers; @@ -39,19 +39,28 @@ pub fn spawn_supervisor(paths: &Paths, cfg: ServerConfig) -> Result Date: Mon, 25 May 2026 12:31:32 +0200 Subject: [PATCH 41/42] fix(supervisor): make backoff sleep interruptible by Stop/Shutdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the bare sleep(delay).await in the Restart backoff arm with a tokio::select! over the timer and cmd_rx. Stop/Shutdown are now handled immediately during backoff (Stop → Stopped, Shutdown → clean exit); Start/Restart/Reconfigure skip the remaining delay and retry at once. Co-Authored-By: Claude Sonnet 4.6 --- crates/xy-supervisor/src/supervisor.rs | 63 +++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 6 deletions(-) diff --git a/crates/xy-supervisor/src/supervisor.rs b/crates/xy-supervisor/src/supervisor.rs index c1bdf64..8c343e7 100644 --- a/crates/xy-supervisor/src/supervisor.rs +++ b/crates/xy-supervisor/src/supervisor.rs @@ -220,14 +220,65 @@ impl SupervisorTask { let delay = self.backoff.next(); - sleep(delay).await; + enum Action { + RetryNow, + Cancel, + Exit, + } - match self.do_start().await { - Ok(c) => child = Some(c), - Err(err) => { - warn!(name = %self.cfg.name, error = %err, "restart spawn failed"); - self.set_state(ServerState::Failed); + let mut delay_fut = std::pin::pin!(sleep(delay)); + + let action = tokio::select! { + _ = &mut delay_fut => Action::RetryNow, + cmd = self.cmd_rx.recv() => match cmd { + None => Action::Exit, + Some(SupervisorCmd::Stop { ack }) => { + let _ = ack.send(StopAck::NotRunning); + Action::Cancel + } + Some(SupervisorCmd::Shutdown { ack }) => { + let _ = ack.send(()); + return; + } + Some(SupervisorCmd::Start { ack }) => { + let _ = ack.send(StartAck::Started); + Action::RetryNow + } + Some(SupervisorCmd::Restart { ack }) => { + let _ = ack.send(()); + Action::RetryNow + } + Some(SupervisorCmd::Reconfigure { new, ack }) => { + self.cfg = new; + self.backoff = Backoff::new( + self.cfg.restart.backoff_initial, + self.cfg.restart.backoff_max, + ); + self.retry_window = RetryWindow::new( + Duration::from_secs(60), + self.cfg.restart.max_retries_per_minute, + ); + let _ = ack.send(()); + Action::RetryNow + } + }, + }; + + match action { + Action::RetryNow => { + match self.do_start().await { + Ok(c) => child = Some(c), + Err(err) => { + warn!(name = %self.cfg.name, error = %err, "restart spawn failed"); + self.set_state(ServerState::Failed); + } + } } + Action::Cancel => { + self.started_at = None; + self.set_state(ServerState::Stopped); + } + Action::Exit => return, } } } From 4a0b32d90ef7ef5b30f0657440ce0c82a6e086b7 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 25 May 2026 12:32:07 +0200 Subject: [PATCH 42/42] fix(supervisor): StartAck::SpawnFailed surfaces real failures Add StartAck::SpawnFailed(String) so callers can distinguish a successful start from a failed spawn. The Start command arm now sends SpawnFailed on io::Error rather than the misleading Started. handlers.rs maps the new variant to an RpcErrorCode::SpawnFailed JSON-RPC error response. Co-Authored-By: Claude Sonnet 4.6 --- crates/xy-supervisor/src/supervisor.rs | 3 ++- crates/xy/src/daemon/handlers.rs | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/xy-supervisor/src/supervisor.rs b/crates/xy-supervisor/src/supervisor.rs index 8c343e7..42edcf9 100644 --- a/crates/xy-supervisor/src/supervisor.rs +++ b/crates/xy-supervisor/src/supervisor.rs @@ -34,6 +34,7 @@ pub enum SupervisorCmd { pub enum StartAck { Started, AlreadyRunning, + SpawnFailed(String), } #[derive(Debug, PartialEq, Eq)] @@ -141,7 +142,7 @@ impl SupervisorTask { Err(err) => { warn!(name = %self.cfg.name, error = %err, "spawn failed"); self.set_state(ServerState::Failed); - let _ = ack.send(StartAck::Started); + let _ = ack.send(StartAck::SpawnFailed(err.to_string())); } } } diff --git a/crates/xy/src/daemon/handlers.rs b/crates/xy/src/daemon/handlers.rs index c5b5938..d331308 100644 --- a/crates/xy/src/daemon/handlers.rs +++ b/crates/xy/src/daemon/handlers.rs @@ -247,6 +247,13 @@ async fn dispatch_lifecycle( match rx.await { Ok(StartAck::Started) => started.push(name), Ok(StartAck::AlreadyRunning) => already.push(name), + Ok(StartAck::SpawnFailed(msg)) => { + return err_response( + id, + RpcErrorCode::SpawnFailed.as_i32(), + format!("failed to start `{name}`: {msg}"), + ); + } Err(_) => { return err_response( id,