diff --git a/Cargo.lock b/Cargo.lock index 2f5c85a..8bf8325 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.8.12" @@ -24,6 +59,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "const-random", "getrandom 0.3.4", "once_cell", "version_check", @@ -350,6 +386,12 @@ dependencies = [ "tokio", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base32" version = "0.5.1" @@ -374,6 +416,17 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bcrypt-pbkdf" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2" +dependencies = [ + "blowfish", + "pbkdf2", + "sha2 0.10.9", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -394,6 +447,9 @@ name = "bitflags" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] [[package]] name = "blake3" @@ -427,6 +483,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -436,6 +501,16 @@ dependencies = [ "objc2", ] +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + [[package]] name = "bs58" version = "0.5.1" @@ -487,6 +562,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.56" @@ -517,6 +601,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "chrono" version = "0.4.43" @@ -524,8 +619,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link", ] @@ -562,6 +659,16 @@ dependencies = [ "half", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + [[package]] name = "clap" version = "4.5.58" @@ -662,6 +769,26 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -963,6 +1090,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -970,6 +1109,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -982,6 +1122,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "ctrlc" version = "3.5.1" @@ -1158,6 +1307,17 @@ dependencies = [ "uuid", ] +[[package]] +name = "delegate" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "der" version = "0.7.10" @@ -1165,6 +1325,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid 0.9.6", + "pem-rfc7468 0.7.0", "zeroize", ] @@ -1175,7 +1336,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" dependencies = [ "const-oid 0.10.2", - "pem-rfc7468", + "pem-rfc7468 1.0.0", "zeroize", ] @@ -1256,6 +1417,15 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "des" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" +dependencies = [ + "cipher", +] + [[package]] name = "diatomic-waker" version = "0.2.3" @@ -1269,6 +1439,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", + "const-oid 0.9.6", "crypto-common 0.1.7", "subtle", ] @@ -1386,6 +1557,20 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der 0.7.10", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature 2.2.0", + "spki 0.7.3", +] + [[package]] name = "ed25519" version = "2.2.3" @@ -1444,6 +1629,27 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468 0.7.0", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "embedded-io" version = "0.4.0" @@ -1518,7 +1724,7 @@ checksum = "4e7f34442dbe69c60fe8eaf58a8cafff81a1f278816d8ab4db255b3bef4ac3c4" dependencies = [ "getrandom 0.3.4", "libm", - "rand", + "rand 0.9.2", "siphasher", ] @@ -1528,6 +1734,16 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -1556,6 +1772,18 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "flurry" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf5efcf77a4da27927d3ab0509dec5b0954bb3bc59da5a1de9e52642ebd4cdf9" +dependencies = [ + "ahash", + "num_cpus", + "parking_lot", + "seize 0.3.3", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1752,7 +1980,7 @@ dependencies = [ "log", "rustversion", "windows-link", - "windows-result", + "windows-result 0.4.1", ] [[package]] @@ -1763,6 +1991,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1807,6 +2036,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gimli" version = "0.31.1" @@ -1830,6 +2069,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" version = "0.4.13" @@ -1933,6 +2183,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex-literal" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" + [[package]] name = "hickory-proto" version = "0.25.2" @@ -1952,7 +2208,7 @@ dependencies = [ "idna", "ipnet", "once_cell", - "rand", + "rand 0.9.2", "ring", "rustls", "thiserror 2.0.18", @@ -1976,7 +2232,7 @@ dependencies = [ "moka", "once_cell", "parking_lot", - "rand", + "rand 0.9.2", "resolv-conf", "rustls", "smallvec", @@ -1986,6 +2242,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.12.1" @@ -1995,6 +2260,15 @@ dependencies = [ "digest 0.10.7", ] +[[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 = "http" version = "1.4.0" @@ -2142,7 +2416,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -2289,7 +2563,7 @@ dependencies = [ "hyper", "hyper-util", "log", - "rand", + "rand 0.9.2", "tokio", "url", "xmltree", @@ -2316,6 +2590,16 @@ dependencies = [ "rustversion", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "instability" version = "0.3.11" @@ -2390,7 +2674,7 @@ dependencies = [ "pkarr", "pkcs8 0.11.0-rc.11", "portmapper", - "rand", + "rand 0.9.2", "reqwest 0.12.28", "rustc-hash", "rustls", @@ -2496,7 +2780,7 @@ dependencies = [ "getrandom 0.3.4", "identity-hash", "lru-slab", - "rand", + "rand 0.9.2", "ring", "rustc-hash", "rustls", @@ -2554,7 +2838,7 @@ dependencies = [ "pin-project", "pkarr", "postcard", - "rand", + "rand 0.9.2", "rcgen", "reloadable-state", "reqwest 0.12.28", @@ -2565,7 +2849,7 @@ dependencies = [ "serde", "serde_bytes", "serde_json", - "sha1", + "sha1 0.11.0-rc.4", "simdutf8", "strum 0.27.2", "time", @@ -2701,6 +2985,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin 0.9.8", +] [[package]] name = "leb128" @@ -2842,6 +3129,12 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + [[package]] name = "memchr" version = "2.8.0" @@ -3100,8 +3393,8 @@ dependencies = [ "tokio-util", "tracing", "web-sys", - "windows", - "windows-result", + "windows 0.62.2", + "windows-result 0.4.1", "wmi", ] @@ -3159,6 +3452,23 @@ checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", "num-traits", + "rand 0.8.5", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", ] [[package]] @@ -3176,6 +3486,17 @@ 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-traits" version = "0.2.19" @@ -3183,6 +3504,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", ] [[package]] @@ -3321,6 +3653,12 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl" version = "0.10.75" @@ -3365,6 +3703,59 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core 0.6.4", + "sha2 0.10.9", +] + +[[package]] +name = "pageant" +version = "0.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "032d6201d2fb765158455ae0d5a510c016bb6da7232e5040e39e9c8db12b0afc" +dependencies = [ + "bytes", + "delegate", + "futures", + "rand 0.8.5", + "thiserror 1.0.69", + "tokio", + "windows 0.58.0", +] + [[package]] name = "papaya" version = "0.2.3" @@ -3372,7 +3763,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f92dd0b07c53a0a0c764db2ace8c541dc47320dad97c2200c2a637ab9dd2328f" dependencies = [ "equivalent", - "seize", + "seize 0.5.1", ] [[package]] @@ -3410,6 +3801,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac", +] + [[package]] name = "pem" version = "3.0.6" @@ -3420,6 +3821,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "pem-rfc7468" version = "1.0.0" @@ -3508,6 +3918,32 @@ dependencies = [ "wasm-bindgen-futures", ] +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der 0.7.10", + "pkcs8 0.10.2", + "spki 0.7.3", +] + +[[package]] +name = "pkcs5" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6" +dependencies = [ + "aes", + "cbc", + "der 0.7.10", + "pbkdf2", + "scrypt", + "sha2 0.10.9", + "spki 0.7.3", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -3515,6 +3951,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ "der 0.7.10", + "pkcs5", + "rand_core 0.6.4", "spki 0.7.3", ] @@ -3575,6 +4013,29 @@ dependencies = [ "plotters-backend", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -3599,7 +4060,7 @@ dependencies = [ "n0-error", "netwatch", "num_enum", - "rand", + "rand 0.9.2", "serde", "smallvec", "socket2 0.6.2", @@ -3670,6 +4131,15 @@ dependencies = [ "syn", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-crate" version = "3.4.0" @@ -3698,8 +4168,8 @@ dependencies = [ "bit-vec", "bitflags", "num-traits", - "rand", - "rand_chacha", + "rand 0.9.2", + "rand_chacha 0.9.0", "rand_xorshift", "regex-syntax", "rusty-fork", @@ -3854,7 +4324,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand", + "rand 0.9.2", "ring", "rustc-hash", "rustls", @@ -3895,16 +4365,37 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ - "rand_chacha", + "rand_chacha 0.9.0", "rand_core 0.9.5", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -4166,6 +4657,16 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -4180,6 +4681,165 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sha2 0.10.9", + "signature 2.2.0", + "spki 0.7.3", + "subtle", + "zeroize", +] + +[[package]] +name = "russh" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c536b90d8e2468d8dedc8de2369383c101325e23fffa3a30de713032862a11d4" +dependencies = [ + "aes", + "aes-gcm", + "async-trait", + "bitflags", + "byteorder", + "cbc", + "chacha20", + "ctr", + "curve25519-dalek 4.1.3", + "des", + "digest 0.10.7", + "elliptic-curve", + "flate2", + "futures", + "generic-array", + "hex-literal", + "hmac", + "log", + "num-bigint", + "once_cell", + "p256", + "p384", + "p521", + "poly1305", + "rand 0.8.5", + "rand_core 0.6.4", + "russh-cryptovec", + "russh-keys", + "russh-sftp", + "russh-util", + "sha1 0.10.6", + "sha2 0.10.9", + "ssh-encoding", + "ssh-key", + "subtle", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "russh-cryptovec" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fadd2c0ab350e21c66556f94ee06f766d8bdae3213857ba7610bfd8e10e51880" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "russh-keys" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e3db166c8678c824627c2c46f619ed5ce4ae33f38a35403c62f6ab8f3985867" +dependencies = [ + "aes", + "async-trait", + "bcrypt-pbkdf", + "block-padding", + "byteorder", + "cbc", + "ctr", + "data-encoding", + "der 0.7.10", + "digest 0.10.7", + "ecdsa", + "ed25519-dalek 2.2.0", + "elliptic-curve", + "futures", + "getrandom 0.2.17", + "hmac", + "home", + "inout", + "log", + "md5", + "num-integer", + "p256", + "p384", + "p521", + "pageant", + "pbkdf2", + "pkcs1", + "pkcs5", + "pkcs8 0.10.2", + "rand 0.8.5", + "rand_core 0.6.4", + "rsa", + "russh-cryptovec", + "russh-util", + "sec1", + "serde", + "sha1 0.10.6", + "sha2 0.10.9", + "spki 0.7.3", + "ssh-encoding", + "ssh-key", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "typenum", + "zeroize", +] + +[[package]] +name = "russh-sftp" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bb94393cafad0530145b8f626d8687f1ee1dedb93d7ba7740d6ae81868b13b5" +dependencies = [ + "bitflags", + "bytes", + "chrono", + "flurry", + "log", + "serde", + "thiserror 2.0.18", + "tokio", + "tokio-util", +] + +[[package]] +name = "russh-util" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63aeb9d2b74f8f38befdc0c5172d5ffcf58f3d2ffcb423f3b6cdfe2c2d747b80" +dependencies = [ + "chrono", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", +] + [[package]] name = "rustc-demangle" version = "0.1.27" @@ -4371,6 +5031,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + [[package]] name = "same-file" version = "1.0.6" @@ -4401,6 +5070,31 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "pbkdf2", + "salsa20", + "sha2 0.10.9", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der 0.7.10", + "generic-array", + "pkcs8 0.10.2", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.6.0" @@ -4424,6 +5118,12 @@ dependencies = [ "libc", ] +[[package]] +name = "seize" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "689224d06523904ebcc9b482c6a3f4f7fb396096645c4cd10c0d2ff7371a34d3" + [[package]] name = "seize" version = "0.5.1" @@ -4563,6 +5263,17 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + [[package]] name = "sha1" version = "0.11.0-rc.4" @@ -4662,6 +5373,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ + "digest 0.10.7", "rand_core 0.6.4", ] @@ -4807,6 +5519,57 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b9b39299b249ad65f3b7e96443bad61c02ca5cd3589f46cb6d610a0fd6c0d6a" +[[package]] +name = "ssh-cipher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +dependencies = [ + "aes", + "aes-gcm", + "cbc", + "chacha20", + "cipher", + "ctr", + "poly1305", + "ssh-encoding", + "subtle", +] + +[[package]] +name = "ssh-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +dependencies = [ + "base64ct", + "pem-rfc7468 0.7.0", + "sha2 0.10.9", +] + +[[package]] +name = "ssh-key" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" +dependencies = [ + "bcrypt-pbkdf", + "ed25519-dalek 2.2.0", + "num-bigint-dig", + "p256", + "p384", + "p521", + "rand_core 0.6.4", + "rsa", + "sec1", + "sha2 0.10.9", + "signature 2.2.0", + "ssh-cipher", + "ssh-encoding", + "subtle", + "zeroize", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4958,6 +5721,21 @@ dependencies = [ "toml 0.8.23", ] +[[package]] +name = "swactor-process" +version = "0.1.0" +dependencies = [ + "async-trait", + "crossbeam-queue", + "libc", + "proptest", + "proptest-state-machine", + "russh", + "russh-keys", + "swactor", + "tokio", +] + [[package]] name = "swactor-std" version = "0.1.0" @@ -5141,6 +5919,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tiny_http" version = "0.12.0" @@ -5302,7 +6089,7 @@ dependencies = [ "getrandom 0.3.4", "http", "httparse", - "rand", + "rand 0.9.2", "ring", "rustls-pki-types", "simdutf8", @@ -5587,6 +6374,16 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -6328,6 +7125,16 @@ dependencies = [ "wasmtime-environ", ] +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.62.2" @@ -6335,7 +7142,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ "windows-collections", - "windows-core", + "windows-core 0.62.2", "windows-future", "windows-numerics", ] @@ -6346,7 +7153,20 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-core", + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", ] [[package]] @@ -6355,11 +7175,11 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link", - "windows-result", - "windows-strings", + "windows-result 0.4.1", + "windows-strings 0.5.1", ] [[package]] @@ -6368,11 +7188,22 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "windows-core", + "windows-core 0.62.2", "windows-link", "windows-threading", ] +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -6384,6 +7215,17 @@ dependencies = [ "syn", ] +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -6407,7 +7249,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-core", + "windows-core 0.62.2", "windows-link", ] @@ -6418,8 +7260,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ "windows-link", - "windows-result", - "windows-strings", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", ] [[package]] @@ -6431,6 +7282,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-strings" version = "0.5.1" @@ -6882,8 +7743,8 @@ dependencies = [ "log", "serde", "thiserror 2.0.18", - "windows", - "windows-core", + "windows 0.62.2", + "windows-core 0.62.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 071ee24..391d6f7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/dashboard", "crates/distribution", "crates/std", + "crates/process", "crates/datastore", "crates/shared-types", "crates/swactor-node", diff --git a/crates/process/Cargo.toml b/crates/process/Cargo.toml new file mode 100644 index 0000000..7ff3c55 --- /dev/null +++ b/crates/process/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "swactor-process" +version = "0.1.0" +edition = "2024" + +[features] +default = [] +ssh = ["dep:russh", "dep:russh-keys", "dep:tokio", "dep:async-trait"] + +[dependencies] +swactor = { path = "../..", default-features = false, features = ["no_random"] } +crossbeam-queue = "0.3.12" +libc = "0.2" +russh = { version = "0.46", optional = true } +russh-keys = { version = "0.46", optional = true } +tokio = { version = "1", features = ["rt", "time", "sync"], optional = true } +async-trait = { version = "0.1", optional = true } + +[dev-dependencies] +proptest = "1" +proptest-state-machine = "0.3" diff --git a/crates/process/src/action.rs b/crates/process/src/action.rs new file mode 100644 index 0000000..6ded095 --- /dev/null +++ b/crates/process/src/action.rs @@ -0,0 +1,53 @@ +use std::time::Duration; + +use crate::types::{ExitStatus, ProcessError, ProcessSpec, PtySize, Signal}; +use swactor::actor::ActorAddress; + +/// Which output stream produced data. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OutputStream { + Stdout, + Stderr, +} + +/// Actions emitted by ProcessSession for the driver or actor layer to execute. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProcessAction { + // --- Driver commands --- + /// Spawn the process described by the spec. + SpawnProcess { spec: ProcessSpec }, + /// Write bytes to the process's stdin. + WriteStdin { data: Vec }, + /// Send a signal to the process. + SendSignal { signal: Signal }, + /// Resize the process's PTY. + ResizePty { size: PtySize }, + /// Close the process's stdin pipe. + CloseStdin, + /// Schedule a kill timeout that fires KillTimeout after the given duration. + ScheduleKillTimeout { duration: Duration }, + + // --- Subscriber notifications --- + /// Notify subscribers that the process started. + NotifyStarted { subscribers: Vec }, + /// Notify subscribers of output. + NotifyOutput { + subscribers: Vec, + data: Vec, + stream: OutputStream, + }, + /// Notify subscribers that the process exited. + NotifyExited { + subscribers: Vec, + status: ExitStatus, + }, + /// Notify subscribers of an error. + NotifyError { + subscribers: Vec, + error: ProcessError, + }, + + // --- Lifecycle --- + /// The session is done; the owning actor should stop itself. + SelfTerminate, +} diff --git a/crates/process/src/actor.rs b/crates/process/src/actor.rs new file mode 100644 index 0000000..82b2a82 --- /dev/null +++ b/crates/process/src/actor.rs @@ -0,0 +1,161 @@ +use std::sync::{Arc, OnceLock}; + +use swactor::actor::{ActorAddress, ActorInterface, Ctx}; + +use crate::action::ProcessAction; +use crate::driver::ProcessDriver; +use crate::event::ProcessEvent; +use crate::message::{ProcessCommand, ProcessNotification}; +use crate::session::ProcessSession; +use crate::waker::ProcessWaker; + +/// Actor wrapper around a `ProcessSession` and its driver. +/// +/// Generic over `D: ProcessDriver` so that tests can use `MockDriver` or +/// `TestDriver` while production uses `LocalDriver`. +pub struct ProcessActor { + session: ProcessSession, + driver: D, + self_addr: Option, + /// Actions from `ProcessSession::new()`, executed in `on_start`. + deferred_actions: Option>, + /// Shared slot for the waker — filled after the actor address is known. + pub waker_slot: Arc>, +} + +impl ProcessActor { + pub fn new( + session: ProcessSession, + driver: D, + initial_actions: Vec, + waker_slot: Arc>, + ) -> Self { + Self { + session, + driver, + self_addr: None, + deferred_actions: Some(initial_actions), + waker_slot, + } + } + + /// Drain events from the driver, apply each to the session, and dispatch + /// all resulting actions. + fn drain_and_dispatch(&mut self, ctx: &Ctx) { + let events = self.driver.poll(); + for event in events { + let actions = self.session.apply(event); + self.dispatch_actions(ctx, actions); + } + } + + /// Execute actions produced by the session state machine. + fn dispatch_actions(&mut self, ctx: &Ctx, actions: Vec) { + let self_addr = self.self_addr.expect("self_addr not set"); + for action in actions { + match action { + // Driver commands — forward to the driver + ProcessAction::SpawnProcess { .. } + | ProcessAction::WriteStdin { .. } + | ProcessAction::SendSignal { .. } + | ProcessAction::ResizePty { .. } + | ProcessAction::CloseStdin + | ProcessAction::ScheduleKillTimeout { .. } => { + self.driver.execute(action); + } + + // Subscriber notifications — send to each subscriber + ProcessAction::NotifyStarted { subscribers } => { + let notif = ProcessNotification::Started { process: self_addr }; + for sub in subscribers { + let _ = ctx.send(sub, notif.clone()); + } + } + ProcessAction::NotifyOutput { + subscribers, + data, + stream, + } => { + let notif = ProcessNotification::Output { + process: self_addr, + data, + stream, + }; + for sub in subscribers { + let _ = ctx.send(sub, notif.clone()); + } + } + ProcessAction::NotifyExited { + subscribers, + status, + } => { + let notif = ProcessNotification::Exited { + process: self_addr, + status, + }; + for sub in subscribers { + let _ = ctx.send(sub, notif.clone()); + } + } + ProcessAction::NotifyError { + subscribers, + error, + } => { + let notif = ProcessNotification::Error { + process: self_addr, + error, + }; + for sub in subscribers { + let _ = ctx.send(sub, notif.clone()); + } + } + + // Lifecycle + ProcessAction::SelfTerminate => { + ctx.stop_self(); + } + } + } + } + + /// Map a `ProcessCommand` to the corresponding `ProcessEvent`. + fn command_to_event(cmd: ProcessCommand) -> Option { + match cmd { + ProcessCommand::WriteStdin { data } => Some(ProcessEvent::WriteStdin { data }), + ProcessCommand::SendSignal { signal } => Some(ProcessEvent::SendSignal { signal }), + ProcessCommand::ResizePty { size } => Some(ProcessEvent::ResizePty { size }), + ProcessCommand::CloseStdin => Some(ProcessEvent::CloseStdin), + ProcessCommand::Close => Some(ProcessEvent::CloseRequested), + ProcessCommand::Subscribe { address } => Some(ProcessEvent::Subscribe { address }), + ProcessCommand::Unsubscribe { address } => { + Some(ProcessEvent::Unsubscribe { address }) + } + ProcessCommand::PollTick => None, // handled by drain + } + } +} + +impl ActorInterface for ProcessActor { + type Incoming = ProcessCommand; + type Response = (); + + fn on_start(&mut self, ctx: &Ctx) { + self.self_addr = Some(ctx.self_addr()); + if let Some(actions) = self.deferred_actions.take() { + self.dispatch_actions(ctx, actions); + } + } + + fn handle(&mut self, ctx: &Ctx, msg: ProcessCommand) { + // Process the incoming command first — this ensures Subscribe + // registers before drain dispatches notifications, and keeps + // user commands (Close, WriteStdin) responsive. + if let Some(event) = Self::command_to_event(msg) { + let actions = self.session.apply(event); + self.dispatch_actions(ctx, actions); + } + + // Then drain pending I/O events from background threads. + self.drain_and_dispatch(ctx); + } +} diff --git a/crates/process/src/driver.rs b/crates/process/src/driver.rs new file mode 100644 index 0000000..dc92fdd --- /dev/null +++ b/crates/process/src/driver.rs @@ -0,0 +1,14 @@ +use crate::action::ProcessAction; +use crate::event::ProcessEvent; + +/// Abstraction over the mechanism that actually runs a process. +/// +/// Implementations translate `ProcessAction` commands into real I/O (or mock I/O) +/// and produce `ProcessEvent`s by polling for state changes. +pub trait ProcessDriver: Send { + /// Execute an action (spawn, write stdin, send signal, etc.). + fn execute(&mut self, action: ProcessAction); + + /// Poll for new events from the underlying process. + fn poll(&mut self) -> Vec; +} diff --git a/crates/process/src/event.rs b/crates/process/src/event.rs new file mode 100644 index 0000000..86f54b7 --- /dev/null +++ b/crates/process/src/event.rs @@ -0,0 +1,71 @@ +use crate::types::{ExitStatus, PtySize, Signal}; +use swactor::actor::ActorAddress; + +/// Events that can be applied to a ProcessSession. +/// +/// Some events come from the driver (Started, SpawnFailed, OutputReceived, etc.), +/// others come from the owning actor (WriteStdin, SendSignal, Subscribe, etc.). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProcessEvent { + // --- Driver-sourced events --- + /// The process spawned successfully. + Started, + /// The kill timeout fired (process didn't exit after SIGTERM). + KillTimeout, + /// The process failed to spawn. + SpawnFailed { reason: String }, + /// Output received on stdout or stderr. + OutputReceived { data: Vec, is_stderr: bool }, + /// The process exited. + Exited { status: ExitStatus }, + /// Connection to the process was lost unexpectedly. + ConnectionLost { reason: String }, + + // --- Driver acknowledgement events --- + /// Stdin bytes were successfully written. + StdinWritten { byte_count: usize }, + /// A signal was delivered. + SignalSent, + /// The PTY was resized. + PtyResized, + + // --- Actor-sourced events --- + /// Write data to the process's stdin. + WriteStdin { data: Vec }, + /// Send a signal to the process. + SendSignal { signal: Signal }, + /// Resize the process's PTY. + ResizePty { size: PtySize }, + /// Close the process's stdin. + CloseStdin, + /// Request a graceful close of the process. + CloseRequested, + /// Subscribe an actor to process notifications. + Subscribe { address: ActorAddress }, + /// Unsubscribe an actor from process notifications. + Unsubscribe { address: ActorAddress }, +} + +impl ProcessEvent { + /// Human-readable name for error messages. + pub fn name(&self) -> &'static str { + match self { + Self::Started => "Started", + Self::KillTimeout => "KillTimeout", + Self::SpawnFailed { .. } => "SpawnFailed", + Self::OutputReceived { .. } => "OutputReceived", + Self::Exited { .. } => "Exited", + Self::ConnectionLost { .. } => "ConnectionLost", + Self::StdinWritten { .. } => "StdinWritten", + Self::SignalSent => "SignalSent", + Self::PtyResized => "PtyResized", + Self::WriteStdin { .. } => "WriteStdin", + Self::SendSignal { .. } => "SendSignal", + Self::ResizePty { .. } => "ResizePty", + Self::CloseStdin => "CloseStdin", + Self::CloseRequested => "CloseRequested", + Self::Subscribe { .. } => "Subscribe", + Self::Unsubscribe { .. } => "Unsubscribe", + } + } +} diff --git a/crates/process/src/lib.rs b/crates/process/src/lib.rs new file mode 100644 index 0000000..4ccd085 --- /dev/null +++ b/crates/process/src/lib.rs @@ -0,0 +1,35 @@ +pub mod action; +pub mod actor; +pub mod driver; +pub mod event; +pub mod local; +pub mod message; +pub mod mock; +pub mod queue; +pub mod session; +pub mod spawn; +pub mod subscriber; +pub mod types; +pub mod waker; + +#[cfg(feature = "ssh")] +pub mod ssh; + +pub use action::{OutputStream, ProcessAction}; +pub use actor::ProcessActor; +pub use driver::ProcessDriver; +pub use event::ProcessEvent; +pub use local::LocalDriver; +pub use message::{ProcessCommand, ProcessNotification}; +pub use mock::MockDriver; +pub use queue::EventQueue; +pub use session::{ProcessSession, ProcessState}; +pub use spawn::{spawn_local_process, spawn_process}; +pub use subscriber::SubscriberSet; +pub use types::{ExitStatus, FlowControl, ProcessError, ProcessMode, ProcessSpec, PtySize, Signal}; +pub use waker::ProcessWaker; + +#[cfg(feature = "ssh")] +pub use ssh::{SshConfig, SshDriver}; +#[cfg(feature = "ssh")] +pub use spawn::spawn_ssh_process; diff --git a/crates/process/src/local/mod.rs b/crates/process/src/local/mod.rs new file mode 100644 index 0000000..8d313a1 --- /dev/null +++ b/crates/process/src/local/mod.rs @@ -0,0 +1,182 @@ +mod pipes; +mod signal; +mod wait; + +use std::io::Write; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::{Arc, OnceLock}; +use std::thread::{self, JoinHandle}; + +use crate::action::ProcessAction; +use crate::driver::ProcessDriver; +use crate::event::ProcessEvent; +use crate::queue::EventQueue; +use crate::types::ProcessSpec; +use crate::waker::ProcessWaker; + +/// A `ProcessDriver` that spawns real OS subprocesses via `std::process::Command`. +/// +/// Background threads read stdout/stderr and wait for process exit, +/// pushing events into a shared `EventQueue`. The actor polls via `poll()`. +pub struct LocalDriver { + queue: EventQueue, + waker_slot: Arc>, + child: Option, + stdin: Option, + _reader_threads: Vec>, + _wait_thread: Option>, +} + +impl LocalDriver { + pub fn new(queue: EventQueue, waker_slot: Arc>) -> Self { + Self { + queue, + waker_slot, + child: None, + stdin: None, + _reader_threads: Vec::new(), + _wait_thread: None, + } + } + + fn spawn_process(&mut self, spec: &ProcessSpec) { + let mut cmd = Command::new(&spec.command); + cmd.args(&spec.args); + for (k, v) in &spec.env { + cmd.env(k, v); + } + if let Some(ref dir) = spec.working_dir { + cmd.current_dir(dir); + } + cmd.stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + match cmd.spawn() { + Ok(mut child) => { + let pid = child.id(); + + // Take the stdin handle + self.stdin = child.stdin.take(); + + // Spawn stdout reader thread + if let Some(stdout) = child.stdout.take() { + let queue = self.queue.clone(); + let waker = self.waker_slot.clone(); + self._reader_threads.push( + thread::Builder::new() + .name(format!("proc-{}-stdout", pid)) + .spawn(move || pipes::read_pipe(stdout, false, queue, waker)) + .expect("failed to spawn stdout reader"), + ); + } + + // Spawn stderr reader thread + if let Some(stderr) = child.stderr.take() { + let queue = self.queue.clone(); + let waker = self.waker_slot.clone(); + self._reader_threads.push( + thread::Builder::new() + .name(format!("proc-{}-stderr", pid)) + .spawn(move || pipes::read_pipe(stderr, true, queue, waker)) + .expect("failed to spawn stderr reader"), + ); + } + + // Spawn wait thread + let queue = self.queue.clone(); + let waker = self.waker_slot.clone(); + self._wait_thread = Some( + thread::Builder::new() + .name(format!("proc-{}-wait", pid)) + .spawn(move || wait::wait_for_exit(pid, queue, waker)) + .expect("failed to spawn wait thread"), + ); + + self.child = Some(child); + self.queue.push(ProcessEvent::Started); + } + Err(e) => { + self.queue.push(ProcessEvent::SpawnFailed { + reason: e.to_string(), + }); + } + } + } +} + +impl ProcessDriver for LocalDriver { + fn execute(&mut self, action: ProcessAction) { + match action { + ProcessAction::SpawnProcess { spec } => { + self.spawn_process(&spec); + } + ProcessAction::WriteStdin { data } => { + if let Some(ref mut stdin) = self.stdin { + match stdin.write_all(&data) { + Ok(()) => { + self.queue.push(ProcessEvent::StdinWritten { + byte_count: data.len(), + }); + } + Err(e) => { + self.queue.push(ProcessEvent::ConnectionLost { + reason: format!("stdin write failed: {}", e), + }); + } + } + } + } + ProcessAction::SendSignal { signal } => { + if let Some(ref child) = self.child { + let pid = child.id(); + match signal::send_signal(pid, signal) { + Ok(()) => { + self.queue.push(ProcessEvent::SignalSent); + } + Err(reason) => { + self.queue.push(ProcessEvent::ConnectionLost { reason }); + } + } + } + } + ProcessAction::ResizePty { .. } => { + // No-op for Phase 1 (pipes only, no PTY support) + self.queue.push(ProcessEvent::PtyResized); + } + ProcessAction::CloseStdin => { + // Drop the stdin handle to close the pipe + self.stdin.take(); + } + ProcessAction::ScheduleKillTimeout { duration } => { + let queue = self.queue.clone(); + let waker = self.waker_slot.clone(); + thread::spawn(move || { + thread::sleep(duration); + queue.push(ProcessEvent::KillTimeout); + if let Some(w) = waker.get() { + w.wake(); + } + }); + } + // Notification actions are not driver commands + _ => {} + } + } + + fn poll(&mut self) -> Vec { + self.queue.drain() + } +} + +impl Drop for LocalDriver { + fn drop(&mut self) { + // Close stdin to let the process know we're done + self.stdin.take(); + // Kill the process if still alive + if let Some(ref mut child) = self.child { + let _ = child.kill(); + let _ = child.wait(); + } + } +} diff --git a/crates/process/src/local/pipes.rs b/crates/process/src/local/pipes.rs new file mode 100644 index 0000000..a5e6b03 --- /dev/null +++ b/crates/process/src/local/pipes.rs @@ -0,0 +1,34 @@ +use std::io::Read; +use std::sync::{Arc, OnceLock}; + +use crate::event::ProcessEvent; +use crate::queue::EventQueue; +use crate::waker::ProcessWaker; + +/// Read from a pipe in a loop, pushing events to the queue and waking the actor. +/// +/// Runs in a background thread. Exits when the pipe reaches EOF or errors. +pub(crate) fn read_pipe( + mut pipe: impl Read + Send + 'static, + is_stderr: bool, + queue: EventQueue, + waker: Arc>, +) { + let mut buf = [0u8; 8192]; + loop { + match pipe.read(&mut buf) { + Ok(0) => break, // EOF + Ok(n) => { + queue.push(ProcessEvent::OutputReceived { + data: buf[..n].to_vec(), + is_stderr, + }); + if let Some(w) = waker.get() { + w.wake(); + } + } + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + Err(_) => break, + } + } +} diff --git a/crates/process/src/local/signal.rs b/crates/process/src/local/signal.rs new file mode 100644 index 0000000..c942735 --- /dev/null +++ b/crates/process/src/local/signal.rs @@ -0,0 +1,30 @@ +use crate::types::Signal; + +/// Map a `Signal` enum variant to the corresponding libc signal constant. +pub(crate) fn signal_to_libc(signal: Signal) -> libc::c_int { + match signal { + Signal::Terminate => libc::SIGTERM, + Signal::Kill => libc::SIGKILL, + Signal::Hangup => libc::SIGHUP, + Signal::Interrupt => libc::SIGINT, + Signal::Other(n) => n, + } +} + +/// Send a signal to a process by PID. Returns `Ok(())` on success. +pub(crate) fn send_signal(pid: u32, signal: Signal) -> Result<(), String> { + let sig = signal_to_libc(signal); + // Safety: kill() is safe to call with any pid/signal combo; + // it returns -1 on error which we check. + let ret = unsafe { libc::kill(pid as libc::pid_t, sig) }; + if ret == 0 { + Ok(()) + } else { + Err(format!( + "kill({}, {}) failed: {}", + pid, + sig, + std::io::Error::last_os_error() + )) + } +} diff --git a/crates/process/src/local/wait.rs b/crates/process/src/local/wait.rs new file mode 100644 index 0000000..01fadea --- /dev/null +++ b/crates/process/src/local/wait.rs @@ -0,0 +1,41 @@ +use std::sync::{Arc, OnceLock}; + +use crate::event::ProcessEvent; +use crate::queue::EventQueue; +use crate::types::ExitStatus; +use crate::waker::ProcessWaker; + +/// Wait for a child process to exit, then push the appropriate event. +/// +/// Runs in a background thread. Uses `libc::waitpid` for accurate exit status. +pub(crate) fn wait_for_exit( + pid: u32, + queue: EventQueue, + waker: Arc>, +) { + let mut status: libc::c_int = 0; + let ret = unsafe { libc::waitpid(pid as libc::pid_t, &mut status, 0) }; + + let exit_status = if ret < 0 { + ExitStatus::Unknown + } else { + decode_wait_status(status) + }; + + queue.push(ProcessEvent::Exited { + status: exit_status, + }); + if let Some(w) = waker.get() { + w.wake(); + } +} + +fn decode_wait_status(status: libc::c_int) -> ExitStatus { + if libc::WIFEXITED(status) { + ExitStatus::Code(libc::WEXITSTATUS(status)) + } else if libc::WIFSIGNALED(status) { + ExitStatus::Signal(libc::WTERMSIG(status)) + } else { + ExitStatus::Unknown + } +} diff --git a/crates/process/src/message.rs b/crates/process/src/message.rs new file mode 100644 index 0000000..a5b42ad --- /dev/null +++ b/crates/process/src/message.rs @@ -0,0 +1,49 @@ +use swactor::actor::ActorAddress; + +use crate::action::OutputStream; +use crate::types::{ExitStatus, ProcessError, PtySize, Signal}; + +/// Commands sent to a process actor. +#[derive(Debug, Clone)] +pub enum ProcessCommand { + /// Write data to the process's stdin. + WriteStdin { data: Vec }, + /// Send a signal to the process. + SendSignal { signal: Signal }, + /// Resize the process's PTY. + ResizePty { size: PtySize }, + /// Close the process's stdin pipe. + CloseStdin, + /// Request a graceful close of the process. + Close, + /// Subscribe to process notifications. + Subscribe { address: ActorAddress }, + /// Unsubscribe from process notifications. + Unsubscribe { address: ActorAddress }, + /// Internal: sent by the waker to trigger event draining. + #[doc(hidden)] + PollTick, +} + +/// Notifications sent from a process actor to subscribers. +#[derive(Debug, Clone)] +pub enum ProcessNotification { + /// The process started successfully. + Started { process: ActorAddress }, + /// Output was received from the process. + Output { + process: ActorAddress, + data: Vec, + stream: OutputStream, + }, + /// The process exited. + Exited { + process: ActorAddress, + status: ExitStatus, + }, + /// An error occurred. + Error { + process: ActorAddress, + error: ProcessError, + }, +} diff --git a/crates/process/src/mock.rs b/crates/process/src/mock.rs new file mode 100644 index 0000000..2a85a5c --- /dev/null +++ b/crates/process/src/mock.rs @@ -0,0 +1,61 @@ +use std::collections::VecDeque; + +use crate::action::ProcessAction; +use crate::driver::ProcessDriver; +use crate::event::ProcessEvent; + +/// A test-oriented driver that records executed actions and lets you inject events. +pub struct MockDriver { + pending_events: VecDeque, + executed_actions: Vec, +} + +impl MockDriver { + pub fn new() -> Self { + Self { + pending_events: VecDeque::new(), + executed_actions: Vec::new(), + } + } + + /// Queue a single event to be returned by the next `poll()`. + pub fn inject(&mut self, event: ProcessEvent) { + self.pending_events.push_back(event); + } + + /// Queue multiple events to be returned by subsequent `poll()` calls. + pub fn inject_many(&mut self, events: impl IntoIterator) { + self.pending_events.extend(events); + } + + /// View all actions that have been executed so far. + pub fn executed_actions(&self) -> &[ProcessAction] { + &self.executed_actions + } + + /// Take all executed actions, clearing the internal log. + pub fn take_executed_actions(&mut self) -> Vec { + std::mem::take(&mut self.executed_actions) + } + + /// Number of events waiting to be polled. + pub fn pending_event_count(&self) -> usize { + self.pending_events.len() + } +} + +impl Default for MockDriver { + fn default() -> Self { + Self::new() + } +} + +impl ProcessDriver for MockDriver { + fn execute(&mut self, action: ProcessAction) { + self.executed_actions.push(action); + } + + fn poll(&mut self) -> Vec { + self.pending_events.drain(..).collect() + } +} diff --git a/crates/process/src/queue.rs b/crates/process/src/queue.rs new file mode 100644 index 0000000..5a492df --- /dev/null +++ b/crates/process/src/queue.rs @@ -0,0 +1,42 @@ +use std::sync::Arc; + +use crossbeam_queue::SegQueue; + +use crate::event::ProcessEvent; + +/// Thread-safe queue for buffering process events from I/O threads. +/// +/// Cloneable via inner `Arc` — I/O threads push events, the driver's +/// `poll()` drains them. +#[derive(Clone)] +pub struct EventQueue { + inner: Arc>, +} + +impl EventQueue { + pub fn new() -> Self { + Self { + inner: Arc::new(SegQueue::new()), + } + } + + /// Push an event (called from I/O threads). + pub fn push(&self, event: ProcessEvent) { + self.inner.push(event); + } + + /// Drain all pending events (called from driver's `poll()`). + pub fn drain(&self) -> Vec { + let mut events = Vec::new(); + while let Some(event) = self.inner.pop() { + events.push(event); + } + events + } +} + +impl Default for EventQueue { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/process/src/session.rs b/crates/process/src/session.rs new file mode 100644 index 0000000..64100b1 --- /dev/null +++ b/crates/process/src/session.rs @@ -0,0 +1,363 @@ +use std::collections::VecDeque; + +use crate::action::{OutputStream, ProcessAction}; +use crate::event::ProcessEvent; +use crate::subscriber::SubscriberSet; +use crate::types::{ExitStatus, FlowControl, ProcessError, ProcessMode, ProcessSpec, Signal}; + +/// The lifecycle states of a process session. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProcessState { + Starting, + Running, + Stopping, + Exited, +} + +impl ProcessState { + pub fn name(&self) -> &'static str { + match self { + Self::Starting => "Starting", + Self::Running => "Running", + Self::Stopping => "Stopping", + Self::Exited => "Exited", + } + } +} + +/// Pure-logic state machine for managing a process lifecycle. +/// +/// Created via `new()` which returns the session plus initial actions (SpawnProcess). +/// Drive it forward by calling `apply(event)` which returns actions to execute. +pub struct ProcessSession { + spec: ProcessSpec, + state: ProcessState, + subscribers: SubscriberSet, + flow: FlowControl, + exit_status: Option, + stdin_closed: bool, + close_requested_before_start: bool, + stdin_buffer: VecDeque>, + stdin_buffer_bytes: usize, +} + +impl ProcessSession { + /// Create a new session. Returns the session and the initial actions to execute + /// (always a single `SpawnProcess` action). + pub fn new(spec: ProcessSpec) -> (Self, Vec) { + let actions = vec![ProcessAction::SpawnProcess { spec: spec.clone() }]; + let session = Self { + spec, + state: ProcessState::Starting, + subscribers: SubscriberSet::new(), + flow: FlowControl::default(), + exit_status: None, + stdin_closed: false, + close_requested_before_start: false, + stdin_buffer: VecDeque::new(), + stdin_buffer_bytes: 0, + }; + (session, actions) + } + + /// Apply an event and return the resulting actions. + pub fn apply(&mut self, event: ProcessEvent) -> Vec { + // Subscribe/Unsubscribe handled in all states + match &event { + ProcessEvent::Subscribe { address } => { + self.subscribers.add(*address); + return vec![]; + } + ProcessEvent::Unsubscribe { address } => { + self.subscribers.remove(address); + return vec![]; + } + _ => {} + } + + // Driver acks — silently consumed in all states + match &event { + ProcessEvent::StdinWritten { byte_count } => { + self.flow.pending_stdin_bytes = + self.flow.pending_stdin_bytes.saturating_sub(*byte_count); + return self.drain_stdin_buffer(); + } + ProcessEvent::SignalSent | ProcessEvent::PtyResized => { + return vec![]; + } + _ => {} + } + + // KillTimeout — handled in all states before per-state dispatch + if matches!(event, ProcessEvent::KillTimeout) { + return if self.state == ProcessState::Stopping { + vec![ProcessAction::SendSignal { signal: Signal::Kill }] + } else { + vec![] + }; + } + + // Dispatch to per-state handler + match self.state { + ProcessState::Starting => self.handle_starting(event), + ProcessState::Running => self.handle_running(event), + ProcessState::Stopping => self.handle_stopping(event), + ProcessState::Exited => self.handle_exited(event), + } + } + + // --- Per-state handlers --- + + fn handle_starting(&mut self, event: ProcessEvent) -> Vec { + match event { + ProcessEvent::Started => { + self.state = ProcessState::Running; + let mut actions = vec![ProcessAction::NotifyStarted { + subscribers: self.subscribers.snapshot(), + }]; + // If close was requested before the process started, transition to Stopping + if self.close_requested_before_start { + self.state = ProcessState::Stopping; + actions.push(ProcessAction::SendSignal { + signal: Signal::Terminate, + }); + if let Some(duration) = self.spec.kill_timeout { + actions.push(ProcessAction::ScheduleKillTimeout { duration }); + } + } + actions + } + ProcessEvent::SpawnFailed { reason } => { + self.state = ProcessState::Exited; + vec![ + ProcessAction::NotifyError { + subscribers: self.subscribers.snapshot(), + error: ProcessError::SpawnFailed { reason }, + }, + ProcessAction::SelfTerminate, + ] + } + ProcessEvent::CloseRequested => { + self.close_requested_before_start = true; + vec![] + } + _ => self.invalid_state_error(&event), + } + } + + fn handle_running(&mut self, event: ProcessEvent) -> Vec { + match event { + ProcessEvent::OutputReceived { data, is_stderr } => { + let stream = if is_stderr { + OutputStream::Stderr + } else { + OutputStream::Stdout + }; + vec![ProcessAction::NotifyOutput { + subscribers: self.subscribers.snapshot(), + data, + stream, + }] + } + ProcessEvent::Exited { status } => { + self.enter_exited(status) + } + ProcessEvent::ConnectionLost { reason } => { + self.state = ProcessState::Exited; + self.exit_status = Some(ExitStatus::Unknown); + self.clear_stdin_buffer(); + vec![ + ProcessAction::NotifyError { + subscribers: self.subscribers.snapshot(), + error: ProcessError::ConnectionLost { reason }, + }, + ProcessAction::SelfTerminate, + ] + } + ProcessEvent::WriteStdin { data } => { + if self.stdin_closed { + return self.notify_error(ProcessError::InvalidState { + attempted: "WriteStdin", + current_state: "Running (stdin closed)", + }); + } + // Backpressure: buffer if over limit + if let Some(limit) = self.spec.stdin_buffer_limit { + if self.flow.pending_stdin_bytes >= limit { + self.stdin_buffer_bytes += data.len(); + self.stdin_buffer.push_back(data); + return vec![]; + } + } + self.flow.pending_stdin_bytes += data.len(); + vec![ProcessAction::WriteStdin { data }] + } + ProcessEvent::SendSignal { signal } => { + vec![ProcessAction::SendSignal { signal }] + } + ProcessEvent::ResizePty { size } => { + vec![ProcessAction::ResizePty { size }] + } + ProcessEvent::CloseStdin => { + if self.stdin_closed { + return vec![]; + } + self.stdin_closed = true; + self.clear_stdin_buffer(); + vec![ProcessAction::CloseStdin] + } + ProcessEvent::CloseRequested => { + self.state = ProcessState::Stopping; + self.clear_stdin_buffer(); + let mut actions = vec![ProcessAction::SendSignal { + signal: Signal::Terminate, + }]; + if let Some(duration) = self.spec.kill_timeout { + actions.push(ProcessAction::ScheduleKillTimeout { duration }); + } + actions + } + _ => self.invalid_state_error(&event), + } + } + + fn handle_stopping(&mut self, event: ProcessEvent) -> Vec { + match event { + ProcessEvent::OutputReceived { data, is_stderr } => { + let stream = if is_stderr { + OutputStream::Stderr + } else { + OutputStream::Stdout + }; + vec![ProcessAction::NotifyOutput { + subscribers: self.subscribers.snapshot(), + data, + stream, + }] + } + ProcessEvent::Exited { status } => { + self.enter_exited(status) + } + ProcessEvent::ConnectionLost { reason } => { + self.state = ProcessState::Exited; + self.exit_status = Some(ExitStatus::Unknown); + vec![ + ProcessAction::NotifyError { + subscribers: self.subscribers.snapshot(), + error: ProcessError::ConnectionLost { reason }, + }, + ProcessAction::SelfTerminate, + ] + } + ProcessEvent::SendSignal { signal } => { + // Escalation (e.g., Kill after Terminate) is allowed in Stopping + vec![ProcessAction::SendSignal { signal }] + } + ProcessEvent::CloseStdin => { + if self.stdin_closed { + return vec![]; + } + self.stdin_closed = true; + vec![ProcessAction::CloseStdin] + } + ProcessEvent::CloseRequested => { + // Already stopping, no-op + vec![] + } + _ => self.invalid_state_error(&event), + } + } + + fn handle_exited(&mut self, event: ProcessEvent) -> Vec { + // Everything in Exited is invalid — produce an error. + // (Acks and Subscribe/Unsubscribe are already handled before dispatch.) + self.invalid_state_error(&event) + } + + // --- Helpers --- + + fn enter_exited(&mut self, status: ExitStatus) -> Vec { + self.state = ProcessState::Exited; + self.exit_status = Some(status); + self.clear_stdin_buffer(); + vec![ + ProcessAction::NotifyExited { + subscribers: self.subscribers.snapshot(), + status, + }, + ProcessAction::SelfTerminate, + ] + } + + fn clear_stdin_buffer(&mut self) { + self.stdin_buffer.clear(); + self.stdin_buffer_bytes = 0; + } + + fn drain_stdin_buffer(&mut self) -> Vec { + let limit = match self.spec.stdin_buffer_limit { + Some(limit) => limit, + None => return vec![], + }; + let mut actions = Vec::new(); + while self.flow.pending_stdin_bytes < limit { + match self.stdin_buffer.pop_front() { + Some(data) => { + self.stdin_buffer_bytes -= data.len(); + self.flow.pending_stdin_bytes += data.len(); + actions.push(ProcessAction::WriteStdin { data }); + } + None => break, + } + } + actions + } + + fn invalid_state_error(&self, event: &ProcessEvent) -> Vec { + self.notify_error(ProcessError::InvalidState { + attempted: event.name(), + current_state: self.state.name(), + }) + } + + fn notify_error(&self, error: ProcessError) -> Vec { + vec![ProcessAction::NotifyError { + subscribers: self.subscribers.snapshot(), + error, + }] + } + + // --- Query methods --- + + pub fn state(&self) -> ProcessState { + self.state + } + + pub fn spec(&self) -> &ProcessSpec { + &self.spec + } + + pub fn mode(&self) -> ProcessMode { + self.spec.mode + } + + pub fn exit_status(&self) -> Option { + self.exit_status + } + + pub fn flow_control(&self) -> &FlowControl { + &self.flow + } + + pub fn subscriber_count(&self) -> usize { + self.subscribers.count() + } + + pub fn stdin_closed(&self) -> bool { + self.stdin_closed + } + + pub fn stdin_buffer_bytes(&self) -> usize { + self.stdin_buffer_bytes + } +} diff --git a/crates/process/src/spawn.rs b/crates/process/src/spawn.rs new file mode 100644 index 0000000..9aa0887 --- /dev/null +++ b/crates/process/src/spawn.rs @@ -0,0 +1,89 @@ +use std::sync::{Arc, OnceLock}; + +use swactor::actor::{ActorAddress, Ctx}; +use swactor::runtime::ExternalSender; +use swactor::Error; + +use crate::actor::ProcessActor; +use crate::driver::ProcessDriver; +use crate::local::LocalDriver; +use crate::message::ProcessCommand; +use crate::queue::EventQueue; +use crate::session::ProcessSession; +use crate::types::ProcessSpec; +use crate::waker::ProcessWaker; + +/// Spawn a process actor using the real `LocalDriver` (OS subprocess). +/// +/// Creates a `ProcessActor`, spawns it in the runtime, and +/// wires up the waker so that I/O thread events automatically wake the actor. +/// +/// Returns the actor's address. Send `ProcessCommand` messages to control it. +pub fn spawn_local_process( + ctx: &Ctx, + sender: &ExternalSender, + spec: ProcessSpec, +) -> Result { + let waker_slot = Arc::new(OnceLock::new()); + let queue = EventQueue::new(); + let driver = LocalDriver::new(queue, waker_slot.clone()); + spawn_process_inner(ctx, sender, spec, driver, waker_slot) +} + +/// Spawn a process actor with a custom driver. +/// +/// Useful for testing with `MockDriver` or other custom drivers while +/// still getting the full actor integration (waker, lifecycle, etc.). +pub fn spawn_process( + ctx: &Ctx, + sender: &ExternalSender, + spec: ProcessSpec, + driver: D, + waker_slot: Arc>, +) -> Result { + spawn_process_inner(ctx, sender, spec, driver, waker_slot) +} + +/// Spawn a process actor using the `SshDriver` (remote host via SSH). +/// +/// Requires a tokio runtime handle (e.g. from `IrohDriver::tokio_handle()`) +/// and SSH connection config. +#[cfg(feature = "ssh")] +pub fn spawn_ssh_process( + ctx: &Ctx, + sender: &ExternalSender, + spec: ProcessSpec, + tokio_handle: tokio::runtime::Handle, + ssh_config: crate::ssh::SshConfig, +) -> Result { + let waker_slot = Arc::new(OnceLock::new()); + let queue = EventQueue::new(); + let driver = crate::ssh::SshDriver::new(queue, waker_slot.clone(), tokio_handle, ssh_config); + spawn_process_inner(ctx, sender, spec, driver, waker_slot) +} + +fn spawn_process_inner( + ctx: &Ctx, + sender: &ExternalSender, + spec: ProcessSpec, + driver: D, + waker_slot: Arc>, +) -> Result { + let (session, initial_actions) = ProcessSession::new(spec); + let actor = ProcessActor::new(session, driver, initial_actions, waker_slot.clone()); + let addr = ctx.spawn(actor)?; + + // Now that we have the address, fill the waker + let sender = sender.clone(); + let waker = ProcessWaker::new(move || { + let _ = sender.send_to(addr, ProcessCommand::PollTick); + }); + waker_slot + .set(waker.clone()) + .expect("waker slot already set"); + + // Flush any events from the startup race window + waker.wake(); + + Ok(addr) +} diff --git a/crates/process/src/ssh/config.rs b/crates/process/src/ssh/config.rs new file mode 100644 index 0000000..7853769 --- /dev/null +++ b/crates/process/src/ssh/config.rs @@ -0,0 +1,32 @@ +use std::path::PathBuf; + +/// Configuration for connecting to a remote host over SSH. +pub struct SshConfig { + pub host: String, + pub port: u16, + pub username: String, + pub key_file: PathBuf, + pub key_passphrase: Option, +} + +impl SshConfig { + pub fn new(host: impl Into, username: impl Into, key_file: PathBuf) -> Self { + Self { + host: host.into(), + port: 22, + username: username.into(), + key_file, + key_passphrase: None, + } + } + + pub fn with_port(mut self, port: u16) -> Self { + self.port = port; + self + } + + pub fn with_passphrase(mut self, passphrase: impl Into) -> Self { + self.key_passphrase = Some(passphrase.into()); + self + } +} diff --git a/crates/process/src/ssh/handler.rs b/crates/process/src/ssh/handler.rs new file mode 100644 index 0000000..2f1901e --- /dev/null +++ b/crates/process/src/ssh/handler.rs @@ -0,0 +1,17 @@ +use russh::client; +use russh_keys::key::PublicKey; + +/// Minimal SSH client handler that accepts all host keys. +pub(super) struct SshHandler; + +#[async_trait::async_trait] +impl client::Handler for SshHandler { + type Error = russh::Error; + + async fn check_server_key( + &mut self, + _server_public_key: &PublicKey, + ) -> Result { + Ok(true) + } +} diff --git a/crates/process/src/ssh/mod.rs b/crates/process/src/ssh/mod.rs new file mode 100644 index 0000000..2f7c697 --- /dev/null +++ b/crates/process/src/ssh/mod.rs @@ -0,0 +1,121 @@ +pub mod config; +mod handler; +mod task; + +use std::sync::{Arc, OnceLock}; + +use tokio::sync::mpsc; + +use crate::action::ProcessAction; +use crate::driver::ProcessDriver; +use crate::event::ProcessEvent; +use crate::queue::EventQueue; +use crate::waker::ProcessWaker; + +pub use config::SshConfig; +use task::SshCommand; + +/// A `ProcessDriver` that runs processes on remote hosts over SSH. +/// +/// Commands are sent via a tokio mpsc channel to a background async task +/// that manages the SSH connection. Events flow back through the shared +/// `EventQueue` + `ProcessWaker` (same pattern as `LocalDriver`). +pub struct SshDriver { + queue: EventQueue, + waker_slot: Arc>, + command_tx: Option>, + command_rx: Option>, + tokio_handle: tokio::runtime::Handle, + ssh_config: SshConfig, + task_handle: Option>, +} + +impl SshDriver { + pub fn new( + queue: EventQueue, + waker_slot: Arc>, + tokio_handle: tokio::runtime::Handle, + ssh_config: SshConfig, + ) -> Self { + let (tx, rx) = mpsc::unbounded_channel(); + Self { + queue, + waker_slot, + command_tx: Some(tx), + command_rx: Some(rx), + tokio_handle, + ssh_config, + task_handle: None, + } + } +} + +impl ProcessDriver for SshDriver { + fn execute(&mut self, action: ProcessAction) { + match action { + ProcessAction::SpawnProcess { spec } => { + let Some(rx) = self.command_rx.take() else { + return; + }; + let queue = self.queue.clone(); + let waker_slot = self.waker_slot.clone(); + // Move the ssh_config out — we only need it once for connection + let config = SshConfig { + host: self.ssh_config.host.clone(), + port: self.ssh_config.port, + username: self.ssh_config.username.clone(), + key_file: self.ssh_config.key_file.clone(), + key_passphrase: self.ssh_config.key_passphrase.clone(), + }; + self.task_handle = Some(self.tokio_handle.spawn( + task::run_ssh_session(config, spec, queue, waker_slot, rx), + )); + } + ProcessAction::WriteStdin { data } => { + if let Some(ref tx) = self.command_tx { + let _ = tx.send(SshCommand::WriteStdin(data)); + } + } + ProcessAction::SendSignal { signal } => { + if let Some(ref tx) = self.command_tx { + let _ = tx.send(SshCommand::SendSignal(signal)); + } + } + ProcessAction::ResizePty { size } => { + if let Some(ref tx) = self.command_tx { + let _ = tx.send(SshCommand::ResizePty { + cols: size.cols, + rows: size.rows, + }); + } + } + ProcessAction::CloseStdin => { + if let Some(ref tx) = self.command_tx { + let _ = tx.send(SshCommand::CloseStdin); + } + } + ProcessAction::ScheduleKillTimeout { duration } => { + if let Some(ref tx) = self.command_tx { + let _ = tx.send(SshCommand::ScheduleKillTimeout(duration)); + } + } + // Notification actions are not driver commands + _ => {} + } + } + + fn poll(&mut self) -> Vec { + self.queue.drain() + } +} + +impl Drop for SshDriver { + fn drop(&mut self) { + // Drop sender to signal the task to shut down + self.command_tx.take(); + // Abort the background task if still running + if let Some(handle) = self.task_handle.take() { + handle.abort(); + } + } +} diff --git a/crates/process/src/ssh/task.rs b/crates/process/src/ssh/task.rs new file mode 100644 index 0000000..0dcbf51 --- /dev/null +++ b/crates/process/src/ssh/task.rs @@ -0,0 +1,317 @@ +use std::sync::{Arc, OnceLock}; +use std::time::Duration; + +use russh::{ChannelMsg, Sig}; +use tokio::sync::mpsc; +use tokio::time::{Instant, sleep_until}; + +use crate::event::ProcessEvent; +use crate::queue::EventQueue; +use crate::types::{ExitStatus, ProcessMode, ProcessSpec, Signal}; +use crate::waker::ProcessWaker; + +use super::config::SshConfig; +use super::handler::SshHandler; + +/// Commands sent from the SshDriver to the background task. +pub(super) enum SshCommand { + WriteStdin(Vec), + SendSignal(Signal), + ResizePty { cols: u16, rows: u16 }, + CloseStdin, + ScheduleKillTimeout(Duration), +} + +/// Run the full SSH session lifecycle. +/// +/// Three phases: connect+auth, channel setup, event loop. +/// All events are pushed to `queue` and the waker is fired. +pub(super) async fn run_ssh_session( + config: SshConfig, + spec: ProcessSpec, + queue: EventQueue, + waker_slot: Arc>, + mut command_rx: mpsc::UnboundedReceiver, +) { + let has_pty = spec.mode == ProcessMode::Interactive; + + // --- Phase 1: Connect + Auth --- + let session = match connect_and_auth(&config).await { + Ok(session) => session, + Err(e) => { + push_and_wake(&queue, &waker_slot, ProcessEvent::SpawnFailed { + reason: format!("SSH connection failed: {e}"), + }); + return; + } + }; + + // --- Phase 2: Channel setup --- + let channel = match setup_channel(&session, &spec, has_pty).await { + Ok(ch) => ch, + Err(e) => { + push_and_wake(&queue, &waker_slot, ProcessEvent::SpawnFailed { + reason: format!("SSH channel setup failed: {e}"), + }); + return; + } + }; + + push_and_wake(&queue, &waker_slot, ProcessEvent::Started); + + // --- Phase 3: Event loop --- + run_event_loop(channel, &mut command_rx, &queue, &waker_slot, has_pty).await; +} + +async fn connect_and_auth( + config: &SshConfig, +) -> Result, Box> { + let ssh_config = russh::client::Config { + keepalive_interval: Some(Duration::from_secs(30)), + keepalive_max: 3, + ..Default::default() + }; + + let mut session = russh::client::connect( + Arc::new(ssh_config), + (config.host.as_str(), config.port), + SshHandler, + ) + .await?; + + let key = russh_keys::load_secret_key( + &config.key_file, + config.key_passphrase.as_deref(), + )?; + + let authenticated = session + .authenticate_publickey(&config.username, Arc::new(key)) + .await?; + + if !authenticated { + return Err("authentication rejected by server".into()); + } + + Ok(session) +} + +async fn setup_channel( + session: &russh::client::Handle, + spec: &ProcessSpec, + has_pty: bool, +) -> Result, Box> { + let channel = session.channel_open_session().await?; + + if has_pty { + let (cols, rows) = spec + .initial_pty_size + .map(|s| (s.cols as u32, s.rows as u32)) + .unwrap_or((80, 24)); + channel + .request_pty(true, "xterm-256color", cols, rows, 0, 0, &[]) + .await?; + } + + // Best-effort env vars (many SSH servers restrict SetEnv) + for (key, val) in &spec.env { + let _ = channel.set_env(true, key, val).await; + } + + if has_pty { + channel.request_shell(true).await?; + } else { + let cmd = build_remote_command(spec); + channel.exec(true, cmd).await?; + } + + Ok(channel) +} + +async fn run_event_loop( + mut channel: russh::Channel, + command_rx: &mut mpsc::UnboundedReceiver, + queue: &EventQueue, + waker_slot: &Arc>, + has_pty: bool, +) { + let mut pending_exit: Option = None; + let mut kill_deadline: Option = None; + + loop { + // Build the kill-timeout future + let kill_sleep = async { + match kill_deadline { + Some(deadline) => sleep_until(deadline).await, + None => std::future::pending().await, + } + }; + + tokio::select! { + msg = channel.wait() => { + match msg { + Some(ChannelMsg::Data { data }) => { + push_and_wake(queue, waker_slot, ProcessEvent::OutputReceived { + data: data.to_vec(), + is_stderr: false, + }); + } + Some(ChannelMsg::ExtendedData { data, ext: 1 }) => { + push_and_wake(queue, waker_slot, ProcessEvent::OutputReceived { + data: data.to_vec(), + is_stderr: true, + }); + } + Some(ChannelMsg::ExtendedData { .. }) => { + // Ignore non-stderr extended data + } + Some(ChannelMsg::ExitStatus { exit_status }) => { + pending_exit = Some(ExitStatus::Code(exit_status as i32)); + } + Some(ChannelMsg::ExitSignal { signal_name, .. }) => { + pending_exit = Some(ExitStatus::Signal( + signal_name_to_code(&signal_name), + )); + } + Some(ChannelMsg::Eof) | Some(ChannelMsg::Close) | None => { + let status = pending_exit.take().unwrap_or(ExitStatus::Unknown); + push_and_wake(queue, waker_slot, ProcessEvent::Exited { status }); + break; + } + _ => {} + } + } + + cmd = command_rx.recv() => { + match cmd { + Some(SshCommand::WriteStdin(data)) => { + let len = data.len(); + match channel.data(&data[..]).await { + Ok(()) => { + push_and_wake(queue, waker_slot, ProcessEvent::StdinWritten { + byte_count: len, + }); + } + Err(e) => { + push_and_wake(queue, waker_slot, ProcessEvent::ConnectionLost { + reason: format!("stdin write failed: {e}"), + }); + } + } + } + Some(SshCommand::SendSignal(Signal::Kill)) => { + let _ = channel.close().await; + push_and_wake(queue, waker_slot, ProcessEvent::SignalSent); + } + Some(SshCommand::SendSignal(signal)) => { + let sig = signal_to_russh(signal); + let _ = channel.signal(sig).await; + push_and_wake(queue, waker_slot, ProcessEvent::SignalSent); + } + Some(SshCommand::ResizePty { cols, rows }) => { + if has_pty { + let _ = channel.window_change( + cols as u32, rows as u32, 0, 0, + ).await; + } + push_and_wake(queue, waker_slot, ProcessEvent::PtyResized); + } + Some(SshCommand::CloseStdin) => { + let _ = channel.eof().await; + } + Some(SshCommand::ScheduleKillTimeout(dur)) => { + kill_deadline = Some(Instant::now() + dur); + } + None => { + // Sender dropped — close channel + let _ = channel.close().await; + break; + } + } + } + + _ = kill_sleep => { + push_and_wake(queue, waker_slot, ProcessEvent::KillTimeout); + kill_deadline = None; + } + } + } +} + +/// Push an event and wake the actor. +fn push_and_wake( + queue: &EventQueue, + waker_slot: &Arc>, + event: ProcessEvent, +) { + queue.push(event); + if let Some(w) = waker_slot.get() { + w.wake(); + } +} + +/// Build a remote exec command string from a ProcessSpec. +/// +/// Produces: `cd '' && KEY='VAL' ... ` +fn build_remote_command(spec: &ProcessSpec) -> String { + let mut parts = Vec::new(); + + if let Some(ref dir) = spec.working_dir { + parts.push(format!("cd {}", shell_escape(dir))); + } + + for (key, val) in &spec.env { + parts.push(format!("{}={}", key, shell_escape(val))); + } + + let mut cmd = shell_escape(&spec.command); + for arg in &spec.args { + cmd.push(' '); + cmd.push_str(&shell_escape(arg)); + } + parts.push(cmd); + + if parts.len() > 1 && spec.working_dir.is_some() { + // Join with && so cd failure aborts + let cd_part = parts.remove(0); + format!("{} && {}", cd_part, parts.join(" ")) + } else { + parts.join(" ") + } +} + +/// POSIX single-quote escaping: wrap in single quotes, escape embedded quotes. +fn shell_escape(s: &str) -> String { + if s.is_empty() { + return "''".to_string(); + } + // If the string is simple (alphanumeric + safe chars), no quoting needed + if s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/' | ':' | ',' | '+' | '=')) { + return s.to_string(); + } + // Single-quote the string, replacing ' with '\'' + format!("'{}'", s.replace('\'', "'\\''")) +} + +fn signal_to_russh(signal: Signal) -> Sig { + match signal { + Signal::Terminate => Sig::TERM, + Signal::Kill => Sig::KILL, + Signal::Hangup => Sig::HUP, + Signal::Interrupt => Sig::INT, + Signal::Other(_) => Sig::TERM, // Best effort fallback + } +} + +fn signal_name_to_code(sig: &Sig) -> i32 { + match sig { + Sig::HUP => 1, + Sig::INT => 2, + Sig::QUIT => 3, + Sig::ABRT => 6, + Sig::KILL => 9, + Sig::ALRM => 14, + Sig::TERM => 15, + Sig::USR1 => 10, + _ => 15, // Default to SIGTERM code + } +} diff --git a/crates/process/src/subscriber.rs b/crates/process/src/subscriber.rs new file mode 100644 index 0000000..6fadd82 --- /dev/null +++ b/crates/process/src/subscriber.rs @@ -0,0 +1,41 @@ +use swactor::actor::ActorAddress; + +/// A deduplicated collection of subscriber addresses. +#[derive(Debug, Clone)] +pub struct SubscriberSet { + inner: Vec, +} + +impl SubscriberSet { + pub fn new() -> Self { + Self { inner: Vec::new() } + } + + /// Add an address. No-op if already present. + pub fn add(&mut self, address: ActorAddress) { + if !self.inner.contains(&address) { + self.inner.push(address); + } + } + + /// Remove an address. No-op if not present. + pub fn remove(&mut self, address: &ActorAddress) { + self.inner.retain(|a| a != address); + } + + /// Snapshot of current subscribers. + pub fn snapshot(&self) -> Vec { + self.inner.clone() + } + + /// Number of subscribers. + pub fn count(&self) -> usize { + self.inner.len() + } +} + +impl Default for SubscriberSet { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/process/src/types.rs b/crates/process/src/types.rs new file mode 100644 index 0000000..358d7a0 --- /dev/null +++ b/crates/process/src/types.rs @@ -0,0 +1,71 @@ +use std::collections::HashMap; +use std::time::Duration; + +/// Describes how to spawn a process. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProcessSpec { + pub command: String, + pub args: Vec, + pub env: HashMap, + pub working_dir: Option, + pub mode: ProcessMode, + pub initial_pty_size: Option, + /// If set, escalate to SIGKILL after this duration if the process hasn't exited + /// after SIGTERM. None = no escalation. + pub kill_timeout: Option, + /// If set, buffer stdin writes when pending bytes exceed this limit. + /// None = unlimited (current behavior). + pub stdin_buffer_limit: Option, +} + +/// Whether the process is interactive (PTY) or automated (pipes). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProcessMode { + Interactive, + Automated, +} + +/// Dimensions of a pseudo-terminal. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PtySize { + pub cols: u16, + pub rows: u16, +} + +/// How a process exited. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExitStatus { + Code(i32), + Signal(i32), + Unknown, +} + +/// Signals that can be sent to a process. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Signal { + Terminate, + Kill, + Hangup, + Interrupt, + Other(i32), +} + +/// Errors produced by the process session. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProcessError { + SpawnFailed { reason: String }, + ConnectionLost { reason: String }, + InvalidState { attempted: &'static str, current_state: &'static str }, +} + +/// Passive tracking of stdin backpressure. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FlowControl { + pub pending_stdin_bytes: usize, +} + +impl Default for FlowControl { + fn default() -> Self { + Self { pending_stdin_bytes: 0 } + } +} diff --git a/crates/process/src/waker.rs b/crates/process/src/waker.rs new file mode 100644 index 0000000..5613b2a --- /dev/null +++ b/crates/process/src/waker.rs @@ -0,0 +1,25 @@ +use std::sync::Arc; + +/// A handle that I/O threads use to wake the owning actor. +/// +/// Constructed with a closure that sends a `ProcessCommand::PollTick` +/// to the actor via `ExternalSender`. Thread-safe and cloneable. +#[derive(Clone)] +pub struct ProcessWaker(Arc); + +impl std::fmt::Debug for ProcessWaker { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ProcessWaker").finish_non_exhaustive() + } +} + +impl ProcessWaker { + pub fn new(f: impl Fn() + Send + Sync + 'static) -> Self { + Self(Arc::new(f)) + } + + /// Wake the owning actor so it drains pending events. + pub fn wake(&self) { + (self.0)(); + } +} diff --git a/crates/process/tests/actor_scenarios.rs b/crates/process/tests/actor_scenarios.rs new file mode 100644 index 0000000..cf6ba82 --- /dev/null +++ b/crates/process/tests/actor_scenarios.rs @@ -0,0 +1,514 @@ +//! Layer 3 — Actor integration tests. +//! +//! Uses a TestDriver backed by a shared EventQueue so tests can inject +//! events and observe actions without real OS processes. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; + +use swactor::actor::{ActorAddress, ActorInterface, Ctx}; +use swactor::runtime::{Inbox, Runtime, RuntimeConfig}; + +use swactor_process::*; + +// ── TestDriver ────────────────────────────────────────────────────────────── + +/// Shared harness for injecting events and inspecting driver actions. +#[derive(Clone)] +struct TestHarness { + queue: EventQueue, + actions: Arc>>, +} + +impl TestHarness { + fn new() -> Self { + Self { + queue: EventQueue::new(), + actions: Arc::new(Mutex::new(Vec::new())), + } + } + + fn inject(&self, event: ProcessEvent) { + self.queue.push(event); + } + + fn take_actions(&self) -> Vec { + std::mem::take(&mut self.actions.lock().unwrap()) + } +} + +/// A ProcessDriver that records actions and drains from a shared queue. +struct TestDriver { + queue: EventQueue, + actions: Arc>>, +} + +impl TestDriver { + fn from_harness(harness: &TestHarness) -> Self { + Self { + queue: harness.queue.clone(), + actions: harness.actions.clone(), + } + } +} + +impl ProcessDriver for TestDriver { + fn execute(&mut self, action: ProcessAction) { + self.actions.lock().unwrap().push(action); + } + + fn poll(&mut self) -> Vec { + self.queue.drain() + } +} + +// ── Helpers ───────────────────────────────────────────────────────────────── + +fn automated_spec() -> ProcessSpec { + ProcessSpec { + command: "echo".into(), + args: vec!["hello".into()], + env: HashMap::new(), + working_dir: None, + mode: ProcessMode::Automated, + initial_pty_size: None, + kill_timeout: None, + stdin_buffer_limit: None, + } +} + +fn setup() -> (Runtime, ExternalSender) { + let rt = Runtime::new(RuntimeConfig::default()); + let sender = rt.create_sender(); + (rt, sender) +} + +/// Helper: tick until we receive N messages, returning them. +fn tick_collect( + rt: &Runtime, + inbox: &Inbox, + n: usize, + max_ticks: usize, +) -> Vec { + let mut msgs = Vec::new(); + for _ in 0..max_ticks { + rt.tick(); + while let Some(m) = inbox.try_recv() { + msgs.push(m); + if msgs.len() >= n { + return msgs; + } + } + } + msgs +} + +use swactor::runtime::ExternalSender; + +// ── Spawner actor ─────────────────────────────────────────────────────────── +// We can't call ctx.spawn from outside a handle(), so we use a small "spawner" +// actor that spawns the process actor and reports its address. + +#[derive(Clone)] +struct SpawnRequest { + spec: ProcessSpec, + harness: TestHarness, + reply_to: ActorAddress, + sender: ExternalSender, +} + +#[derive(Clone, Debug)] +struct SpawnedAddr(ActorAddress); + +struct SpawnerActor; + +impl ActorInterface for SpawnerActor { + type Incoming = SpawnRequest; + type Response = SpawnedAddr; + + fn handle(&mut self, ctx: &Ctx, msg: SpawnRequest) { + let waker_slot = Arc::new(OnceLock::new()); + let driver = TestDriver::from_harness(&msg.harness); + let addr = spawn_process(ctx, &msg.sender, msg.spec, driver, waker_slot) + .expect("spawn_process failed"); + let _ = ctx.send(msg.reply_to, SpawnedAddr(addr)); + } +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +#[test] +fn happy_path_spawn_output_exit_notifies_subscriber() { + let (rt, sender) = setup(); + let harness = TestHarness::new(); + let notif_inbox = rt.new_inbox::().unwrap(); + + // Spawn the spawner actor + let spawner_addr = rt.spawn(SpawnerActor).unwrap(); + let reply_inbox = rt.new_inbox::().unwrap(); + rt.tick(); + + // Ask spawner to create a process actor + rt.send_to( + spawner_addr, + SpawnRequest { + spec: automated_spec(), + harness: harness.clone(), + reply_to: *reply_inbox.addr(), + sender: sender.clone(), + }, + ) + .unwrap(); + + // Tick to process spawn request + for _ in 0..5 { + rt.tick(); + } + + let spawned = reply_inbox.try_recv().expect("should get spawned addr"); + let proc_addr = spawned.0; + + // Verify SpawnProcess action was sent to driver + let actions = harness.take_actions(); + assert!( + actions.iter().any(|a| matches!(a, ProcessAction::SpawnProcess { .. })), + "driver should receive SpawnProcess, got: {:?}", + actions + ); + + // Subscribe to notifications + rt.send_to(proc_addr, ProcessCommand::Subscribe { address: *notif_inbox.addr() }) + .unwrap(); + rt.tick(); + + // Inject Started event from "driver" + harness.inject(ProcessEvent::Started); + // Send PollTick to trigger drain + rt.send_to(proc_addr, ProcessCommand::PollTick).unwrap(); + for _ in 0..3 { + rt.tick(); + } + + let msgs = tick_collect(&rt, ¬if_inbox, 1, 10); + assert!( + msgs.iter().any(|m| matches!(m, ProcessNotification::Started { .. })), + "subscriber should get Started notification, got: {:?}", + msgs + ); + + // Inject output + harness.inject(ProcessEvent::OutputReceived { + data: b"hello\n".to_vec(), + is_stderr: false, + }); + rt.send_to(proc_addr, ProcessCommand::PollTick).unwrap(); + let msgs = tick_collect(&rt, ¬if_inbox, 1, 10); + assert!( + msgs.iter().any(|m| matches!(m, ProcessNotification::Output { .. })), + "subscriber should get Output notification" + ); + + // Inject exit + harness.inject(ProcessEvent::Exited { + status: ExitStatus::Code(0), + }); + rt.send_to(proc_addr, ProcessCommand::PollTick).unwrap(); + let msgs = tick_collect(&rt, ¬if_inbox, 1, 10); + assert!( + msgs.iter().any(|m| matches!( + m, + ProcessNotification::Exited { status: ExitStatus::Code(0), .. } + )), + "subscriber should get Exited(0) notification" + ); +} + +#[test] +fn polltick_drains_queued_events() { + let (rt, sender) = setup(); + let harness = TestHarness::new(); + let notif_inbox = rt.new_inbox::().unwrap(); + + let spawner_addr = rt.spawn(SpawnerActor).unwrap(); + let reply_inbox = rt.new_inbox::().unwrap(); + rt.tick(); + + rt.send_to( + spawner_addr, + SpawnRequest { + spec: automated_spec(), + harness: harness.clone(), + reply_to: *reply_inbox.addr(), + sender: sender.clone(), + }, + ) + .unwrap(); + for _ in 0..5 { + rt.tick(); + } + + let proc_addr = reply_inbox.try_recv().unwrap().0; + + // Subscribe + rt.send_to(proc_addr, ProcessCommand::Subscribe { address: *notif_inbox.addr() }) + .unwrap(); + rt.tick(); + + // Queue multiple events before sending PollTick + harness.inject(ProcessEvent::Started); + harness.inject(ProcessEvent::OutputReceived { + data: b"line1\n".to_vec(), + is_stderr: false, + }); + harness.inject(ProcessEvent::OutputReceived { + data: b"line2\n".to_vec(), + is_stderr: false, + }); + + // Single PollTick should drain all + rt.send_to(proc_addr, ProcessCommand::PollTick).unwrap(); + let msgs = tick_collect(&rt, ¬if_inbox, 3, 20); + + assert_eq!(msgs.len(), 3, "all three events should produce notifications"); + assert!(matches!(msgs[0], ProcessNotification::Started { .. })); + assert!(matches!(msgs[1], ProcessNotification::Output { .. })); + assert!(matches!(msgs[2], ProcessNotification::Output { .. })); +} + +#[test] +fn close_command_triggers_graceful_shutdown() { + let (rt, sender) = setup(); + let harness = TestHarness::new(); + let notif_inbox = rt.new_inbox::().unwrap(); + + let spawner_addr = rt.spawn(SpawnerActor).unwrap(); + let reply_inbox = rt.new_inbox::().unwrap(); + rt.tick(); + + rt.send_to( + spawner_addr, + SpawnRequest { + spec: automated_spec(), + harness: harness.clone(), + reply_to: *reply_inbox.addr(), + sender: sender.clone(), + }, + ) + .unwrap(); + for _ in 0..5 { + rt.tick(); + } + let proc_addr = reply_inbox.try_recv().unwrap().0; + + // Subscribe and get to Running state + rt.send_to(proc_addr, ProcessCommand::Subscribe { address: *notif_inbox.addr() }) + .unwrap(); + rt.tick(); + harness.inject(ProcessEvent::Started); + rt.send_to(proc_addr, ProcessCommand::PollTick).unwrap(); + let _ = tick_collect::(&rt, ¬if_inbox, 1, 10); + + // Send Close + harness.take_actions(); // clear previous actions + rt.send_to(proc_addr, ProcessCommand::Close).unwrap(); + for _ in 0..5 { + rt.tick(); + } + + let actions = harness.take_actions(); + assert!( + actions.iter().any(|a| matches!( + a, + ProcessAction::SendSignal { signal: Signal::Terminate } + )), + "Close should trigger SIGTERM, got: {:?}", + actions + ); +} + +#[test] +fn write_stdin_and_signal_forwarded_to_driver() { + let (rt, sender) = setup(); + let harness = TestHarness::new(); + + let spawner_addr = rt.spawn(SpawnerActor).unwrap(); + let reply_inbox = rt.new_inbox::().unwrap(); + rt.tick(); + + rt.send_to( + spawner_addr, + SpawnRequest { + spec: automated_spec(), + harness: harness.clone(), + reply_to: *reply_inbox.addr(), + sender: sender.clone(), + }, + ) + .unwrap(); + for _ in 0..5 { + rt.tick(); + } + let proc_addr = reply_inbox.try_recv().unwrap().0; + + // Get to Running + harness.inject(ProcessEvent::Started); + rt.send_to(proc_addr, ProcessCommand::PollTick).unwrap(); + for _ in 0..5 { + rt.tick(); + } + + harness.take_actions(); // clear SpawnProcess action + + // Write stdin + rt.send_to( + proc_addr, + ProcessCommand::WriteStdin { + data: b"input\n".to_vec(), + }, + ) + .unwrap(); + for _ in 0..3 { + rt.tick(); + } + + let actions = harness.take_actions(); + assert!( + actions.iter().any(|a| matches!(a, ProcessAction::WriteStdin { .. })), + "WriteStdin should be forwarded to driver, got: {:?}", + actions + ); + + // Send signal + rt.send_to( + proc_addr, + ProcessCommand::SendSignal { + signal: Signal::Interrupt, + }, + ) + .unwrap(); + for _ in 0..3 { + rt.tick(); + } + + let actions = harness.take_actions(); + assert!( + actions.iter().any(|a| matches!( + a, + ProcessAction::SendSignal { signal: Signal::Interrupt } + )), + "SendSignal should be forwarded to driver, got: {:?}", + actions + ); +} + +#[test] +fn spawn_failure_notifies_error_and_stops_actor() { + let (rt, sender) = setup(); + let harness = TestHarness::new(); + let notif_inbox = rt.new_inbox::().unwrap(); + + let spawner_addr = rt.spawn(SpawnerActor).unwrap(); + let reply_inbox = rt.new_inbox::().unwrap(); + rt.tick(); + + rt.send_to( + spawner_addr, + SpawnRequest { + spec: automated_spec(), + harness: harness.clone(), + reply_to: *reply_inbox.addr(), + sender: sender.clone(), + }, + ) + .unwrap(); + for _ in 0..5 { + rt.tick(); + } + let proc_addr = reply_inbox.try_recv().unwrap().0; + + // Subscribe + rt.send_to(proc_addr, ProcessCommand::Subscribe { address: *notif_inbox.addr() }) + .unwrap(); + rt.tick(); + + // Inject spawn failure + harness.inject(ProcessEvent::SpawnFailed { + reason: "command not found".into(), + }); + rt.send_to(proc_addr, ProcessCommand::PollTick).unwrap(); + + let msgs = tick_collect(&rt, ¬if_inbox, 1, 20); + assert!( + msgs.iter().any(|m| matches!(m, ProcessNotification::Error { .. })), + "subscriber should get Error notification on spawn failure" + ); + + // Actor should have stopped — sending further messages should fail or be ignored + // (the address may still be in the map briefly, but the actor won't process) + for _ in 0..10 { + rt.tick(); + } +} + +#[test] +fn subscribe_and_unsubscribe_routing() { + let (rt, sender) = setup(); + let harness = TestHarness::new(); + let inbox_a = rt.new_inbox::().unwrap(); + let inbox_b = rt.new_inbox::().unwrap(); + + let spawner_addr = rt.spawn(SpawnerActor).unwrap(); + let reply_inbox = rt.new_inbox::().unwrap(); + rt.tick(); + + rt.send_to( + spawner_addr, + SpawnRequest { + spec: automated_spec(), + harness: harness.clone(), + reply_to: *reply_inbox.addr(), + sender: sender.clone(), + }, + ) + .unwrap(); + for _ in 0..5 { + rt.tick(); + } + let proc_addr = reply_inbox.try_recv().unwrap().0; + + // Subscribe both + rt.send_to(proc_addr, ProcessCommand::Subscribe { address: *inbox_a.addr() }) + .unwrap(); + rt.send_to(proc_addr, ProcessCommand::Subscribe { address: *inbox_b.addr() }) + .unwrap(); + rt.tick(); + + // Get to Running + harness.inject(ProcessEvent::Started); + rt.send_to(proc_addr, ProcessCommand::PollTick).unwrap(); + for _ in 0..5 { + rt.tick(); + } + + // Both should have received Started + assert!(inbox_a.try_recv().is_some(), "inbox_a should get Started"); + assert!(inbox_b.try_recv().is_some(), "inbox_b should get Started"); + + // Unsubscribe inbox_b + rt.send_to(proc_addr, ProcessCommand::Unsubscribe { address: *inbox_b.addr() }) + .unwrap(); + rt.tick(); + + // Inject output — only inbox_a should receive it + harness.inject(ProcessEvent::OutputReceived { + data: b"data".to_vec(), + is_stderr: false, + }); + rt.send_to(proc_addr, ProcessCommand::PollTick).unwrap(); + for _ in 0..5 { + rt.tick(); + } + + assert!(inbox_a.try_recv().is_some(), "inbox_a should get Output"); + assert!(inbox_b.try_recv().is_none(), "inbox_b should NOT get Output after unsubscribe"); +} diff --git a/crates/process/tests/e2e_process.rs b/crates/process/tests/e2e_process.rs new file mode 100644 index 0000000..31f323f --- /dev/null +++ b/crates/process/tests/e2e_process.rs @@ -0,0 +1,189 @@ +//! End-to-end tests: full Runtime + ExternalSender + ProcessActor. +//! +//! Spawns real OS processes through the actor system and verifies the +//! complete notification flow. + +use std::collections::HashMap; + +use swactor::actor::{ActorAddress, ActorInterface, Ctx}; +use swactor::runtime::{ExternalSender, Inbox, Runtime, RuntimeConfig}; + +use swactor_process::*; + +fn automated_spec(cmd: &str, args: &[&str]) -> ProcessSpec { + ProcessSpec { + command: cmd.into(), + args: args.iter().map(|s| s.to_string()).collect(), + env: HashMap::new(), + working_dir: None, + mode: ProcessMode::Automated, + initial_pty_size: None, + kill_timeout: None, + stdin_buffer_limit: None, + } +} + +/// Tick and collect up to `n` notifications, with a max tick budget. +fn tick_collect( + rt: &Runtime, + inbox: &Inbox, + n: usize, + max_ticks: usize, +) -> Vec { + let mut msgs = Vec::new(); + for _ in 0..max_ticks { + rt.tick(); + // Small sleep to let I/O threads produce events + std::thread::sleep(std::time::Duration::from_millis(5)); + while let Some(m) = inbox.try_recv() { + msgs.push(m); + if msgs.len() >= n { + return msgs; + } + } + } + msgs +} + +// ── Spawner actor (needed because spawn_local_process requires &Ctx) ──────── + +#[derive(Clone)] +struct E2eSpawnRequest { + spec: ProcessSpec, + subscriber: ActorAddress, + reply_to: ActorAddress, + sender: ExternalSender, +} + +#[derive(Clone, Debug)] +struct E2eSpawned(ActorAddress); + +struct E2eSpawnerActor; + +impl ActorInterface for E2eSpawnerActor { + type Incoming = E2eSpawnRequest; + type Response = E2eSpawned; + + fn handle(&mut self, ctx: &Ctx, msg: E2eSpawnRequest) { + let addr = spawn_local_process(ctx, &msg.sender, msg.spec) + .expect("spawn_local_process failed"); + // Subscribe the notification inbox + let _ = ctx.send(addr, ProcessCommand::Subscribe { address: msg.subscriber }); + let _ = ctx.send(msg.reply_to, E2eSpawned(addr)); + } +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +#[test] +fn echo_hello_full_lifecycle() { + let rt = Runtime::new(RuntimeConfig::default()); + let sender = rt.create_sender(); + let notif_inbox = rt.new_inbox::().unwrap(); + + let spawner_addr = rt.spawn(E2eSpawnerActor).unwrap(); + let reply_inbox = rt.new_inbox::().unwrap(); + rt.tick(); + + rt.send_to( + spawner_addr, + E2eSpawnRequest { + spec: automated_spec("echo", &["hello"]), + subscriber: *notif_inbox.addr(), + reply_to: *reply_inbox.addr(), + sender: sender.clone(), + }, + ) + .unwrap(); + + // Tick enough for the spawner to process + the process actor to start + for _ in 0..10 { + rt.tick(); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + + let spawned = reply_inbox.try_recv().expect("should get spawned address"); + let _proc_addr = spawned.0; + + // Collect notifications: Started, Output("hello\n"), Exited(0) + let msgs = tick_collect(&rt, ¬if_inbox, 3, 200); + + let has_started = msgs.iter().any(|m| matches!(m, ProcessNotification::Started { .. })); + let has_output = msgs.iter().any(|m| { + if let ProcessNotification::Output { data, .. } = m { + String::from_utf8_lossy(data).contains("hello") + } else { + false + } + }); + let has_exited = msgs.iter().any(|m| { + matches!( + m, + ProcessNotification::Exited { + status: ExitStatus::Code(0), + .. + } + ) + }); + + assert!(has_started, "should receive Started notification, got: {:?}", msgs); + assert!(has_output, "should receive Output with 'hello', got: {:?}", msgs); + assert!(has_exited, "should receive Exited(0) notification, got: {:?}", msgs); + + // Verify ordering: Started before Output before Exited + let started_idx = msgs + .iter() + .position(|m| matches!(m, ProcessNotification::Started { .. })) + .unwrap(); + let output_idx = msgs + .iter() + .position(|m| matches!(m, ProcessNotification::Output { .. })) + .unwrap(); + let exited_idx = msgs + .iter() + .position(|m| matches!(m, ProcessNotification::Exited { .. })) + .unwrap(); + + assert!( + started_idx < output_idx, + "Started should come before Output" + ); + assert!( + output_idx < exited_idx, + "Output should come before Exited" + ); +} + +#[test] +fn bad_command_reports_error_e2e() { + let rt = Runtime::new(RuntimeConfig::default()); + let sender = rt.create_sender(); + let notif_inbox = rt.new_inbox::().unwrap(); + + let spawner_addr = rt.spawn(E2eSpawnerActor).unwrap(); + let reply_inbox = rt.new_inbox::().unwrap(); + rt.tick(); + + rt.send_to( + spawner_addr, + E2eSpawnRequest { + spec: automated_spec("/nonexistent/binary/xyz", &[]), + subscriber: *notif_inbox.addr(), + reply_to: *reply_inbox.addr(), + sender: sender.clone(), + }, + ) + .unwrap(); + + for _ in 0..10 { + rt.tick(); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + + let msgs = tick_collect(&rt, ¬if_inbox, 1, 200); + assert!( + msgs.iter().any(|m| matches!(m, ProcessNotification::Error { .. })), + "should receive Error notification for bad command, got: {:?}", + msgs + ); +} diff --git a/crates/process/tests/local_driver.rs b/crates/process/tests/local_driver.rs new file mode 100644 index 0000000..cf5cd0f --- /dev/null +++ b/crates/process/tests/local_driver.rs @@ -0,0 +1,324 @@ +//! Layer 4 — LocalDriver integration tests. +//! +//! Real OS processes, no actor layer. Tests LocalDriver in isolation. + +use std::collections::HashMap; +use std::sync::{Arc, OnceLock}; +use std::thread; +use std::time::Duration; + +use swactor_process::*; + +fn automated_spec(cmd: &str, args: &[&str]) -> ProcessSpec { + ProcessSpec { + command: cmd.into(), + args: args.iter().map(|s| s.to_string()).collect(), + env: HashMap::new(), + working_dir: None, + mode: ProcessMode::Automated, + initial_pty_size: None, + kill_timeout: None, + stdin_buffer_limit: None, + } +} + +/// Poll the driver until `pred` matches at least one collected event, or timeout. +fn poll_until_match( + driver: &mut LocalDriver, + timeout: Duration, + pred: impl Fn(&ProcessEvent) -> bool, +) -> Vec { + let start = std::time::Instant::now(); + let mut all_events = Vec::new(); + loop { + let events = driver.poll(); + if events.is_empty() { + if start.elapsed() >= timeout { + break; + } + thread::sleep(Duration::from_millis(10)); + } + all_events.extend(events); + if all_events.iter().any(&pred) { + break; + } + } + all_events +} + +fn has_event(events: &[ProcessEvent], pred: impl Fn(&ProcessEvent) -> bool) -> bool { + events.iter().any(pred) +} + +#[test] +fn echo_produces_started_output_and_exit_zero() { + let queue = EventQueue::new(); + let waker_slot = Arc::new(OnceLock::new()); + let mut driver = LocalDriver::new(queue, waker_slot); + + let spec = automated_spec("echo", &["hello"]); + driver.execute(ProcessAction::SpawnProcess { spec }); + + // Wait for Exited (which means Started + output + exit are all in) + let events = poll_until_match(&mut driver, Duration::from_secs(5), |e| { + matches!(e, ProcessEvent::Exited { .. }) + }); + + assert!( + has_event(&events, |e| matches!(e, ProcessEvent::Started)), + "should have Started event, got: {:?}", + events + ); + assert!( + has_event(&events, |e| matches!(e, ProcessEvent::OutputReceived { is_stderr: false, .. })), + "should have stdout OutputReceived" + ); + + // Check the output contains "hello" + let output: Vec = events + .iter() + .filter_map(|e| match e { + ProcessEvent::OutputReceived { + data, is_stderr: false, + } => Some(data.clone()), + _ => None, + }) + .flatten() + .collect(); + let output_str = String::from_utf8_lossy(&output); + assert!( + output_str.contains("hello"), + "output should contain 'hello', got: {:?}", + output_str + ); + + assert!( + has_event(&events, |e| matches!( + e, + ProcessEvent::Exited { status: ExitStatus::Code(0) } + )), + "should have Exited(0)" + ); +} + +#[test] +fn cat_stdin_echo_and_close() { + let queue = EventQueue::new(); + let waker_slot = Arc::new(OnceLock::new()); + let mut driver = LocalDriver::new(queue, waker_slot); + + let spec = automated_spec("cat", &[]); + driver.execute(ProcessAction::SpawnProcess { spec }); + + // Wait for Started + let events = poll_until_match(&mut driver, Duration::from_secs(5), |e| { + matches!(e, ProcessEvent::Started) + }); + assert!(has_event(&events, |e| matches!(e, ProcessEvent::Started))); + + // Write to stdin + driver.execute(ProcessAction::WriteStdin { + data: b"ping\n".to_vec(), + }); + + // Wait until we see actual output (not just the StdinWritten ack) + let events = poll_until_match(&mut driver, Duration::from_secs(5), |e| { + matches!(e, ProcessEvent::OutputReceived { .. }) + }); + let output: Vec = events + .iter() + .filter_map(|e| match e { + ProcessEvent::OutputReceived { data, .. } => Some(data.clone()), + _ => None, + }) + .flatten() + .collect(); + let output_str = String::from_utf8_lossy(&output); + assert!( + output_str.contains("ping"), + "cat should echo back 'ping', got: {:?}", + output_str + ); + + // Close stdin — cat should exit + driver.execute(ProcessAction::CloseStdin); + let events = poll_until_match(&mut driver, Duration::from_secs(5), |e| { + matches!(e, ProcessEvent::Exited { .. }) + }); + assert!( + has_event(&events, |e| matches!( + e, + ProcessEvent::Exited { status: ExitStatus::Code(0) } + )), + "cat should exit cleanly after stdin close, got: {:?}", + events + ); +} + +#[test] +fn signal_terminates_long_running_process() { + let queue = EventQueue::new(); + let waker_slot = Arc::new(OnceLock::new()); + let mut driver = LocalDriver::new(queue, waker_slot); + + let spec = automated_spec("sleep", &["60"]); + driver.execute(ProcessAction::SpawnProcess { spec }); + + // Wait for Started + let events = poll_until_match(&mut driver, Duration::from_secs(5), |e| { + matches!(e, ProcessEvent::Started) + }); + assert!(has_event(&events, |e| matches!(e, ProcessEvent::Started))); + + // Send SIGTERM + driver.execute(ProcessAction::SendSignal { + signal: Signal::Terminate, + }); + + // Wait for Exited (may also see SignalSent ack first) + let events = poll_until_match(&mut driver, Duration::from_secs(5), |e| { + matches!(e, ProcessEvent::Exited { .. }) + }); + assert!( + has_event(&events, |e| matches!( + e, + ProcessEvent::Exited { status: ExitStatus::Signal(_) } + )), + "sleep should exit with signal status after SIGTERM, got: {:?}", + events + ); +} + +#[test] +fn bad_command_produces_spawn_failed() { + let queue = EventQueue::new(); + let waker_slot = Arc::new(OnceLock::new()); + let mut driver = LocalDriver::new(queue, waker_slot); + + let spec = automated_spec("/nonexistent/binary/that/does/not/exist", &[]); + driver.execute(ProcessAction::SpawnProcess { spec }); + + let events = poll_until_match(&mut driver, Duration::from_secs(5), |e| { + matches!(e, ProcessEvent::SpawnFailed { .. }) + }); + assert!( + has_event(&events, |e| matches!(e, ProcessEvent::SpawnFailed { .. })), + "nonexistent binary should produce SpawnFailed, got: {:?}", + events + ); +} + +#[test] +fn large_output_no_data_loss() { + let queue = EventQueue::new(); + let waker_slot = Arc::new(OnceLock::new()); + let mut driver = LocalDriver::new(queue, waker_slot); + + // Generate a large amount of output: seq 1 10000 + let spec = automated_spec("seq", &["1", "10000"]); + driver.execute(ProcessAction::SpawnProcess { spec }); + + // Collect all events until exit + let events = poll_until_match(&mut driver, Duration::from_secs(10), |e| { + matches!(e, ProcessEvent::Exited { .. }) + }); + + // Gather all output + let output: Vec = events + .iter() + .filter_map(|e| match e { + ProcessEvent::OutputReceived { data, .. } => Some(data.clone()), + _ => None, + }) + .flatten() + .collect(); + + let output_str = String::from_utf8_lossy(&output); + // seq 1 10000 should end with "10000\n" + assert!( + output_str.contains("10000"), + "large output should contain '10000'" + ); + // Check that it starts with "1\n" + assert!( + output_str.starts_with("1\n"), + "large output should start with '1\\n'" + ); + + assert!( + has_event(&events, |e| matches!( + e, + ProcessEvent::Exited { status: ExitStatus::Code(0) } + )), + "seq should exit cleanly" + ); +} + +#[test] +fn kill_timeout_escalates_to_sigkill() { + let queue = EventQueue::new(); + let waker_slot = Arc::new(OnceLock::new()); + let mut driver = LocalDriver::new(queue, waker_slot); + + // Spawn a process that traps SIGTERM. Use exec to replace the shell so + // SIGTERM goes directly to the perl process (avoids shell vs child races). + let spec = automated_spec( + "perl", + &["-e", "$SIG{TERM} = 'IGNORE'; sleep 300"], + ); + driver.execute(ProcessAction::SpawnProcess { spec }); + + // Wait for Started + let events = poll_until_match(&mut driver, Duration::from_secs(5), |e| { + matches!(e, ProcessEvent::Started) + }); + assert!(has_event(&events, |e| matches!(e, ProcessEvent::Started))); + + // Give the process a moment to set up the trap + thread::sleep(Duration::from_millis(100)); + + // Send SIGTERM (the process ignores it) + driver.execute(ProcessAction::SendSignal { signal: Signal::Terminate }); + poll_until_match(&mut driver, Duration::from_secs(1), |e| { + matches!(e, ProcessEvent::SignalSent) + }); + + // Verify the process is still alive after a short wait (SIGTERM was ignored) + thread::sleep(Duration::from_millis(200)); + let events = driver.poll(); + assert!( + !has_event(&events, |e| matches!(e, ProcessEvent::Exited { .. })), + "process should still be alive after SIGTERM (trap should ignore it)" + ); + + // Schedule a short kill timeout + driver.execute(ProcessAction::ScheduleKillTimeout { + duration: Duration::from_millis(200), + }); + + // Wait for KillTimeout event + let events = poll_until_match(&mut driver, Duration::from_secs(3), |e| { + matches!(e, ProcessEvent::KillTimeout) + }); + assert!( + has_event(&events, |e| matches!(e, ProcessEvent::KillTimeout)), + "should receive KillTimeout, got: {:?}", + events + ); + + // Now send SIGKILL + driver.execute(ProcessAction::SendSignal { signal: Signal::Kill }); + + // Wait for exit + let events = poll_until_match(&mut driver, Duration::from_secs(5), |e| { + matches!(e, ProcessEvent::Exited { .. }) + }); + assert!( + has_event(&events, |e| matches!( + e, + ProcessEvent::Exited { status: ExitStatus::Signal(_) } + )), + "process should exit with signal after SIGKILL, got: {:?}", + events + ); +} diff --git a/crates/process/tests/proptest_session.rs b/crates/process/tests/proptest_session.rs new file mode 100644 index 0000000..de84749 --- /dev/null +++ b/crates/process/tests/proptest_session.rs @@ -0,0 +1,195 @@ +use std::collections::HashMap; + +use proptest::prelude::*; +use swactor::actor::ActorAddress; +use swactor_process::*; + +fn automated_spec() -> ProcessSpec { + ProcessSpec { + command: "test".into(), + args: vec![], + env: HashMap::new(), + working_dir: None, + mode: ProcessMode::Automated, + initial_pty_size: None, + kill_timeout: None, + stdin_buffer_limit: None, + } +} + +fn addr(n: u8) -> ActorAddress { + let mut bytes = [0u8; 32]; + bytes[0] = n; + ActorAddress(bytes) +} + +fn arb_signal() -> impl Strategy { + prop_oneof![ + Just(Signal::Terminate), + Just(Signal::Kill), + Just(Signal::Hangup), + Just(Signal::Interrupt), + (0..32i32).prop_map(Signal::Other), + ] +} + +fn arb_event() -> impl Strategy { + prop_oneof![ + Just(ProcessEvent::Started), + Just(ProcessEvent::KillTimeout), + ".*".prop_map(|reason| ProcessEvent::SpawnFailed { reason }), + proptest::collection::vec(any::(), 0..64) + .prop_map(|data| ProcessEvent::OutputReceived { data, is_stderr: false }), + proptest::collection::vec(any::(), 0..64) + .prop_map(|data| ProcessEvent::OutputReceived { data, is_stderr: true }), + prop_oneof![ + any::().prop_map(ExitStatus::Code), + any::().prop_map(ExitStatus::Signal), + Just(ExitStatus::Unknown), + ] + .prop_map(|status| ProcessEvent::Exited { status }), + ".*".prop_map(|reason| ProcessEvent::ConnectionLost { reason }), + (0..5usize).prop_map(|n| ProcessEvent::StdinWritten { byte_count: n * 10 }), + Just(ProcessEvent::SignalSent), + Just(ProcessEvent::PtyResized), + proptest::collection::vec(any::(), 0..64) + .prop_map(|data| ProcessEvent::WriteStdin { data }), + arb_signal().prop_map(|signal| ProcessEvent::SendSignal { signal }), + Just(ProcessEvent::ResizePty { + size: PtySize { cols: 80, rows: 24 }, + }), + Just(ProcessEvent::CloseStdin), + Just(ProcessEvent::CloseRequested), + (0..4u8).prop_map(|n| ProcessEvent::Subscribe { address: addr(n) }), + (0..4u8).prop_map(|n| ProcessEvent::Unsubscribe { address: addr(n) }), + ] +} + +// ────────────────────────────────────────────── +// 1. No panics for arbitrary event sequences +// ────────────────────────────────────────────── + +proptest! { + #[test] + fn no_panics_on_arbitrary_events(events in proptest::collection::vec(arb_event(), 0..50)) { + let (mut session, _) = ProcessSession::new(automated_spec()); + for event in events { + let _ = session.apply(event); + } + } +} + +// ────────────────────────────────────────────── +// 2. Exited is terminal +// ────────────────────────────────────────────── + +proptest! { + #[test] + fn exited_is_terminal(events in proptest::collection::vec(arb_event(), 0..50)) { + let (mut session, _) = ProcessSession::new(automated_spec()); + let mut reached_exited = false; + + for event in events { + let _ = session.apply(event); + if session.state() == ProcessState::Exited { + reached_exited = true; + } + if reached_exited { + prop_assert_eq!(session.state(), ProcessState::Exited); + } + } + } +} + +// ────────────────────────────────────────────── +// 3. SelfTerminate always last action when entering Exited +// ────────────────────────────────────────────── + +proptest! { + #[test] + fn self_terminate_is_last_when_entering_exited(events in proptest::collection::vec(arb_event(), 0..50)) { + let (mut session, _) = ProcessSession::new(automated_spec()); + let mut was_exited = false; + + for event in events { + let prev_state = session.state(); + let actions = session.apply(event); + + // If we just transitioned into Exited + if session.state() == ProcessState::Exited && !was_exited && prev_state != ProcessState::Exited { + prop_assert!( + matches!(actions.last(), Some(ProcessAction::SelfTerminate)), + "SelfTerminate must be last action when entering Exited, got: {:?}", actions + ); + } + + if session.state() == ProcessState::Exited { + was_exited = true; + } + } + } +} + +// ────────────────────────────────────────────── +// 4. Subscriber count matches add/remove operations +// ────────────────────────────────────────────── + +proptest! { + #[test] + fn subscriber_count_is_consistent( + ops in proptest::collection::vec( + prop_oneof![ + (0..8u8).prop_map(|n| (true, n)), + (0..8u8).prop_map(|n| (false, n)), + ], + 0..30 + ) + ) { + let (mut session, _) = ProcessSession::new(automated_spec()); + let mut expected: Vec = Vec::new(); + + for (is_add, n) in ops { + if is_add { + session.apply(ProcessEvent::Subscribe { address: addr(n) }); + if !expected.contains(&n) { + expected.push(n); + } + } else { + session.apply(ProcessEvent::Unsubscribe { address: addr(n) }); + expected.retain(|&x| x != n); + } + prop_assert_eq!(session.subscriber_count(), expected.len()); + } + } +} + +// ────────────────────────────────────────────── +// 5. State monotonicity (never goes backward) +// ────────────────────────────────────────────── + +fn state_ordinal(s: ProcessState) -> u8 { + match s { + ProcessState::Starting => 0, + ProcessState::Running => 1, + ProcessState::Stopping => 2, + ProcessState::Exited => 3, + } +} + +proptest! { + #[test] + fn state_never_goes_backward(events in proptest::collection::vec(arb_event(), 0..50)) { + let (mut session, _) = ProcessSession::new(automated_spec()); + let mut max_ordinal = state_ordinal(session.state()); + + for event in events { + let _ = session.apply(event); + let current = state_ordinal(session.state()); + prop_assert!( + current >= max_ordinal, + "State went backward: ordinal {} -> {}", max_ordinal, current + ); + max_ordinal = current; + } + } +} diff --git a/crates/process/tests/session_scenarios.rs b/crates/process/tests/session_scenarios.rs new file mode 100644 index 0000000..d5bd99c --- /dev/null +++ b/crates/process/tests/session_scenarios.rs @@ -0,0 +1,698 @@ +use std::collections::HashMap; +use std::time::Duration; + +use swactor::actor::ActorAddress; +use swactor_process::*; + +fn automated_spec() -> ProcessSpec { + ProcessSpec { + command: "echo".into(), + args: vec!["hello".into()], + env: HashMap::new(), + working_dir: None, + mode: ProcessMode::Automated, + initial_pty_size: None, + kill_timeout: None, + stdin_buffer_limit: None, + } +} + +fn spec_with_kill_timeout(timeout: Duration) -> ProcessSpec { + ProcessSpec { + kill_timeout: Some(timeout), + ..automated_spec() + } +} + +fn spec_with_stdin_limit(limit: usize) -> ProcessSpec { + ProcessSpec { + stdin_buffer_limit: Some(limit), + ..automated_spec() + } +} + +fn interactive_spec() -> ProcessSpec { + ProcessSpec { + command: "/bin/bash".into(), + args: vec![], + env: HashMap::new(), + working_dir: None, + mode: ProcessMode::Interactive, + initial_pty_size: Some(PtySize { cols: 80, rows: 24 }), + kill_timeout: None, + stdin_buffer_limit: None, + } +} + +fn addr(n: u8) -> ActorAddress { + let mut bytes = [0u8; 32]; + bytes[0] = n; + ActorAddress(bytes) +} + +/// Verify that a SelfTerminate is present and is the last action. +fn assert_self_terminate_is_last(actions: &[ProcessAction]) { + assert!( + matches!(actions.last(), Some(ProcessAction::SelfTerminate)), + "SelfTerminate must be the last action, got: {actions:?}" + ); +} + +// ────────────────────────────────────────────── +// 1. Happy path — automated process +// ────────────────────────────────────────────── + +#[test] +fn automated_process_runs_produces_output_and_exits_cleanly() { + let (mut session, init) = ProcessSession::new(automated_spec()); + assert_eq!(session.state(), ProcessState::Starting); + assert!(matches!(&init[0], ProcessAction::SpawnProcess { .. })); + + // Process starts + let actions = session.apply(ProcessEvent::Started); + assert_eq!(session.state(), ProcessState::Running); + assert!(matches!(&actions[0], ProcessAction::NotifyStarted { .. })); + + // Some output arrives + let actions = session.apply(ProcessEvent::OutputReceived { + data: b"hello\n".to_vec(), + is_stderr: false, + }); + assert!(matches!( + &actions[0], + ProcessAction::NotifyOutput { stream: OutputStream::Stdout, .. } + )); + + // More output on stderr + let actions = session.apply(ProcessEvent::OutputReceived { + data: b"warn\n".to_vec(), + is_stderr: true, + }); + assert!(matches!( + &actions[0], + ProcessAction::NotifyOutput { stream: OutputStream::Stderr, .. } + )); + + // Process exits + let actions = session.apply(ProcessEvent::Exited { + status: ExitStatus::Code(0), + }); + assert_eq!(session.state(), ProcessState::Exited); + assert_eq!(session.exit_status(), Some(ExitStatus::Code(0))); + assert_self_terminate_is_last(&actions); +} + +// ────────────────────────────────────────────── +// 2. Interactive process with subscriber lifecycle +// ────────────────────────────────────────────── + +#[test] +fn interactive_session_manages_subscribers_correctly() { + let (mut session, _) = ProcessSession::new(interactive_spec()); + + // Add two subscribers before start + session.apply(ProcessEvent::Subscribe { address: addr(1) }); + session.apply(ProcessEvent::Subscribe { address: addr(2) }); + assert_eq!(session.subscriber_count(), 2); + + // Duplicate add is a no-op + session.apply(ProcessEvent::Subscribe { address: addr(1) }); + assert_eq!(session.subscriber_count(), 2); + + // Start — both subscribers notified + let actions = session.apply(ProcessEvent::Started); + match &actions[0] { + ProcessAction::NotifyStarted { subscribers } => { + assert_eq!(subscribers.len(), 2); + } + other => panic!("expected NotifyStarted, got {other:?}"), + } + + // Remove one subscriber + session.apply(ProcessEvent::Unsubscribe { address: addr(1) }); + assert_eq!(session.subscriber_count(), 1); + + // Output only goes to remaining subscriber + let actions = session.apply(ProcessEvent::OutputReceived { + data: b"data".to_vec(), + is_stderr: false, + }); + match &actions[0] { + ProcessAction::NotifyOutput { subscribers, .. } => { + assert_eq!(subscribers, &vec![addr(2)]); + } + other => panic!("expected NotifyOutput, got {other:?}"), + } + + // Exit + let actions = session.apply(ProcessEvent::Exited { + status: ExitStatus::Code(0), + }); + match &actions[0] { + ProcessAction::NotifyExited { subscribers, .. } => { + assert_eq!(subscribers, &vec![addr(2)]); + } + other => panic!("expected NotifyExited, got {other:?}"), + } + assert_self_terminate_is_last(&actions); +} + +// ────────────────────────────────────────────── +// 3. Spawn failure +// ────────────────────────────────────────────── + +#[test] +fn spawn_failure_notifies_and_self_terminates() { + let (mut session, _) = ProcessSession::new(automated_spec()); + session.apply(ProcessEvent::Subscribe { address: addr(1) }); + + let actions = session.apply(ProcessEvent::SpawnFailed { + reason: "command not found".into(), + }); + assert_eq!(session.state(), ProcessState::Exited); + assert!(matches!( + &actions[0], + ProcessAction::NotifyError { + error: ProcessError::SpawnFailed { .. }, + .. + } + )); + assert_self_terminate_is_last(&actions); +} + +// ────────────────────────────────────────────── +// 4. Connection loss mid-run +// ────────────────────────────────────────────── + +#[test] +fn connection_loss_during_running_transitions_to_exited() { + let (mut session, _) = ProcessSession::new(automated_spec()); + session.apply(ProcessEvent::Started); + + let actions = session.apply(ProcessEvent::ConnectionLost { + reason: "pipe broken".into(), + }); + assert_eq!(session.state(), ProcessState::Exited); + assert_eq!(session.exit_status(), Some(ExitStatus::Unknown)); + assert!(matches!( + &actions[0], + ProcessAction::NotifyError { + error: ProcessError::ConnectionLost { .. }, + .. + } + )); + assert_self_terminate_is_last(&actions); +} + +// ────────────────────────────────────────────── +// 5. Close requested before start +// ────────────────────────────────────────────── + +#[test] +fn close_before_start_sends_signal_on_belated_start() { + let (mut session, _) = ProcessSession::new(automated_spec()); + + // Close requested while still Starting + let actions = session.apply(ProcessEvent::CloseRequested); + assert!(actions.is_empty()); + assert_eq!(session.state(), ProcessState::Starting); + + // Process starts belatedly — should immediately get SIGTERM + let actions = session.apply(ProcessEvent::Started); + assert_eq!(session.state(), ProcessState::Stopping); + assert!(matches!(&actions[0], ProcessAction::NotifyStarted { .. })); + assert!(matches!( + &actions[1], + ProcessAction::SendSignal { signal: Signal::Terminate } + )); +} + +// ────────────────────────────────────────────── +// 6. Invalid operations produce errors, not panics +// ────────────────────────────────────────────── + +#[test] +fn invalid_event_in_starting_produces_error() { + let (mut session, _) = ProcessSession::new(automated_spec()); + + let actions = session.apply(ProcessEvent::WriteStdin { + data: b"hi".to_vec(), + }); + assert!(matches!( + &actions[0], + ProcessAction::NotifyError { + error: ProcessError::InvalidState { attempted: "WriteStdin", current_state: "Starting" }, + .. + } + )); + // State unchanged + assert_eq!(session.state(), ProcessState::Starting); +} + +#[test] +fn invalid_event_in_exited_produces_error() { + let (mut session, _) = ProcessSession::new(automated_spec()); + session.apply(ProcessEvent::SpawnFailed { + reason: "no".into(), + }); + assert_eq!(session.state(), ProcessState::Exited); + + let actions = session.apply(ProcessEvent::WriteStdin { + data: b"hi".to_vec(), + }); + assert!(matches!( + &actions[0], + ProcessAction::NotifyError { + error: ProcessError::InvalidState { attempted: "WriteStdin", current_state: "Exited" }, + .. + } + )); +} + +// ────────────────────────────────────────────── +// 7. Stdin closed then write → error +// ────────────────────────────────────────────── + +#[test] +fn write_after_stdin_closed_produces_error() { + let (mut session, _) = ProcessSession::new(automated_spec()); + session.apply(ProcessEvent::Started); + + let actions = session.apply(ProcessEvent::CloseStdin); + assert!(matches!(&actions[0], ProcessAction::CloseStdin)); + assert!(session.stdin_closed()); + + // Duplicate close is a no-op + let actions = session.apply(ProcessEvent::CloseStdin); + assert!(actions.is_empty()); + + // Write after close → error + let actions = session.apply(ProcessEvent::WriteStdin { + data: b"too late".to_vec(), + }); + assert!(matches!( + &actions[0], + ProcessAction::NotifyError { + error: ProcessError::InvalidState { .. }, + .. + } + )); +} + +// ────────────────────────────────────────────── +// 8. MockDriver round-trip (driver + session tick loop) +// ────────────────────────────────────────────── + +#[test] +fn mock_driver_round_trip() { + let (mut session, init_actions) = ProcessSession::new(automated_spec()); + let mut driver = MockDriver::new(); + + // Execute initial actions (SpawnProcess) + for action in init_actions { + driver.execute(action); + } + assert!(matches!( + &driver.executed_actions()[0], + ProcessAction::SpawnProcess { .. } + )); + + // Simulate: driver produces Started + driver.inject(ProcessEvent::Started); + + // Tick loop: poll → apply → execute + let events = driver.poll(); + for event in events { + let actions = session.apply(event); + for action in actions { + driver.execute(action); + } + } + assert_eq!(session.state(), ProcessState::Running); + + // Simulate output and exit + driver.inject(ProcessEvent::OutputReceived { + data: b"done".to_vec(), + is_stderr: false, + }); + driver.inject(ProcessEvent::Exited { + status: ExitStatus::Code(0), + }); + + let events = driver.poll(); + for event in events { + let actions = session.apply(event); + for action in actions { + driver.execute(action); + } + } + + assert_eq!(session.state(), ProcessState::Exited); + + // Verify the driver saw the expected sequence + let all_actions = driver.take_executed_actions(); + assert!(matches!(&all_actions[0], ProcessAction::SpawnProcess { .. })); + assert!(matches!(&all_actions[1], ProcessAction::NotifyStarted { .. })); + assert!(matches!(&all_actions[2], ProcessAction::NotifyOutput { .. })); + assert!(matches!(&all_actions[3], ProcessAction::NotifyExited { .. })); + assert!(matches!(&all_actions[4], ProcessAction::SelfTerminate)); +} + +// ────────────────────────────────────────────── +// 9. Signal escalation in Stopping +// ────────────────────────────────────────────── + +#[test] +fn signal_escalation_allowed_in_stopping() { + let (mut session, _) = ProcessSession::new(automated_spec()); + session.apply(ProcessEvent::Started); + session.apply(ProcessEvent::CloseRequested); + assert_eq!(session.state(), ProcessState::Stopping); + + // Escalate to Kill + let actions = session.apply(ProcessEvent::SendSignal { + signal: Signal::Kill, + }); + assert!(matches!( + &actions[0], + ProcessAction::SendSignal { signal: Signal::Kill } + )); + + // Can still receive output while stopping + let actions = session.apply(ProcessEvent::OutputReceived { + data: b"final".to_vec(), + is_stderr: false, + }); + assert!(matches!(&actions[0], ProcessAction::NotifyOutput { .. })); + + // Finally exits + let actions = session.apply(ProcessEvent::Exited { + status: ExitStatus::Signal(9), + }); + assert_eq!(session.exit_status(), Some(ExitStatus::Signal(9))); + assert_self_terminate_is_last(&actions); +} + +// ────────────────────────────────────────────── +// 10. Late acks in Exited silently consumed +// ────────────────────────────────────────────── + +#[test] +fn late_acks_in_exited_are_silently_consumed() { + let (mut session, _) = ProcessSession::new(automated_spec()); + session.apply(ProcessEvent::Started); + session.apply(ProcessEvent::Exited { + status: ExitStatus::Code(0), + }); + assert_eq!(session.state(), ProcessState::Exited); + + // Acks should produce no actions, no errors + assert!(session.apply(ProcessEvent::StdinWritten { byte_count: 10 }).is_empty()); + assert!(session.apply(ProcessEvent::SignalSent).is_empty()); + assert!(session.apply(ProcessEvent::PtyResized).is_empty()); + + // Subscribe/Unsubscribe also still works in Exited + assert!(session.apply(ProcessEvent::Subscribe { address: addr(1) }).is_empty()); + assert_eq!(session.subscriber_count(), 1); + assert!(session.apply(ProcessEvent::Unsubscribe { address: addr(1) }).is_empty()); + assert_eq!(session.subscriber_count(), 0); +} + +// ────────────────────────────────────────────── +// Flow control tracking +// ────────────────────────────────────────────── + +#[test] +fn flow_control_tracks_pending_stdin_bytes() { + let (mut session, _) = ProcessSession::new(automated_spec()); + session.apply(ProcessEvent::Started); + + session.apply(ProcessEvent::WriteStdin { + data: vec![0u8; 100], + }); + assert_eq!(session.flow_control().pending_stdin_bytes, 100); + + session.apply(ProcessEvent::WriteStdin { + data: vec![0u8; 50], + }); + assert_eq!(session.flow_control().pending_stdin_bytes, 150); + + session.apply(ProcessEvent::StdinWritten { byte_count: 80 }); + assert_eq!(session.flow_control().pending_stdin_bytes, 70); + + // Ack more than pending → saturates at 0 + session.apply(ProcessEvent::StdinWritten { byte_count: 200 }); + assert_eq!(session.flow_control().pending_stdin_bytes, 0); +} + +// ────────────────────────────────────────────── +// CloseStdin in Stopping +// ────────────────────────────────────────────── + +#[test] +fn close_stdin_allowed_in_stopping() { + let (mut session, _) = ProcessSession::new(automated_spec()); + session.apply(ProcessEvent::Started); + session.apply(ProcessEvent::CloseRequested); + assert_eq!(session.state(), ProcessState::Stopping); + + let actions = session.apply(ProcessEvent::CloseStdin); + assert!(matches!(&actions[0], ProcessAction::CloseStdin)); + assert!(session.stdin_closed()); +} + +// ────────────────────────────────────────────── +// Connection loss in Stopping +// ────────────────────────────────────────────── + +#[test] +fn connection_loss_in_stopping_transitions_to_exited() { + let (mut session, _) = ProcessSession::new(automated_spec()); + session.apply(ProcessEvent::Started); + session.apply(ProcessEvent::CloseRequested); + assert_eq!(session.state(), ProcessState::Stopping); + + let actions = session.apply(ProcessEvent::ConnectionLost { + reason: "gone".into(), + }); + assert_eq!(session.state(), ProcessState::Exited); + assert_self_terminate_is_last(&actions); +} + +// ────────────────────────────────────────────── +// Redundant CloseRequested in Stopping is no-op +// ────────────────────────────────────────────── + +#[test] +fn duplicate_close_requested_in_stopping_is_noop() { + let (mut session, _) = ProcessSession::new(automated_spec()); + session.apply(ProcessEvent::Started); + session.apply(ProcessEvent::CloseRequested); + assert_eq!(session.state(), ProcessState::Stopping); + + let actions = session.apply(ProcessEvent::CloseRequested); + assert!(actions.is_empty()); + assert_eq!(session.state(), ProcessState::Stopping); +} + +// ────────────────────────────────────────────── +// Kill timeout — A1–A6 +// ────────────────────────────────────────────── + +#[test] +fn close_requested_with_kill_timeout_schedules_timer() { + let (mut session, _) = ProcessSession::new(spec_with_kill_timeout(Duration::from_secs(5))); + session.apply(ProcessEvent::Started); + + let actions = session.apply(ProcessEvent::CloseRequested); + assert_eq!(session.state(), ProcessState::Stopping); + assert!(matches!( + &actions[0], + ProcessAction::SendSignal { signal: Signal::Terminate } + )); + assert!(matches!( + &actions[1], + ProcessAction::ScheduleKillTimeout { duration } if *duration == Duration::from_secs(5) + )); +} + +#[test] +fn close_before_start_with_kill_timeout_schedules_timer_on_belated_start() { + let (mut session, _) = ProcessSession::new(spec_with_kill_timeout(Duration::from_secs(3))); + session.apply(ProcessEvent::CloseRequested); + + let actions = session.apply(ProcessEvent::Started); + assert_eq!(session.state(), ProcessState::Stopping); + assert!(matches!(&actions[0], ProcessAction::NotifyStarted { .. })); + assert!(matches!( + &actions[1], + ProcessAction::SendSignal { signal: Signal::Terminate } + )); + assert!(matches!( + &actions[2], + ProcessAction::ScheduleKillTimeout { duration } if *duration == Duration::from_secs(3) + )); +} + +#[test] +fn kill_timeout_in_stopping_sends_sigkill() { + let (mut session, _) = ProcessSession::new(spec_with_kill_timeout(Duration::from_secs(5))); + session.apply(ProcessEvent::Started); + session.apply(ProcessEvent::CloseRequested); + assert_eq!(session.state(), ProcessState::Stopping); + + let actions = session.apply(ProcessEvent::KillTimeout); + assert!(matches!( + &actions[0], + ProcessAction::SendSignal { signal: Signal::Kill } + )); + assert_eq!(session.state(), ProcessState::Stopping); +} + +#[test] +fn kill_timeout_silently_consumed_outside_stopping() { + // Starting + let (mut session, _) = ProcessSession::new(automated_spec()); + assert!(session.apply(ProcessEvent::KillTimeout).is_empty()); + assert_eq!(session.state(), ProcessState::Starting); + + // Running + session.apply(ProcessEvent::Started); + assert!(session.apply(ProcessEvent::KillTimeout).is_empty()); + assert_eq!(session.state(), ProcessState::Running); + + // Exited + session.apply(ProcessEvent::Exited { status: ExitStatus::Code(0) }); + assert!(session.apply(ProcessEvent::KillTimeout).is_empty()); + assert_eq!(session.state(), ProcessState::Exited); +} + +#[test] +fn close_requested_without_kill_timeout_no_schedule_action() { + let (mut session, _) = ProcessSession::new(automated_spec()); + session.apply(ProcessEvent::Started); + + let actions = session.apply(ProcessEvent::CloseRequested); + assert_eq!(actions.len(), 1); + assert!(matches!( + &actions[0], + ProcessAction::SendSignal { signal: Signal::Terminate } + )); +} + +#[test] +fn kill_timeout_full_escalation_to_sigkill_then_exit() { + let (mut session, _) = ProcessSession::new(spec_with_kill_timeout(Duration::from_secs(1))); + session.apply(ProcessEvent::Started); + + // CloseRequested → SIGTERM + schedule + let actions = session.apply(ProcessEvent::CloseRequested); + assert_eq!(session.state(), ProcessState::Stopping); + assert!(matches!(&actions[0], ProcessAction::SendSignal { signal: Signal::Terminate })); + assert!(matches!(&actions[1], ProcessAction::ScheduleKillTimeout { .. })); + + // KillTimeout fires → SIGKILL + let actions = session.apply(ProcessEvent::KillTimeout); + assert!(matches!(&actions[0], ProcessAction::SendSignal { signal: Signal::Kill })); + + // Process finally exits via signal 9 + let actions = session.apply(ProcessEvent::Exited { status: ExitStatus::Signal(9) }); + assert_eq!(session.state(), ProcessState::Exited); + assert_eq!(session.exit_status(), Some(ExitStatus::Signal(9))); + assert_self_terminate_is_last(&actions); +} + +// ────────────────────────────────────────────── +// Backpressure — B1–B5 +// ────────────────────────────────────────────── + +#[test] +fn backpressure_buffers_when_over_limit() { + let (mut session, _) = ProcessSession::new(spec_with_stdin_limit(100)); + session.apply(ProcessEvent::Started); + + // First write (50 bytes) — under limit, passes through + let actions = session.apply(ProcessEvent::WriteStdin { data: vec![1u8; 50] }); + assert_eq!(actions.len(), 1); + assert!(matches!(&actions[0], ProcessAction::WriteStdin { .. })); + assert_eq!(session.flow_control().pending_stdin_bytes, 50); + + // Second write (60 bytes) — still under limit (50 < 100), passes through + let actions = session.apply(ProcessEvent::WriteStdin { data: vec![2u8; 60] }); + assert_eq!(actions.len(), 1); + assert_eq!(session.flow_control().pending_stdin_bytes, 110); + + // Third write (30 bytes) — now at 110 >= 100, buffered + let actions = session.apply(ProcessEvent::WriteStdin { data: vec![3u8; 30] }); + assert!(actions.is_empty()); + assert_eq!(session.stdin_buffer_bytes(), 30); + // pending_stdin_bytes unchanged (buffered data not counted as pending) + assert_eq!(session.flow_control().pending_stdin_bytes, 110); +} + +#[test] +fn stdin_written_ack_drains_buffer() { + let (mut session, _) = ProcessSession::new(spec_with_stdin_limit(100)); + session.apply(ProcessEvent::Started); + + // Fill up: 100 bytes pending + session.apply(ProcessEvent::WriteStdin { data: vec![1u8; 100] }); + assert_eq!(session.flow_control().pending_stdin_bytes, 100); + + // Buffer two chunks + session.apply(ProcessEvent::WriteStdin { data: vec![2u8; 40] }); + session.apply(ProcessEvent::WriteStdin { data: vec![3u8; 30] }); + assert_eq!(session.stdin_buffer_bytes(), 70); + + // Ack 80 bytes → pending drops to 20, buffer should drain in FIFO order + let actions = session.apply(ProcessEvent::StdinWritten { byte_count: 80 }); + // pending was 100, now 20. Drain first chunk (40 bytes) → pending = 60. + // 60 < 100, drain second chunk (30 bytes) → pending = 90. + // 90 < 100, buffer empty. + assert_eq!(actions.len(), 2); + assert!(matches!(&actions[0], ProcessAction::WriteStdin { data } if data.len() == 40)); + assert!(matches!(&actions[1], ProcessAction::WriteStdin { data } if data.len() == 30)); + assert_eq!(session.flow_control().pending_stdin_bytes, 90); + assert_eq!(session.stdin_buffer_bytes(), 0); +} + +#[test] +fn close_requested_clears_stdin_buffer() { + let (mut session, _) = ProcessSession::new(spec_with_stdin_limit(50)); + session.apply(ProcessEvent::Started); + + session.apply(ProcessEvent::WriteStdin { data: vec![1u8; 60] }); + session.apply(ProcessEvent::WriteStdin { data: vec![2u8; 30] }); + assert_eq!(session.stdin_buffer_bytes(), 30); + + session.apply(ProcessEvent::CloseRequested); + assert_eq!(session.stdin_buffer_bytes(), 0); +} + +#[test] +fn no_backpressure_when_limit_is_none() { + let (mut session, _) = ProcessSession::new(automated_spec()); + session.apply(ProcessEvent::Started); + + // All writes pass through regardless of pending bytes + for _ in 0..10 { + let actions = session.apply(ProcessEvent::WriteStdin { data: vec![0u8; 1000] }); + assert_eq!(actions.len(), 1); + assert!(matches!(&actions[0], ProcessAction::WriteStdin { .. })); + } + assert_eq!(session.flow_control().pending_stdin_bytes, 10_000); + assert_eq!(session.stdin_buffer_bytes(), 0); +} + +#[test] +fn exit_clears_stdin_buffer() { + let (mut session, _) = ProcessSession::new(spec_with_stdin_limit(50)); + session.apply(ProcessEvent::Started); + + session.apply(ProcessEvent::WriteStdin { data: vec![1u8; 60] }); + session.apply(ProcessEvent::WriteStdin { data: vec![2u8; 30] }); + assert_eq!(session.stdin_buffer_bytes(), 30); + + session.apply(ProcessEvent::Exited { status: ExitStatus::Code(0) }); + assert_eq!(session.stdin_buffer_bytes(), 0); +} diff --git a/docs/development_history/PROCESS_RUNNER.md b/docs/development_history/PROCESS_RUNNER.md new file mode 100644 index 0000000..2fbfa3b --- /dev/null +++ b/docs/development_history/PROCESS_RUNNER.md @@ -0,0 +1,434 @@ +# Process Runner Design: Async Process Management in Swactor + +## Context + +Swactor is a synchronous, tick-based actor framework (Erlang-inspired). Actors must return quickly from `handle()` — blocking stalls the entire worker thread. There is no built-in async I/O. + +The goal: let actors manage long-lived async "processes" — OS subprocesses and SSH shells — with full lifecycle control. Must support both interactive use (live shell, bidirectional real-time I/O) and automated execution (run commands, stream output, report exit). + +Constraints from discussion: +- Backends: SSH + local processes (two backends, not more) +- Scale: Architecture should support thousands; first implementation handles tens +- This is a standalone new feature — not related to or derived from the CI runner system + +--- + +## Architecture: State Machine + Driver + Process-as-Actor + +### Data Flow (full picture) + +``` +OS process stdout/stderr + │ (background thread reads pipe) + ▼ + EventQueue (Arc) — shared lock-free buffer + │ (background thread calls ProcessWaker → ExternalSender → PollTick) + ▼ + Actor handle(PollTick) + │ calls driver.poll() which drains EventQueue + ▼ + Vec + │ + ▼ + session.apply(event) → Vec + │ + ├─ Driver commands → driver.execute(action) → OS I/O + ├─ Notifications → ctx.send(subscriber, ProcessNotification) + └─ SelfTerminate → ctx.stop_self() +``` + +### The Layers + +| Layer | Purpose | Status | +|-------|---------|--------| +| 1 — ProcessSession | Pure-logic state machine | **Implemented** | +| 2 — ProcessDriver trait + MockDriver | Driver abstraction + test double | **Implemented** | +| 3 — Process Actor + ExternalSender | Swactor integration, waker, event queue | **Implemented** | +| 4 — LocalDriver | `std::process::Command` + pipe I/O + signal | **Implemented** | +| 5 — SshDriver | SSH library + channel I/O | Not started | + +--- + +## Implemented: Layers 1 + 2 (Pure Logic) + +Crate: `crates/process/` (`swactor-process`) + +### Layer 1 — ProcessSession (State Machine) + +The core state machine. Pure logic, no I/O, fully deterministic. + +**States:** `Starting` → `Running` → `Stopping` → `Exited` + +State transitions are monotonic — the state never goes backward. `Exited` is terminal. + +**Construction:** + +```rust +let (session, initial_actions) = ProcessSession::new(spec); +// initial_actions == [SpawnProcess { spec }] +// session.state() == Starting +``` + +**Event loop:** + +```rust +let actions = session.apply(event); +for action in actions { + match action { + ProcessAction::SpawnProcess { .. } | + ProcessAction::WriteStdin { .. } | + ProcessAction::SendSignal { .. } | + ProcessAction::ResizePty { .. } | + ProcessAction::CloseStdin | + ProcessAction::ScheduleKillTimeout { .. } => driver.execute(action), + + ProcessAction::NotifyStarted { subscribers } | + ProcessAction::NotifyOutput { subscribers, .. } | + ProcessAction::NotifyExited { subscribers, .. } | + ProcessAction::NotifyError { subscribers, .. } => { /* send to subscribers */ } + + ProcessAction::SelfTerminate => { /* actor stops itself */ } + } +} +``` + +**Key invariants (all verified by property-based tests):** +- Invalid events produce `NotifyError` actions — never panic +- `SelfTerminate` is always the last action when entering `Exited` +- State monotonicity: Starting ≤ Running ≤ Stopping ≤ Exited +- Subscriber count always matches add/remove operations +- No panics for arbitrary event sequences + +**Event handling by state:** + +| Event | Starting | Running | Stopping | Exited | +|-------|----------|---------|----------|--------| +| Started | → Running (+ NotifyStarted) | error | error | error | +| SpawnFailed | → Exited (+ NotifyError + SelfTerminate) | error | error | error | +| OutputReceived | error | NotifyOutput | NotifyOutput | error | +| Exited | error | → Exited (+ NotifyExited + SelfTerminate) | → Exited (+ NotifyExited + SelfTerminate) | error | +| ConnectionLost | error | → Exited (+ NotifyError + SelfTerminate) | → Exited (+ NotifyError + SelfTerminate) | error | +| WriteStdin | error | WriteStdin (or buffer/error) | error | error | +| SendSignal | error | SendSignal | SendSignal (escalation) | error | +| ResizePty | error | ResizePty | error | error | +| CloseStdin | error | CloseStdin (+ clear buffer) | CloseStdin (+ set flag) | error | +| CloseRequested | set deferred flag | → Stopping (+ SendSignal Terminate [+ ScheduleKillTimeout]) | no-op | error | +| KillTimeout | silent | silent | SendSignal Kill | silent | +| Subscribe | add subscriber | add subscriber | add subscriber | add subscriber | +| Unsubscribe | remove subscriber | remove subscriber | remove subscriber | remove subscriber | +| StdinWritten | update flow | update flow + drain buffer | update flow | update flow | +| SignalSent | silent | silent | silent | silent | +| PtyResized | silent | silent | silent | silent | + +**Special behaviors:** +- **Close-before-start:** If `CloseRequested` arrives in `Starting`, a flag is set. When `Started` arrives, the session transitions through Running straight to Stopping and emits `SendSignal(Terminate)` (plus `ScheduleKillTimeout` if configured). +- **Kill timeout:** When `spec.kill_timeout` is `Some(duration)`, entering `Stopping` emits `ScheduleKillTimeout { duration }` alongside `SendSignal(Terminate)`. If the process hasn't exited when the timeout fires, the `KillTimeout` event triggers `SendSignal(Kill)`. `KillTimeout` in non-Stopping states is silently consumed (harmless late arrival after the process already exited). +- **Backpressure:** When `spec.stdin_buffer_limit` is `Some(limit)` and `pending_stdin_bytes >= limit`, `WriteStdin` events are buffered in a `VecDeque` instead of emitting actions. When `StdinWritten` acks reduce `pending_stdin_bytes` below the limit, buffered writes drain in FIFO order. The buffer is cleared on `CloseRequested`, `CloseStdin`, `ConnectionLost`, and `Exited`. When `stdin_buffer_limit` is `None`, all writes pass through immediately (original behavior). +- **FlowControl:** `pending_stdin_bytes` is incremented on `WriteStdin` emission, decremented on `StdinWritten` receipt (saturating). +- **Stdin closed:** Once `CloseStdin` is applied, further `WriteStdin` events produce `InvalidState` errors. Duplicate `CloseStdin` is a no-op. Closing stdin also clears any buffered writes. +- **Late acks in Exited:** `StdinWritten`, `SignalSent`, `PtyResized`, and `KillTimeout` are silently consumed in all states (including Exited) — they never produce errors. + +### Types + +**ProcessSpec** — describes how to spawn a process: +- `command: String`, `args: Vec`, `env: HashMap` +- `working_dir: Option`, `mode: ProcessMode`, `initial_pty_size: Option` +- `kill_timeout: Option` — escalate SIGTERM → SIGKILL after this duration (None = no escalation) +- `stdin_buffer_limit: Option` — buffer stdin writes when pending bytes exceed limit (None = unlimited) + +**ProcessMode** — `Interactive` | `Automated` (Copy) + +**ExitStatus** — `Code(i32)` | `Signal(i32)` | `Unknown` (Copy) + +**Signal** — `Terminate` | `Kill` | `Hangup` | `Interrupt` | `Other(i32)` (Copy) + +**ProcessError** — `SpawnFailed { reason }` | `ConnectionLost { reason }` | `InvalidState { attempted, current_state }` + +**OutputStream** — `Stdout` | `Stderr` (Copy) + +**SubscriberSet** — deduplicated `Vec` with linear-scan dedup. Methods: `add()`, `remove()`, `snapshot()`, `count()`. + +### Layer 2 — ProcessDriver Trait + MockDriver + +```rust +pub trait ProcessDriver: Send { + fn execute(&mut self, action: ProcessAction); + fn poll(&mut self) -> Vec; +} +``` + +**MockDriver** — test-oriented implementation: +- `inject(event)` / `inject_many(events)` — queue events for `poll()` +- `executed_actions()` — view recorded actions +- `take_executed_actions()` — take + clear recorded actions +- `pending_event_count()` — number of queued events +- `poll()` drains all pending events, `execute()` records actions + +--- + +## Implemented: Layers 3 + 4 (Actor Integration + Local OS Processes) + +### ExternalSender (swactor core primitive) + +A `Clone + Send + Sync` handle for injecting messages into actor mailboxes from any thread. Lives in the `swactor` crate (because `Envelope` and `AddressMap` are `pub(crate)`). + +```rust +// Create from a runtime +let sender = runtime.create_sender(); + +// Use from any thread (including I/O background threads) +sender.send_to(actor_addr, MyMessage { ... })?; +``` + +**Implementation:** Clones of the runtime's `Arc`, per-worker `Sender` channels, and `Arc>>` for worker thread unparking. The `send_to` method looks up the actor's worker, pushes an envelope, and unparks the worker thread. + +**Changes to swactor core:** +- `src/channel.rs` — Added `Clone` for `Sender` (clones the inner `Arc`) +- `src/runtime.rs` — Changed `worker_threads` from `Vec>` to `Arc>>`, added `ExternalSender` struct and `Runtime::create_sender()` factory + +### Layer 3 — Process Actor + +**`ProcessActor`** — generic actor implementing `ActorInterface` with `Incoming = ProcessCommand`. + +**Message types:** + +```rust +pub enum ProcessCommand { + WriteStdin { data: Vec }, + SendSignal { signal: Signal }, + ResizePty { size: PtySize }, + CloseStdin, + Close, + Subscribe { address: ActorAddress }, + Unsubscribe { address: ActorAddress }, + PollTick, // internal: sent by waker from I/O threads +} + +pub enum ProcessNotification { + Started { process: ActorAddress }, + Output { process: ActorAddress, data: Vec, stream: OutputStream }, + Exited { process: ActorAddress, status: ExitStatus }, + Error { process: ActorAddress, error: ProcessError }, +} +``` + +**Handle ordering:** Commands are processed first, then I/O events are drained. This ensures `Subscribe` registers the subscriber before `Started` (or other buffered events) get dispatched. `PollTick` has no command effect — it just triggers the drain. + +**Event queue (`EventQueue`):** Thin wrapper around `Arc>`. I/O threads push events; `driver.poll()` drains them. + +**Waker (`ProcessWaker`):** `Arc` — constructed with a closure that sends `PollTick` via `ExternalSender`. I/O threads call `waker.wake()` after pushing events. + +**Factory functions:** + +```rust +// Spawn with real OS subprocess +let addr = spawn_local_process(ctx, &sender, spec)?; + +// Spawn with custom driver (for testing) +let addr = spawn_process(ctx, &sender, spec, driver, waker_slot)?; +``` + +The factory creates the driver, session, and actor, spawns it, then fills the waker slot with a closure that sends `PollTick` to the actor's address. + +### Layer 4 — LocalDriver + +Real OS process management via `std::process::Command` with piped I/O. + +**Components:** + +| File | Purpose | +|------|---------| +| `local/mod.rs` | `LocalDriver` struct, `ProcessDriver` impl, process spawning | +| `local/pipes.rs` | Background thread reading stdout/stderr pipes (8KB buffer) | +| `local/signal.rs` | `Signal` → libc constant mapping, `kill()` wrapper | +| `local/wait.rs` | Background `waitpid()` thread with WIFEXITED/WIFSIGNALED decoding | + +**Thread structure per process:** +- 1 stdout reader thread +- 1 stderr reader thread +- 1 waitpid thread + +Each thread pushes events to the shared `EventQueue` and calls `waker.wake()`. + +**Drop behavior:** Closes stdin, kills the process, waits for exit. + +**PTY support:** Not yet implemented — `ResizePty` is a no-op that returns a `PtyResized` ack. Pipe-based I/O only in this phase. + +--- + +## File Structure + +``` +swactor (root crate): + src/ + channel.rs — + Clone for Sender + runtime.rs — + ExternalSender, create_sender(), Arc + +crates/process/ (swactor-process): + Cargo.toml — + crossbeam-queue, libc deps + src/ + lib.rs — module declarations + re-exports + types.rs — ProcessSpec, ProcessMode, ExitStatus, Signal, PtySize, etc. + event.rs — ProcessEvent enum + action.rs — ProcessAction enum + OutputStream + subscriber.rs — SubscriberSet + session.rs — ProcessSession state machine + driver.rs — ProcessDriver trait + mock.rs — MockDriver + queue.rs — EventQueue (Arc) + waker.rs — ProcessWaker (Arc) + message.rs — ProcessCommand, ProcessNotification + actor.rs — ProcessActor impl ActorInterface + spawn.rs — spawn_local_process(), spawn_process() factory functions + local/ + mod.rs — LocalDriver struct + ProcessDriver impl + pipes.rs — Pipe reader background threads + signal.rs — OS signal delivery + wait.rs — waitpid background thread + tests/ + session_scenarios.rs — 26 session state machine scenario tests + proptest_session.rs — 5 property-based session tests (KillTimeout included in arb_event) + actor_scenarios.rs — 6 actor integration tests (TestDriver) + local_driver.rs — 6 LocalDriver integration tests (real processes) + e2e_process.rs — 2 end-to-end tests (Runtime + LocalDriver + real processes) +``` + +--- + +## Test Coverage + +### Layers 1 + 2 — Session + MockDriver (31 tests) + +**Scenario tests** (26 tests in `tests/session_scenarios.rs`): +1. Happy path automated: new → Started → OutputReceived×N → Exited(0) +2. Interactive session with subscriber lifecycle (add/remove, verify notification membership) +3. Spawn failure → error notification + SelfTerminate +4. Connection loss mid-run → Exited with Unknown status +5. Close before start → deferred SIGTERM on belated start +6. Invalid event in Starting → NotifyError (no panic) +7. Invalid event in Exited → NotifyError (no panic) +8. Stdin closed then write → NotifyError +9. MockDriver round-trip (driver + session in simulated tick loop) +10. Signal escalation in Stopping (Kill after Terminate) +11. Late acks in Exited silently consumed +12. Flow control tracks pending stdin bytes (including saturating subtract) +13. CloseStdin allowed in Stopping +14. Connection loss in Stopping → Exited +15. Duplicate CloseRequested in Stopping → no-op +16. CloseRequested with kill_timeout emits both SendSignal{Terminate} and ScheduleKillTimeout +17. Close-before-start with kill_timeout schedules timer on belated start +18. KillTimeout in Stopping → SendSignal{Kill}, state stays Stopping +19. KillTimeout silently consumed in Starting, Running, Exited +20. CloseRequested without kill_timeout emits no ScheduleKillTimeout +21. Full escalation flow: CloseRequested → KillTimeout → Exited{Signal(9)} +22. Backpressure buffers writes when pending bytes exceed limit +23. StdinWritten ack drains buffered chunks in FIFO order +24. CloseRequested clears stdin buffer +25. No backpressure when limit is None (all writes pass through) +26. Exited clears stdin buffer + +**Property-based tests** (5 tests in `tests/proptest_session.rs`): +1. No panics for arbitrary event sequences (up to 50 events, including KillTimeout) +2. Exited is terminal (state never leaves Exited) +3. SelfTerminate always last action when entering Exited +4. Subscriber count matches add/remove operations +5. State monotonicity (state ordinal never decreases) + +### Layer 3 — Actor Integration (6 tests) + +Tests in `tests/actor_scenarios.rs` using a `TestDriver` (shared `EventQueue` + recorded actions): + +1. **Happy path** — spawn → Started → Output → Exited → subscriber gets all notifications → actor stops +2. **PollTick drains queued events** — three events buffered, single PollTick delivers all three notifications +3. **Close triggers graceful shutdown** — Close command produces SIGTERM via driver +4. **WriteStdin/SendSignal forwarded** — commands reach the driver as actions +5. **Spawn failure** — error notification sent to subscriber, actor self-terminates +6. **Subscribe/Unsubscribe routing** — two subscribers, unsubscribe one, only remaining gets subsequent notifications + +### Layer 4 — LocalDriver Integration (6 tests) + +Tests in `tests/local_driver.rs` using real OS processes, no actor layer: + +1. **`echo hello`** — Started + OutputReceived("hello\n") + Exited(0) +2. **`cat` stdin echo** — write "ping\n" → read "ping\n" back → close stdin → Exited(0) +3. **`sleep 60` + SIGTERM** — Started → send Terminate → Exited(Signal) +4. **Bad command** → SpawnFailed +5. **`seq 1 10000`** — large output integrity (no data loss, correct start/end) +6. **Kill timeout escalation** — spawn SIGTERM-ignoring process, ScheduleKillTimeout fires KillTimeout, SIGKILL terminates it + +### End-to-End (2 tests) + +Tests in `tests/e2e_process.rs` — full stack (Runtime + ExternalSender + ProcessActor + LocalDriver + real process): + +1. **`echo hello` lifecycle** — spawn, subscribe, verify Started → Output("hello") → Exited(0) in order +2. **Bad command** — spawn nonexistent binary, verify Error notification arrives + +--- + +## Design Decisions Made + +1. **ExternalSender over WorkerExtension:** The I/O → actor bridge is a general-purpose swactor core primitive, not process-specific. Any crate can use `ExternalSender` to inject messages from background threads. + +2. **Handle ordering (command first, then drain):** Processing the incoming command before draining I/O events ensures that `Subscribe` registers the subscriber before buffered events (like `Started`) are dispatched. This avoids a race where early lifecycle events are sent to an empty subscriber list. + +3. **ProcessActor is generic over `D: ProcessDriver`:** Enables testing with `TestDriver` while production uses `LocalDriver`. No trait object overhead. + +4. **Thread-per-pipe model:** Each LocalDriver spawns 3 threads (stdout reader, stderr reader, waitpid). Simple, debuggable, correct for Phase 1 (tens of processes). + +5. **EventQueue is lock-free:** Uses `crossbeam_queue::SegQueue` — no contention between I/O writer threads and the actor's poll draining. + +6. **Waker uses OnceLock:** The waker slot (`Arc>`) is filled after the actor address is known. I/O threads that call `waker.get()` before it's set simply skip the wake — events accumulate in the EventQueue and are drained on the next message. + +--- + +## Next Steps + +### Near-term + +1. **PTY support for Interactive mode** — The `LocalDriver` currently uses pipes only. Interactive mode needs PTY allocation (via raw libc: `openpty()` → `fork()` → `setsid()` + `ioctl(TIOCSCTTY)` + `dup2` + `execvp`), `SIGWINCH` for resize, and merged stdout/stderr on a single PTY master FD. The `ResizePty` action is already wired through as a no-op. + +2. **Output buffering policies** — Subscribers currently receive every raw byte chunk. Add optional line-buffering or size-buffering in the session layer for consumers that want complete lines. + +### Layer 5 — SshDriver + +SSH-based process management. Same `ProcessDriver` trait, different backend. + +**Open decisions:** +- **SSH library:** `russh` (pure Rust, async — needs tokio bridge) vs. `ssh2` (libssh2 bindings, synchronous — fits the thread model naturally) +- **Authentication:** Password, key file, agent forwarding, or pluggable credential provider +- **Connection multiplexing:** One SSH connection per process actor, or connection pool with multiple channels +- **Health monitoring:** Heartbeat/keepalive to detect connection drops → `ConnectionLost` events + +### Scaling Path + +The architecture isolates scaling concerns in the driver layer: + +- **Phase 1 (tens):** Each driver spawns OS threads for I/O. Simple, debuggable. ← **current** +- **Phase 2 (hundreds):** Shared thread pool for driver I/O. Replace per-process threads with a pool that multiplexes reads across processes. +- **Phase 3 (thousands):** Async internals (tokio tasks for I/O). State machine and actor layers unchanged — only `ProcessDriver` implementations change. + +--- + +## Alternative Approaches Considered + +### WorkerExtension Approach + +Managing processes as a per-worker extension (like TimerWheel). Rejected because: +- Ties processes to specific workers, complicating supervision +- Processes can't benefit from the actor model's naming, grouping, and monitoring +- The API would be less intuitive than "send a message to the process" +- Tick-bound latency is problematic for interactive use + +### Pure Bridge Actor Approach + +A single centralized bridge actor owning all processes (like IrohDriver). Rejected as the primary design because: +- Doesn't give individual processes actor identity — can't supervise, name, or monitor them independently +- Centralizes failure — the bridge dying kills all processes +- However, this pattern does appear inside the recommended approach: the driver layer within each process actor is essentially a tiny bridge + +### Pure Process-as-Actor (without state machine) + +Just actors with embedded I/O logic, no state machine separation. Rejected because: +- Untestable without real processes or SSH connections +- Can't simulate +- Backend-specific logic (SSH vs. local) interleaved with lifecycle logic diff --git a/src/channel.rs b/src/channel.rs index 38f6c3d..3d2a1a1 100644 --- a/src/channel.rs +++ b/src/channel.rs @@ -55,6 +55,14 @@ pub(crate) struct Sender { queue: Arc>, } +impl Clone for Sender { + fn clone(&self) -> Self { + Self { + queue: self.queue.clone(), + } + } +} + impl Sender { pub fn send(&self, value: T) { self.queue.push(value) diff --git a/src/runtime.rs b/src/runtime.rs index 534afa3..069388c 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -110,7 +110,7 @@ pub struct Runtime { /// Workers available for tick(). run() drains this and moves workers to threads. tick_workers: RefCell>, /// Thread handles for waking parked workers. Set by workers on startup via OnceLock. - worker_threads: Vec>, + worker_threads: Arc>>, created_at: Instant, #[cfg(feature = "transport")] codec_registry: Option>, @@ -146,6 +146,50 @@ impl RuntimeAddress { } } +/// A cloneable, `Send + Sync` handle for injecting messages into actor mailboxes +/// from any thread — including non-actor I/O threads. +/// +/// Created via [`Runtime::create_sender`]. The primary use case is bridging +/// background I/O (e.g., pipe readers, network listeners) with the tick-based +/// actor system. +pub struct ExternalSender { + address_map: Arc, + transfer_txs: Vec>, + worker_threads: Arc>>, +} + +impl Clone for ExternalSender { + fn clone(&self) -> Self { + Self { + address_map: self.address_map.clone(), + transfer_txs: self.transfer_txs.clone(), + worker_threads: self.worker_threads.clone(), + } + } +} + +// Safety: All fields are Send+Sync (Arc uses RwLock, +// Sender wraps Arc, Thread is Send+Sync). +unsafe impl Send for ExternalSender {} +unsafe impl Sync for ExternalSender {} + +impl ExternalSender { + /// Send a typed message to an actor address, waking the owning worker thread. + /// + /// Returns `Err` if the address is not found in the runtime's address map. + pub fn send_to(&self, addr: ActorAddress, msg: M) -> Result<(), Error> { + match self.address_map.lookup(&addr) { + Some(wid) => { + self.transfer_txs[wid.as_usize()] + .send(Envelope::new(addr, Box::new(msg))); + notify_worker(&self.worker_threads, wid.as_usize()); + Ok(()) + } + None => Err(Error::from("Address not found")), + } + } +} + impl Runtime { /// Builds a new `Runtime` struct, but does not yet run anything. If multithreaded, call /// `run()`, if single threaded, needs to be driven by calls to the `tick()` method. @@ -188,8 +232,8 @@ impl Runtime { let placement = Placement::new(num_workers, worker_stats.clone()); - let worker_threads: Vec> = - (0..num_workers).map(|_| OnceLock::new()).collect(); + let worker_threads: Arc>> = + Arc::new((0..num_workers).map(|_| OnceLock::new()).collect()); let rt = Self { config, @@ -317,6 +361,18 @@ impl Runtime { }) } + /// Create an [`ExternalSender`] handle for injecting messages from any thread. + /// + /// The returned handle is `Clone + Send + Sync` and can be moved into + /// background I/O threads to bridge external events into the actor system. + pub fn create_sender(&self) -> ExternalSender { + ExternalSender { + address_map: self.address_map.clone(), + transfer_txs: self.transfer_txs.iter().map(|tx| tx.clone()).collect(), + worker_threads: self.worker_threads.clone(), + } + } + fn make_tick_context(&self) -> TickContext<'_> { TickContext { address_map: &self.address_map, @@ -438,7 +494,7 @@ impl Runtime { self.is_running.store(false, Ordering::Release); // Wake all parked workers so they see the shutdown flag immediately - for thread in &self.worker_threads { + for thread in self.worker_threads.iter() { if let Some(t) = thread.get() { t.unpark(); }