diff --git a/Cargo.lock b/Cargo.lock index ba61062..7769c85 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -168,12 +168,12 @@ checksum = "b21b63ab5a0db0369deb913540af2892750e42d949faacc7a61495ac418a1692" dependencies = [ "async-io", "blocking", - "cfg-if", + "cfg-if 1.0.0", "event-listener", "futures-lite", "libc", "once_cell", - "signal-hook", + "signal-hook 0.3.10", "winapi", ] @@ -218,6 +218,15 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + [[package]] name = "blocking" version = "1.0.2" @@ -284,6 +293,12 @@ version = "1.0.71" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79c2681d6594606957bbb8631c4b90a7fcaaa72cdb714743a437b156d6a7eedd" +[[package]] +name = "cfg-if" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" + [[package]] name = "cfg-if" version = "1.0.0" @@ -296,7 +311,7 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01b72a433d0cf2aef113ba70f62634c56fddb0f244e6377185c56a7cadbd8f91" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "cipher", "cpufeatures", "zeroize", @@ -409,7 +424,7 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d82cfc11ce7f2c3faef78d8a684447b40d503d9681acebed6cb728d45940c4db" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "lazy_static", ] @@ -431,16 +446,46 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + +[[package]] +name = "dirs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13aea89a5c93364a98e9b37b2fa237effbb694d5cfe01c5b70941f7eb087d5e3" +dependencies = [ + "cfg-if 0.1.10", + "dirs-sys", +] + [[package]] name = "dirs-next" version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "dirs-sys-next", ] +[[package]] +name = "dirs-sys" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03d86534ed367a67548dc68113a0f5db55432fdfbb6e6f9d77704397d95d5780" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + [[package]] name = "dirs-sys-next" version = "0.1.2" @@ -454,7 +499,7 @@ dependencies = [ [[package]] name = "distant" -version = "0.15.1" +version = "0.16.0" dependencies = [ "assert_cmd", "assert_fs", @@ -467,19 +512,21 @@ dependencies = [ "log", "once_cell", "predicates", - "rand", + "rand 0.8.4", "rstest", "serde", "serde_json", "structopt", "strum", + "terminal_size", + "termwiz", "tokio", "whoami", ] [[package]] name = "distant-core" -version = "0.15.1" +version = "0.16.0" dependencies = [ "assert_fs", "bytes", @@ -491,9 +538,9 @@ dependencies = [ "indoc", "log", "once_cell", - "portable-pty 0.7.0", + "portable-pty", "predicates", - "rand", + "rand 0.8.4", "serde", "serde_json", "structopt", @@ -505,7 +552,7 @@ dependencies = [ [[package]] name = "distant-lua" -version = "0.15.1" +version = "0.16.0" dependencies = [ "distant-core", "distant-ssh2", @@ -539,7 +586,7 @@ dependencies = [ [[package]] name = "distant-ssh2" -version = "0.15.1" +version = "0.16.0" dependencies = [ "assert_cmd", "assert_fs", @@ -551,7 +598,7 @@ dependencies = [ "log", "once_cell", "predicates", - "rand", + "rand 0.8.4", "rpassword", "rstest", "serde", @@ -797,15 +844,26 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if 1.0.0", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + [[package]] name = "getrandom" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fcd999463524c52659517fe2cea98493cfe485d10565e7b0fb07dbba7ad2753" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "libc", - "wasi", + "wasi 0.10.2+wasi-snapshot-preview1", ] [[package]] @@ -901,7 +959,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "716d3d89f35ac6a34fd0eed635395f4c3b76fa889338a4632e5231a8684216bd" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", ] [[package]] @@ -949,6 +1007,29 @@ version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd8f7255a17a627354f321ef0055d63b898c6fb27eff628af4d1b66b7331edf6" +[[package]] +name = "libssh-rs" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a24b7857c3bd6f52e22f1ca5445fad27f65719126de518abec5c1648f232e42" +dependencies = [ + "bitflags", + "libssh-rs-sys", + "thiserror", +] + +[[package]] +name = "libssh-rs-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d592a55d4efe34f3e437e3f74e32b6d60d54aa3270fe2925840173c7d8648a42" +dependencies = [ + "cc", + "libz-sys", + "openssl-sys", + "pkg-config", +] + [[package]] name = "libssh2-sys" version = "0.2.23" @@ -990,7 +1071,7 @@ version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", ] [[package]] @@ -1004,9 +1085,9 @@ dependencies = [ [[package]] name = "luajit-src" -version = "210.2.0+resty5f13855" +version = "210.3.2+resty1085a4d" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49f85722ea9e022305a077b916c9271011a195ee8dc9b2b764fc78b0378e3b72" +checksum = "b1e27456f513225a9edd22fc0a5f526323f6adb3099c4de87a84ceb842d93ba4" dependencies = [ "cc", ] @@ -1017,6 +1098,12 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a" +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + [[package]] name = "mio" version = "0.7.13" @@ -1041,9 +1128,9 @@ dependencies = [ [[package]] name = "mlua" -version = "0.6.6" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4235d7e740d73d7429df6f176c81b248f05c39d67264d45a7d8cecb67c227f6f" +checksum = "7d4c93ad12064932ae8f0667ecd09ca714ff44813fa1d1965ae4279108b67f21" dependencies = [ "bstr 0.2.17", "cc", @@ -1057,6 +1144,7 @@ dependencies = [ "num-traits", "once_cell", "pkg-config", + "rustc-hash", "serde", ] @@ -1075,6 +1163,16 @@ dependencies = [ "syn", ] +[[package]] +name = "nom" +version = "5.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffb4262d26ed83a1c0a33a38fe2bb15797329c85770da05e6b828ddb782627af" +dependencies = [ + "memchr", + "version_check", +] + [[package]] name = "normalize-line-endings" version = "0.3.0" @@ -1090,6 +1188,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-derive" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "876a53fff98e03a936a674b29568b0e605f06b29372c2489ff4de23f1949743d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "num-integer" version = "0.1.44" @@ -1139,18 +1248,18 @@ checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" [[package]] name = "openssl-src" -version = "111.16.0+1.1.1l" +version = "300.0.4+3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ab2173f69416cf3ec12debb5823d244127d23a9b127d5a5189aa97c5fa2859f" +checksum = "216e1c6b4549e24182b9d7aa268f645414888a69daf44c7b2d8118da8e7b23e7" dependencies = [ "cc", ] [[package]] name = "openssl-sys" -version = "0.9.67" +version = "0.9.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69df2d8dfc6ce3aaf44b40dec6f487d5a886516cf6879c49e98e0710f310a058" +checksum = "7df13d165e607909b363a4757a6f133f8a818a74e9d3a98d09c6128e15fa4c73" dependencies = [ "autocfg", "cc", @@ -1160,6 +1269,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "ordered-float" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7940cf2ca942593318d07fcf2596cdca60a85c9e7fab408a5e21a4f9dcd40d87" +dependencies = [ + "num-traits", +] + [[package]] name = "parking" version = "2.0.0" @@ -1183,7 +1301,7 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d76e8e1493bcac0d2766c42737f34458f1c8c50c0d23bcb24ea953affb273216" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "instant", "libc", "redox_syscall", @@ -1197,6 +1315,53 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acbf547ad0c65e31259204bd90935776d1c693cec2f4ff7abb7a1bbbd40dfe58" +[[package]] +name = "pest" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10f4872ae94d7b90ae48754df22fd42ad52ce740b8f370b03da4835417403e53" +dependencies = [ + "ucd-trie", +] + +[[package]] +name = "phf" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" +dependencies = [ + "phf_shared", + "rand 0.7.3", +] + +[[package]] +name = "phf_shared" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.7" @@ -1221,7 +1386,7 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92341d779fa34ea8437ef4d82d440d5e1ce3f3ff7f824aa64424cd481f9a1f25" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "libc", "log", "wepoll-ffi", @@ -1239,24 +1404,6 @@ dependencies = [ "universal-hash", ] -[[package]] -name = "portable-pty" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b8383c3934bd6da733223ad1b22f8102c46e8bbced07800b2346cc34326ff83" -dependencies = [ - "anyhow", - "bitflags", - "filedescriptor", - "lazy_static", - "libc", - "log", - "serial", - "shared_library", - "shell-words", - "winapi", -] - [[package]] name = "portable-pty" version = "0.7.0" @@ -1365,6 +1512,20 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc 0.2.0", + "rand_pcg", +] + [[package]] name = "rand" version = "0.8.4" @@ -1372,9 +1533,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e7573632e6454cf6b99d7aac4ccca54be06da05aca2ef7423d22d27d4d4bcd8" dependencies = [ "libc", - "rand_chacha", - "rand_core", - "rand_hc", + "rand_chacha 0.3.1", + "rand_core 0.6.3", + "rand_hc 0.3.1", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", ] [[package]] @@ -1384,7 +1555,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.3", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", ] [[package]] @@ -1393,7 +1573,16 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7" dependencies = [ - "getrandom", + "getrandom 0.2.3", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", ] [[package]] @@ -1402,7 +1591,16 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d51e9f596de227fda2ea6c84607f5558e196eeaf43c986b724ba4fb8fdf497e7" dependencies = [ - "rand_core", + "rand_core 0.6.3", +] + +[[package]] +name = "rand_pcg" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +dependencies = [ + "rand_core 0.5.1", ] [[package]] @@ -1420,7 +1618,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "528532f3d801c87aec9def2add9ca802fe569e44a544afe633765267840abe64" dependencies = [ - "getrandom", + "getrandom 0.2.3", "redox_syscall", ] @@ -1472,20 +1670,26 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2288c66aeafe3b2ed227c981f364f9968fa952ef0b30e84ada4486e7ee24d00a" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "proc-macro2", "quote", "rustc_version", "syn", ] +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc_version" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" dependencies = [ - "semver", + "semver 1.0.4", ] [[package]] @@ -1515,12 +1719,30 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" +[[package]] +name = "semver" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f301af10236f6df4160f7c3f04eec6dbc70ace82d23326abad5edee88801c6b6" +dependencies = [ + "semver-parser", +] + [[package]] name = "semver" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "568a8e6258aa33c13358f81fd834adb854c6f7c9468520910a9b1e8fac068012" +[[package]] +name = "semver-parser" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0bef5b7f9e0df16536d3961cfb6e84331c065b4066afb39768d0e319411f7" +dependencies = [ + "pest", +] + [[package]] name = "serde" version = "1.0.130" @@ -1581,7 +1803,7 @@ dependencies = [ "ioctl-rs", "libc", "serial-core", - "termios", + "termios 0.2.2", ] [[package]] @@ -1594,6 +1816,19 @@ dependencies = [ "serial-core", ] +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer", + "cfg-if 1.0.0", + "cpufeatures", + "digest", + "opaque-debug", +] + [[package]] name = "shared_library" version = "0.1.9" @@ -1610,6 +1845,16 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fa3938c99da4914afedd13bf3d79bcb6c277d1b2c398d23257a304d9e1b074" +[[package]] +name = "signal-hook" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e31d442c16f047a671b5a71e2161d6e68814012b7f5379d269ebd915fac2729" +dependencies = [ + "libc", + "signal-hook-registry", +] + [[package]] name = "signal-hook" version = "0.3.10" @@ -1640,6 +1885,12 @@ dependencies = [ "termcolor", ] +[[package]] +name = "siphasher" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a86232ab60fa71287d7f2ddae4a7073f6b7aac33631c3015abb556f08c6d0a3e" + [[package]] name = "slab" version = "0.4.4" @@ -1766,9 +2017,9 @@ version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dac1c663cfc93810f88aed9b8941d48cabf856a1b111c29a40439018d870eb22" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "libc", - "rand", + "rand 0.8.4", "redox_syscall", "remove_dir_all", "winapi", @@ -1783,6 +2034,29 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "terminal_size" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633c1a546cee861a1a6d0dc69ebeca693bf4296661ba7852b9d21d159e0506df" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "terminfo" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76971977e6121664ec1b960d1313aacfa75642adc93b9d4d53b247bd4cb1747e" +dependencies = [ + "dirs", + "fnv", + "nom", + "phf", + "phf_codegen", +] + [[package]] name = "termios" version = "0.2.2" @@ -1792,12 +2066,53 @@ dependencies = [ "libc", ] +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + [[package]] name = "termtree" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78fbf2dd23e79c28ccfa2472d3e6b3b189866ffef1aeb91f17c2d968b6586378" +[[package]] +name = "termwiz" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31ef6892cc0348a9b3b8c377addba91e0f6365863d92354bf27559dca81ee8c5" +dependencies = [ + "anyhow", + "base64", + "bitflags", + "cfg-if 1.0.0", + "filedescriptor", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "num-derive", + "num-traits", + "ordered-float", + "regex", + "semver 0.11.0", + "sha2", + "signal-hook 0.1.17", + "terminfo", + "termios 0.3.3", + "thiserror", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "winapi", +] + [[package]] name = "textwrap" version = "0.11.0" @@ -1897,6 +2212,12 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b63708a265f51345575b27fe43f9500ad611579e764c79edbc2037b1121959ec" +[[package]] +name = "ucd-trie" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56dee185309b50d1f11bfedef0fe6d036842e3fb77413abef29f8f8d1c5d4c1c" + [[package]] name = "unicode-segmentation" version = "1.8.0" @@ -1931,6 +2252,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "utf8parse" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "936e4b492acfd135421d8dca4b1aa80a7bfc26e702ef3af710e0752684df5372" + [[package]] name = "vcpkg" version = "0.2.15" @@ -1949,6 +2276,15 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe" +[[package]] +name = "vtparse" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f41c9314c4dde1f43dd0c46c67bb5ae73850ce11eebaf7d8b912e178bda5401" +dependencies = [ + "utf8parse", +] + [[package]] name = "wait-timeout" version = "0.2.0" @@ -1975,6 +2311,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + [[package]] name = "wasi" version = "0.10.2+wasi-snapshot-preview1" @@ -1987,7 +2329,7 @@ version = "0.2.78" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "632f73e236b219150ea279196e54e610f5dbafa5d61786303d4da54f84e47fce" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "wasm-bindgen-macro", ] @@ -2056,9 +2398,9 @@ dependencies = [ [[package]] name = "wezterm-ssh" -version = "0.2.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e233b00aaa22b6ef9b573534e529e9672da8662a8355c37acacedd88741ce1ec" +checksum = "21b5be360161357d5504d2b773d51fb23c4f9e92507720ef51821444d60cb5bb" dependencies = [ "anyhow", "base64", @@ -2067,8 +2409,10 @@ dependencies = [ "dirs-next", "filedescriptor", "filenamegen", + "libc", + "libssh-rs", "log", - "portable-pty 0.5.0", + "portable-pty", "regex", "smol", "ssh2", diff --git a/Cargo.toml b/Cargo.toml index 3c7a7ff..43f6400 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ name = "distant" description = "Operate on a remote computer through file and process manipulation" categories = ["command-line-utilities"] keywords = ["cli"] -version = "0.15.1" +version = "0.16.0" authors = ["Chip Senkbeil "] edition = "2018" homepage = "https://github.com/chipsenkbeil/distant" @@ -25,20 +25,22 @@ ssh2 = ["distant-ssh2"] [dependencies] derive_more = { version = "0.99.16", default-features = false, features = ["display", "from", "error", "is_variant"] } -distant-core = { version = "=0.15.1", path = "distant-core", features = ["structopt"] } +distant-core = { version = "=0.16.0", path = "distant-core", features = ["structopt"] } flexi_logger = "0.18.0" log = "0.4.14" once_cell = "1.8.0" rand = { version = "0.8.4", features = ["getrandom"] } -tokio = { version = "1.12.0", features = ["full"] } serde = { version = "1.0.126", features = ["derive"] } serde_json = "1.0.64" structopt = "0.3.22" strum = { version = "0.21.0", features = ["derive"] } +tokio = { version = "1.12.0", features = ["full"] } +terminal_size = "0.1.17" +termwiz = "0.15.0" whoami = "1.1.2" # Optional native SSH functionality -distant-ssh2 = { version = "=0.15.1", path = "distant-ssh2", features = ["serde"], optional = true } +distant-ssh2 = { version = "=0.16.0", path = "distant-ssh2", features = ["serde"], optional = true } [target.'cfg(unix)'.dependencies] fork = "0.1.18" diff --git a/distant-core/Cargo.toml b/distant-core/Cargo.toml index bc1c26f..de56985 100644 --- a/distant-core/Cargo.toml +++ b/distant-core/Cargo.toml @@ -3,7 +3,7 @@ name = "distant-core" description = "Core library for distant, enabling operation on a remote computer through file and process manipulation" categories = ["network-programming"] keywords = ["api", "async"] -version = "0.15.1" +version = "0.16.0" authors = ["Chip Senkbeil "] edition = "2018" homepage = "https://github.com/chipsenkbeil/distant" diff --git a/distant-core/src/client/process.rs b/distant-core/src/client/process.rs index a94dff1..7fc6b36 100644 --- a/distant-core/src/client/process.rs +++ b/distant-core/src/client/process.rs @@ -61,8 +61,11 @@ pub struct RemoteProcess { /// Receiver for stderr pub stderr: Option, + /// Sender for resize events + resizer: RemoteProcessResizer, + /// Sender for kill events - kill: mpsc::Sender<()>, + killer: RemoteProcessKiller, /// Task that waits for the process to complete wait_task: JoinHandle<()>, @@ -134,6 +137,7 @@ impl RemoteProcess { let (stdin_tx, stdin_rx) = mpsc::channel(CLIENT_PIPE_CAPACITY); let (stdout_tx, stdout_rx) = mpsc::channel(CLIENT_PIPE_CAPACITY); let (stderr_tx, stderr_rx) = mpsc::channel(CLIENT_PIPE_CAPACITY); + let (resize_tx, resize_rx) = mpsc::channel(1); // Used to terminate request task, either explicitly by the process or internally // by the response task when it terminates @@ -161,7 +165,7 @@ impl RemoteProcess { _ = abort_req_task_rx.recv() => { panic!("killed"); } - res = process_outgoing_requests(tenant, id, channel, stdin_rx, kill_rx) => { + res = process_outgoing_requests(tenant, id, channel, stdin_rx, resize_rx, kill_rx) => { res } } @@ -185,7 +189,8 @@ impl RemoteProcess { stdin: Some(RemoteStdin(stdin_tx)), stdout: Some(RemoteStdout(stdout_rx)), stderr: Some(RemoteStderr(stderr_rx)), - kill: kill_tx, + resizer: RemoteProcessResizer(resize_tx), + killer: RemoteProcessKiller(kill_tx), wait_task, status, }) @@ -225,6 +230,26 @@ impl RemoteProcess { .unwrap_or(Err(RemoteProcessError::UnexpectedEof)) } + /// Resizes the pty of the remote process if it is attached to one + pub async fn resize(&self, size: PtySize) -> Result<(), RemoteProcessError> { + self.resizer.resize(size).await + } + + /// Clones a copy of the remote process pty resizer + pub fn clone_resizer(&self) -> RemoteProcessResizer { + self.resizer.clone() + } + + /// Submits a kill request for the running process + pub async fn kill(&mut self) -> Result<(), RemoteProcessError> { + self.killer.kill().await + } + + /// Clones a copy of the remote process killer + pub fn clone_killer(&self) -> RemoteProcessKiller { + self.killer.clone() + } + /// Aborts the process by forcing its response task to shutdown, which means that a call /// to `wait` will return an error. Note that this does **not** send a kill request, so if /// you want to be nice you should send the request before aborting. @@ -232,10 +257,31 @@ impl RemoteProcess { let _ = self.abort_req_task_tx.try_send(()); let _ = self.abort_res_task_tx.try_send(()); } +} + +/// A handle to the channel to kill a remote process +#[derive(Clone, Debug)] +pub struct RemoteProcessResizer(mpsc::Sender); + +impl RemoteProcessResizer { + /// Resizes the pty of the remote process if it is attached to one + pub async fn resize(&self, size: PtySize) -> Result<(), RemoteProcessError> { + self.0 + .send(size) + .await + .map_err(|_| RemoteProcessError::ChannelDead)?; + Ok(()) + } +} +/// A handle to the channel to kill a remote process +#[derive(Clone, Debug)] +pub struct RemoteProcessKiller(mpsc::Sender<()>); + +impl RemoteProcessKiller { /// Submits a kill request for the running process pub async fn kill(&mut self) -> Result<(), RemoteProcessError> { - self.kill + self.0 .send(()) .await .map_err(|_| RemoteProcessError::ChannelDead)?; @@ -244,10 +290,15 @@ impl RemoteProcess { } /// A handle to a remote process' standard input (stdin) -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct RemoteStdin(mpsc::Sender>); impl RemoteStdin { + /// Creates a disconnected remote stdin + pub fn disconnected() -> Self { + Self(mpsc::channel(1).0) + } + /// Tries to write to the stdin of the remote process, returning ok if immediately /// successful, `WouldBlock` if would need to wait to send data, and `BrokenPipe` /// if stdin has been closed @@ -374,6 +425,7 @@ async fn process_outgoing_requests( id: usize, mut channel: SessionChannel, mut stdin_rx: mpsc::Receiver>, + mut resize_rx: mpsc::Receiver, mut kill_rx: mpsc::Receiver<()>, ) -> Result<(), RemoteProcessError> { let result = loop { @@ -389,6 +441,17 @@ async fn process_outgoing_requests( None => break Err(RemoteProcessError::ChannelDead), } } + size = resize_rx.recv() => { + match size { + Some(size) => channel.fire( + Request::new( + tenant.as_str(), + vec![RequestData::ProcResizePty { id, size }] + ) + ).await?, + None => break Err(RemoteProcessError::ChannelDead), + } + } msg = kill_rx.recv() => { if msg.is_some() { channel.fire(Request::new( diff --git a/distant-core/src/data.rs b/distant-core/src/data.rs index 6a2bcac..80f4bba 100644 --- a/distant-core/src/data.rs +++ b/distant-core/src/data.rs @@ -1,4 +1,5 @@ use derive_more::{Display, Error, IsVariant}; +use portable_pty::PtySize as PortablePtySize; use serde::{Deserialize, Serialize}; use std::{io, num::ParseIntError, path::PathBuf, str::FromStr}; use strum::AsRefStr; @@ -210,7 +211,7 @@ pub enum RequestData { }, /// Spawns a new process on the remote machine - #[cfg_attr(feature = "structopt", structopt(visible_aliases = &["run"]))] + #[cfg_attr(feature = "structopt", structopt(visible_aliases = &["spawn", "run"]))] ProcSpawn { /// Name of the command to run cmd: String, @@ -396,9 +397,11 @@ pub struct PtySize { pub cols: u16, /// Width of a cell in pixels. Note that some systems never fill this value and ignore it. + #[serde(default)] pub pixel_width: u16, /// Height of a cell in pixels. Note that some systems never fill this value and ignore it. + #[serde(default)] pub pixel_height: u16, } @@ -408,6 +411,38 @@ impl PtySize { Self { rows, cols, + ..Default::default() + } + } +} + +impl From for PtySize { + fn from(size: PortablePtySize) -> Self { + Self { + rows: size.rows, + cols: size.cols, + pixel_width: size.pixel_width, + pixel_height: size.pixel_height, + } + } +} + +impl From for PortablePtySize { + fn from(size: PtySize) -> Self { + Self { + rows: size.rows, + cols: size.cols, + pixel_width: size.pixel_width, + pixel_height: size.pixel_height, + } + } +} + +impl Default for PtySize { + fn default() -> Self { + PtySize { + rows: 24, + cols: 80, pixel_width: 0, pixel_height: 0, } diff --git a/distant-core/src/server/distant/handler.rs b/distant-core/src/server/distant/handler.rs index 66059ef..6f5b4db 100644 --- a/distant-core/src/server/distant/handler.rs +++ b/distant-core/src/server/distant/handler.rs @@ -1,10 +1,12 @@ use crate::{ - constants::{MAX_PIPE_CHUNK_SIZE, READ_PAUSE_MILLIS}, data::{ self, DirEntry, FileType, Metadata, PtySize, Request, RequestData, Response, ResponseData, RunningProcess, SystemInfo, }, - server::distant::state::{Process, State}, + server::distant::{ + process::{Process, PtyProcess, SimpleProcess}, + state::{ProcessState, State}, + }, }; use derive_more::{Display, Error, From}; use futures::future; @@ -14,14 +16,12 @@ use std::{ future::Future, path::{Path, PathBuf}, pin::Pin, - process::Stdio, sync::Arc, time::SystemTime, }; use tokio::{ - io::{self, AsyncReadExt, AsyncWriteExt}, - process::Command, - sync::{mpsc, oneshot, Mutex, MutexGuard}, + io::{self, AsyncWriteExt}, + sync::{mpsc, Mutex}, }; use walkdir::WalkDir; @@ -43,7 +43,7 @@ impl From for ResponseData { } } -type PostHook = Box) + Send>; +type PostHook = Box; struct Outgoing { data: ResponseData, post_hook: Option, @@ -161,7 +161,7 @@ pub(super) async fn process( // Invoke all post hooks for hook in post_hooks { - hook(state.lock().await); + hook(); } result @@ -439,212 +439,119 @@ async fn proc_spawn( where F: FnMut(Vec) -> ReplyRet + Clone + Send + 'static, { - let id = rand::random(); + debug!(" Spawning {} {}", conn_id, cmd, args.join(" ")); + let mut child: Box = match pty { + Some(size) => Box::new(PtyProcess::spawn(cmd.clone(), args.clone(), size)?), + None => Box::new(SimpleProcess::spawn(cmd.clone(), args.clone())?), + }; - debug!( - " Spawning {} {}", - conn_id, - id, - cmd, - args.join(" ") - ); - let mut child = Command::new(cmd.to_string()) - .args(args.clone()) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn()?; - state - .lock() - .await - .push_process(conn_id, Process::new(id, cmd, args, detached, pty)); + let id = child.id(); + let stdin = child.take_stdin(); + let stdout = child.take_stdout(); + let stderr = child.take_stderr(); + let killer = child.clone_killer(); + let pty = child.clone_pty(); - let post_hook = Box::new(move |mut state_lock: MutexGuard<'_, State>| { + let state_2 = Arc::clone(&state); + let post_hook = Box::new(move || { // Spawn a task that sends stdout as a response - let mut stdout = child.stdout.take().unwrap(); - let mut reply_2 = reply.clone(); - let stdout_task = tokio::spawn(async move { - let mut buf: [u8; MAX_PIPE_CHUNK_SIZE] = [0; MAX_PIPE_CHUNK_SIZE]; - loop { - match stdout.read(&mut buf).await { - Ok(n) if n > 0 => { - let payload = vec![ResponseData::ProcStdout { - id, - data: buf[..n].to_vec(), - }]; - if !reply_2(payload).await { - error!(" Stdout channel closed", conn_id, id); + if let Some(mut stdout) = stdout { + let mut reply_2 = reply.clone(); + let _ = tokio::spawn(async move { + loop { + match stdout.recv().await { + Ok(Some(data)) => { + let payload = vec![ResponseData::ProcStdout { id, data }]; + if !reply_2(payload).await { + error!(" Stdout channel closed", conn_id, id); + break; + } + } + Ok(None) => break, + Err(x) => { + error!( + " Reading stdout failed: {}", + conn_id, id, x + ); break; } - - // Pause to allow buffer to fill up a little bit, avoiding - // spamming with a lot of smaller responses - tokio::time::sleep(tokio::time::Duration::from_millis(READ_PAUSE_MILLIS)) - .await; - } - Ok(_) => break, - Err(x) => { - error!( - " Reading stdout failed: {}", - conn_id, id, x - ); - break; } } - } - }); + }); + } // Spawn a task that sends stderr as a response - let mut stderr = child.stderr.take().unwrap(); - let mut reply_2 = reply.clone(); - let stderr_task = tokio::spawn(async move { - let mut buf: [u8; MAX_PIPE_CHUNK_SIZE] = [0; MAX_PIPE_CHUNK_SIZE]; - loop { - match stderr.read(&mut buf).await { - Ok(n) if n > 0 => { - let payload = vec![ResponseData::ProcStderr { - id, - data: buf[..n].to_vec(), - }]; - if !reply_2(payload).await { - error!(" Stderr channel closed", conn_id, id); + if let Some(mut stderr) = stderr { + let mut reply_2 = reply.clone(); + let _ = tokio::spawn(async move { + loop { + match stderr.recv().await { + Ok(Some(data)) => { + let payload = vec![ResponseData::ProcStderr { id, data }]; + if !reply_2(payload).await { + error!(" Stderr channel closed", conn_id, id); + break; + } + } + Ok(None) => break, + Err(x) => { + error!( + " Reading stderr failed: {}", + conn_id, id, x + ); break; } - - // Pause to allow buffer to fill up a little bit, avoiding - // spamming with a lot of smaller responses - tokio::time::sleep(tokio::time::Duration::from_millis(READ_PAUSE_MILLIS)) - .await; - } - Ok(_) => break, - Err(x) => { - error!( - " Reading stderr failed: {}", - conn_id, id, x - ); - break; } } - } - }); - - // Spawn a task that sends stdin to the process - let mut stdin = child.stdin.take().unwrap(); - let (stdin_tx, mut stdin_rx) = mpsc::channel::>(1); - let stdin_task = tokio::spawn(async move { - while let Some(line) = stdin_rx.recv().await { - if let Err(x) = stdin.write_all(&line).await { - error!( - " Failed to send stdin: {}", - conn_id, id, x - ); - break; - } - } - }); + }); + } // Spawn a task that waits on the process to exit but can also // kill the process when triggered - let state_2 = Arc::clone(&state); - let (kill_tx, kill_rx) = oneshot::channel(); let mut reply_2 = reply.clone(); - let wait_task = tokio::spawn(async move { - tokio::select! { - status = child.wait() => { - debug!( - " Completed and waiting on stdout & stderr tasks", - conn_id, - id, - ); - - // Force stdin task to abort if it hasn't exited as there is no - // point to sending any more stdin - stdin_task.abort(); - - if let Err(x) = stderr_task.await { - error!(" Join on stderr task failed: {}", conn_id, id, x); - } - - if let Err(x) = stdout_task.await { - error!(" Join on stdout task failed: {}", conn_id, id, x); - } - - state_2.lock().await.remove_process(conn_id, id); - - match status { - Ok(status) => { - let success = status.success(); - let mut code = status.code(); - - // If we succeeded and have no exit code, automatically populate - // with success exit code - if success && code.is_none() { - code = Some(0); - } - - let payload = vec![ResponseData::ProcDone { id, success, code }]; - if !reply_2(payload).await { - error!( - " Failed to send done", - conn_id, - id, - ); - } - } - Err(x) => { - let payload = vec![ResponseData::from(x)]; - if !reply_2(payload).await { - error!( - " Failed to send error for waiting", - conn_id, - id, - ); - } - } - } - - }, - _ = kill_rx => { - debug!(" Killing", conn_id, id); - - if let Err(x) = child.kill().await { - error!(" Unable to kill: {}", conn_id, id, x); - } + let _ = tokio::spawn(async move { + let status = child.wait().await; + debug!(" Completed {:?}", conn_id, id, status); - // Force stdin task to abort if it hasn't exited as there is no - // point to sending any more stdin - stdin_task.abort(); + state_2.lock().await.remove_process(conn_id, id); - if let Err(x) = stderr_task.await { - error!(" Join on stderr task failed: {}", conn_id, id, x); - } - - if let Err(x) = stdout_task.await { - error!(" Join on stdout task failed: {}", conn_id, id, x); - } - - // Wait for the child after being killed to ensure that it has been cleaned - // up at the operating system level - if let Err(x) = child.wait().await { - error!(" Failed to wait after killed: {}", conn_id, id, x); + match status { + Ok(status) => { + let payload = vec![ResponseData::ProcDone { + id, + success: status.success, + code: status.code, + }]; + if !reply_2(payload).await { + error!(" Failed to send done", conn_id, id,); } - - state_2.lock().await.remove_process(conn_id, id); - - let payload = vec![ResponseData::ProcDone { id, success: false, code: None }]; + } + Err(x) => { + let payload = vec![ResponseData::from(x)]; if !reply_2(payload).await { - error!(" Failed to send done", conn_id, id); + error!( + " Failed to send error for waiting", + conn_id, id, + ); } } } }); - - // Update our state with the new process - if let Some(proc) = state_lock.mut_process(id) { - proc.initialize(stdin_tx, kill_tx, wait_task); - } }); + state.lock().await.push_process_state( + conn_id, + ProcessState { + cmd, + args, + detached, + id, + stdin, + killer, + pty, + }, + ); + debug!( " Spawned successfully! Will enter post hook later", conn_id, id @@ -656,8 +563,8 @@ where } async fn proc_kill(conn_id: usize, state: HState, id: usize) -> Result { - if let Some(process) = state.lock().await.processes.remove(&id) { - if process.kill() { + if let Some(mut process) = state.lock().await.processes.remove(&id) { + if process.killer.kill().await.is_ok() { return Ok(Outgoing::from(ResponseData::Ok)); } } @@ -677,9 +584,11 @@ async fn proc_stdin( id: usize, data: Vec, ) -> Result { - if let Some(process) = state.lock().await.processes.get(&id) { - if process.send_stdin(data).await { - return Ok(Outgoing::from(ResponseData::Ok)); + if let Some(process) = state.lock().await.processes.get_mut(&id) { + if let Some(stdin) = process.stdin.as_mut() { + if stdin.send(&data).await.is_ok() { + return Ok(Outgoing::from(ResponseData::Ok)); + } } } @@ -698,7 +607,19 @@ async fn proc_resize_pty( id: usize, size: PtySize, ) -> Result { - todo!(); + if let Some(process) = state.lock().await.processes.get(&id) { + let _ = process.pty.resize_pty(size)?; + + return Ok(Outgoing::from(ResponseData::Ok)); + } + + Err(ServerError::IoError(io::Error::new( + io::ErrorKind::BrokenPipe, + format!( + " Unable to resize pty to {:?}", + conn_id, id, size, + ), + ))) } async fn proc_list(state: HState) -> Result { @@ -712,7 +633,8 @@ async fn proc_list(state: HState) -> Result { cmd: p.cmd.to_string(), args: p.args.clone(), detached: p.detached, - pty: p.pty.clone(), + // TODO: Support retrieving current pty size + pty: None, id: p.id, }) .collect(), @@ -2213,7 +2135,7 @@ mod tests { } #[tokio::test] - async fn proc_run_should_send_error_on_failure() { + async fn proc_spawn_should_send_error_on_failure() { let (conn_id, state, tx, mut rx) = setup(1); let req = Request::new( @@ -2240,7 +2162,7 @@ mod tests { } #[tokio::test] - async fn proc_run_should_send_back_proc_start_on_success() { + async fn proc_spawn_should_send_back_proc_start_on_success() { let (conn_id, state, tx, mut rx) = setup(1); let req = Request::new( @@ -2270,7 +2192,7 @@ mod tests { // with / but thinks it's on windows and is providing \ #[tokio::test] #[cfg_attr(windows, ignore)] - async fn proc_run_should_send_back_stdout_periodically_when_available() { + async fn proc_spawn_should_send_back_stdout_periodically_when_available() { let (conn_id, state, tx, mut rx) = setup(1); // Run a program that echoes to stdout @@ -2337,7 +2259,7 @@ mod tests { // with / but thinks it's on windows and is providing \ #[tokio::test] #[cfg_attr(windows, ignore)] - async fn proc_run_should_send_back_stderr_periodically_when_available() { + async fn proc_spawn_should_send_back_stderr_periodically_when_available() { let (conn_id, state, tx, mut rx) = setup(1); // Run a program that echoes to stderr @@ -2404,7 +2326,7 @@ mod tests { // with / but thinks it's on windows and is providing \ #[tokio::test] #[cfg_attr(windows, ignore)] - async fn proc_run_should_clear_process_from_state_when_done() { + async fn proc_spawn_should_clear_process_from_state_when_done() { let (conn_id, state, tx, mut rx) = setup(1); // Run a program that ends after a little bit @@ -2448,7 +2370,7 @@ mod tests { } #[tokio::test] - async fn proc_run_should_clear_process_from_state_when_killed() { + async fn proc_spawn_should_clear_process_from_state_when_killed() { let (conn_id, state, tx, mut rx) = setup(1); // Run a program that ends slowly diff --git a/distant-core/src/server/distant/mod.rs b/distant-core/src/server/distant/mod.rs index e35373e..5ff155d 100644 --- a/distant-core/src/server/distant/mod.rs +++ b/distant-core/src/server/distant/mod.rs @@ -1,6 +1,8 @@ mod handler; +mod process; mod state; +pub(crate) use process::{InputChannel, ProcessKiller, ProcessPty}; use state::State; use crate::{ diff --git a/distant-core/src/server/distant/process/mod.rs b/distant-core/src/server/distant/process/mod.rs new file mode 100644 index 0000000..6a4b50e --- /dev/null +++ b/distant-core/src/server/distant/process/mod.rs @@ -0,0 +1,151 @@ +use crate::data::PtySize; +use std::{future::Future, pin::Pin}; +use tokio::{io, sync::mpsc}; + +mod pty; +pub use pty::*; + +mod simple; +pub use simple::*; + +mod wait; +pub use wait::{ExitStatus, WaitRx, WaitTx}; + +/// Alias to the return type of an async function (for use with traits) +pub type FutureReturn<'a, T> = Pin + Send + 'a>>; + +/// Represents a process on the remote server +pub trait Process: ProcessKiller + ProcessPty { + /// Represents the id of the process + fn id(&self) -> usize; + + /// Waits for the process to exit, returning the exit status + /// + /// If the process has already exited, the status is returned immediately. + fn wait(&mut self) -> FutureReturn<'_, io::Result>; + + /// Returns a reference to stdin channel if the process still has it associated + fn stdin(&self) -> Option<&dyn InputChannel>; + + /// Returns a mutable reference to the stdin channel if the process still has it associated + fn mut_stdin(&mut self) -> Option<&mut (dyn InputChannel + 'static)>; + + /// Takes the stdin channel from the process if it is still associated + fn take_stdin(&mut self) -> Option>; + + /// Returns a reference to stdout channel if the process still has it associated + fn stdout(&self) -> Option<&dyn OutputChannel>; + + /// Returns a mutable reference to the stdout channel if the process still has it associated + fn mut_stdout(&mut self) -> Option<&mut (dyn OutputChannel + 'static)>; + + /// Takes the stdout channel from the process if it is still associated + fn take_stdout(&mut self) -> Option>; + + /// Returns a reference to stderr channel if the process still has it associated + fn stderr(&self) -> Option<&dyn OutputChannel>; + + /// Returns a mutable reference to the stderr channel if the process still has it associated + fn mut_stderr(&mut self) -> Option<&mut (dyn OutputChannel + 'static)>; + + /// Takes the stderr channel from the process if it is still associated + fn take_stderr(&mut self) -> Option>; +} + +/// Represents interface that can be used to work with a pty associated with a process +pub trait ProcessPty: Send + Sync { + /// Returns the current size of the process' pty if it has one + fn pty_size(&self) -> Option; + + /// Resize the pty associated with the process; returns an error if fails or if the + /// process does not leverage a pty + fn resize_pty(&self, size: PtySize) -> io::Result<()>; + + /// Clone a process pty to support reading and updating pty independently + fn clone_pty(&self) -> Box; +} + +/// Trait that can be implemented to mark a process as not having a pty +pub trait NoProcessPty: Send + Sync {} + +/// Internal type so we can create a dummy instance that implements trait +struct NoProcessPtyImpl {} +impl NoProcessPty for NoProcessPtyImpl {} + +impl ProcessPty for T { + fn pty_size(&self) -> Option { + None + } + + fn resize_pty(&self, _size: PtySize) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Other, + "Process does not use pty", + )) + } + + fn clone_pty(&self) -> Box { + Box::new(NoProcessPtyImpl {}) + } +} + +/// Represents interface that can be used to kill a remote process +pub trait ProcessKiller: Send + Sync { + /// Kill the process + /// + /// If the process is dead or has already been killed, this will return + /// an error. + fn kill(&mut self) -> FutureReturn<'_, io::Result<()>>; + + /// Clone a process killer to support sending signals independently + fn clone_killer(&self) -> Box; +} + +impl ProcessKiller for mpsc::Sender<()> { + fn kill(&mut self) -> FutureReturn<'_, io::Result<()>> { + async fn inner(this: &mut mpsc::Sender<()>) -> io::Result<()> { + this.send(()) + .await + .map_err(|x| io::Error::new(io::ErrorKind::BrokenPipe, x)) + } + Box::pin(inner(self)) + } + + fn clone_killer(&self) -> Box { + Box::new(self.clone()) + } +} + +/// Represents an input channel of a process such as stdin +pub trait InputChannel: Send + Sync { + /// Sends input through channel, returning unit if succeeds or an error if fails + fn send<'a>(&'a mut self, data: &[u8]) -> FutureReturn<'a, io::Result<()>>; +} + +impl InputChannel for mpsc::Sender> { + fn send<'a>(&'a mut self, data: &[u8]) -> FutureReturn<'a, io::Result<()>> { + let data = data.to_vec(); + Box::pin(async move { + match mpsc::Sender::send(self, data).await { + Ok(_) => Ok(()), + Err(_) => Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "Input channel closed", + )), + } + }) + } +} + +/// Represents an output channel of a process such as stdout or stderr +pub trait OutputChannel: Send + Sync { + /// Waits for next output from channel, returning Some(data) if there is output, None if + /// the channel has been closed, or bubbles up an error if encountered + fn recv(&mut self) -> FutureReturn<'_, io::Result>>>; +} + +impl OutputChannel for mpsc::Receiver> { + fn recv(&mut self) -> FutureReturn<'_, io::Result>>> { + Box::pin(async move { Ok(mpsc::Receiver::recv(self).await) }) + } +} diff --git a/distant-core/src/server/distant/process/pty.rs b/distant-core/src/server/distant/process/pty.rs new file mode 100644 index 0000000..19ebe06 --- /dev/null +++ b/distant-core/src/server/distant/process/pty.rs @@ -0,0 +1,278 @@ +use super::{ + wait, ExitStatus, FutureReturn, InputChannel, OutputChannel, Process, ProcessKiller, + ProcessPty, PtySize, WaitRx, +}; +use crate::constants::{MAX_PIPE_CHUNK_SIZE, READ_PAUSE_MILLIS}; +use portable_pty::{CommandBuilder, MasterPty, PtySize as PortablePtySize}; +use std::{ + ffi::OsStr, + io::{self, Read, Write}, + sync::{Arc, Mutex}, +}; +use tokio::{sync::mpsc, task::JoinHandle}; + +/// Represents a process that is associated with a pty +pub struct PtyProcess { + id: usize, + pty_master: PtyProcessMaster, + stdin: Option>, + stdout: Option>, + stdin_task: Option>, + stdout_task: Option>>, + kill_tx: mpsc::Sender<()>, + wait: WaitRx, +} + +impl PtyProcess { + /// Spawns a new simple process + pub fn spawn(program: S, args: I, size: PtySize) -> io::Result + where + S: AsRef, + I: IntoIterator, + S2: AsRef, + { + // Establish our new pty for the given size + let pty_system = portable_pty::native_pty_system(); + let pty_pair = pty_system + .openpty(PortablePtySize { + rows: size.rows, + cols: size.cols, + pixel_width: size.pixel_width, + pixel_height: size.pixel_height, + }) + .map_err(|x| io::Error::new(io::ErrorKind::Other, x))?; + let pty_master = pty_pair.master; + let pty_slave = pty_pair.slave; + + // Spawn our process within the pty + let mut cmd = CommandBuilder::new(program); + cmd.args(args); + let mut child = pty_slave + .spawn_command(cmd) + .map_err(|x| io::Error::new(io::ErrorKind::Other, x))?; + + // NOTE: Need to drop slave to close out file handles and avoid deadlock when waiting on + // the child + drop(pty_slave); + + // Spawn a blocking task to process submitting stdin async + let (stdin_tx, mut stdin_rx) = mpsc::channel::>(1); + let mut stdin_writer = pty_master + .try_clone_writer() + .map_err(|x| io::Error::new(io::ErrorKind::Other, x))?; + let stdin_task = tokio::task::spawn_blocking(move || { + while let Some(input) = stdin_rx.blocking_recv() { + if stdin_writer.write_all(&input).is_err() { + break; + } + } + }); + + // Spawn a blocking task to process receiving stdout async + let (stdout_tx, stdout_rx) = mpsc::channel::>(1); + let mut stdout_reader = pty_master + .try_clone_reader() + .map_err(|x| io::Error::new(io::ErrorKind::Other, x))?; + let stdout_task = tokio::task::spawn_blocking(move || { + let mut buf: [u8; MAX_PIPE_CHUNK_SIZE] = [0; MAX_PIPE_CHUNK_SIZE]; + loop { + match stdout_reader.read(&mut buf) { + Ok(n) if n > 0 => { + let _ = stdout_tx.blocking_send(buf[..n].to_vec()).map_err(|_| { + io::Error::new(io::ErrorKind::BrokenPipe, "Output channel closed") + })?; + } + Ok(_) => return Ok(()), + Err(x) => return Err(x), + } + } + }); + + let (kill_tx, mut kill_rx) = mpsc::channel(1); + let (mut wait_tx, wait_rx) = wait::channel(); + + tokio::spawn(async move { + loop { + match (child.try_wait(), kill_rx.try_recv()) { + (Ok(Some(status)), _) => { + // TODO: Keep track of io error + let _ = wait_tx + .send(ExitStatus { + success: status.success(), + code: None, + }) + .await; + break; + } + (_, Ok(_)) => { + // TODO: Keep track of io error + let _ = wait_tx.kill().await; + break; + } + (Err(x), _) => { + // TODO: Keep track of io error + let _ = wait_tx.send(x).await; + break; + } + _ => { + tokio::time::sleep(tokio::time::Duration::from_millis(READ_PAUSE_MILLIS)) + .await; + continue; + } + } + } + }); + + Ok(Self { + id: rand::random(), + pty_master: PtyProcessMaster(Arc::new(Mutex::new(pty_master))), + stdin: Some(Box::new(stdin_tx)), + stdout: Some(Box::new(stdout_rx)), + stdin_task: Some(stdin_task), + stdout_task: Some(stdout_task), + kill_tx, + wait: wait_rx, + }) + } +} + +impl Process for PtyProcess { + fn id(&self) -> usize { + self.id + } + + fn wait(&mut self) -> FutureReturn<'_, io::Result> { + async fn inner(this: &mut PtyProcess) -> io::Result { + let mut status = this.wait.recv().await?; + + if let Some(task) = this.stdin_task.take() { + task.abort(); + } + if let Some(task) = this.stdout_task.take() { + let _ = task.await; + } + + if status.success && status.code.is_none() { + status.code = Some(0); + } + Ok(status) + } + Box::pin(inner(self)) + } + + fn stdin(&self) -> Option<&dyn InputChannel> { + self.stdin.as_deref() + } + + fn mut_stdin(&mut self) -> Option<&mut (dyn InputChannel + 'static)> { + self.stdin.as_deref_mut() + } + + fn take_stdin(&mut self) -> Option> { + self.stdin.take() + } + + fn stdout(&self) -> Option<&dyn OutputChannel> { + self.stdout.as_deref() + } + + fn mut_stdout(&mut self) -> Option<&mut (dyn OutputChannel + 'static)> { + self.stdout.as_deref_mut() + } + + fn take_stdout(&mut self) -> Option> { + self.stdout.take() + } + + fn stderr(&self) -> Option<&dyn OutputChannel> { + None + } + + fn mut_stderr(&mut self) -> Option<&mut (dyn OutputChannel + 'static)> { + None + } + + fn take_stderr(&mut self) -> Option> { + None + } +} + +impl ProcessKiller for PtyProcess { + fn kill(&mut self) -> FutureReturn<'_, io::Result<()>> { + async fn inner(this: &mut PtyProcess) -> io::Result<()> { + this.kill_tx + .send(()) + .await + .map_err(|x| io::Error::new(io::ErrorKind::BrokenPipe, x)) + } + Box::pin(inner(self)) + } + + fn clone_killer(&self) -> Box { + Box::new(self.kill_tx.clone()) + } +} + +#[derive(Clone)] +pub struct PtyProcessKiller(mpsc::Sender<()>); + +impl ProcessKiller for PtyProcessKiller { + fn kill(&mut self) -> FutureReturn<'_, io::Result<()>> { + async fn inner(this: &mut PtyProcessKiller) -> io::Result<()> { + this.0 + .send(()) + .await + .map_err(|x| io::Error::new(io::ErrorKind::BrokenPipe, x)) + } + Box::pin(inner(self)) + } + + fn clone_killer(&self) -> Box { + Box::new(self.clone()) + } +} + +impl ProcessPty for PtyProcess { + fn pty_size(&self) -> Option { + self.pty_master.pty_size() + } + + fn resize_pty(&self, size: PtySize) -> io::Result<()> { + self.pty_master.resize_pty(size) + } + + fn clone_pty(&self) -> Box { + self.pty_master.clone_pty() + } +} + +#[derive(Clone)] +pub struct PtyProcessMaster(Arc>>); + +impl ProcessPty for PtyProcessMaster { + fn pty_size(&self) -> Option { + self.0.lock().unwrap().get_size().ok().map(|size| PtySize { + rows: size.rows, + cols: size.cols, + pixel_width: size.pixel_width, + pixel_height: size.pixel_height, + }) + } + + fn resize_pty(&self, size: PtySize) -> io::Result<()> { + self.0 + .lock() + .unwrap() + .resize(PortablePtySize { + rows: size.rows, + cols: size.cols, + pixel_width: size.pixel_width, + pixel_height: size.pixel_height, + }) + .map_err(|x| io::Error::new(io::ErrorKind::Other, x)) + } + + fn clone_pty(&self) -> Box { + Box::new(self.clone()) + } +} diff --git a/distant-core/src/server/distant/process/simple.rs b/distant-core/src/server/distant/process/simple.rs new file mode 100644 index 0000000..afd61be --- /dev/null +++ b/distant-core/src/server/distant/process/simple.rs @@ -0,0 +1,181 @@ +use super::{ + wait, ExitStatus, FutureReturn, InputChannel, NoProcessPty, OutputChannel, Process, + ProcessKiller, WaitRx, +}; +use std::{ffi::OsStr, process::Stdio}; +use tokio::{io, process::Command, sync::mpsc, task::JoinHandle}; + +mod tasks; + +/// Represents a simple process that does not have a pty +pub struct SimpleProcess { + id: usize, + stdin: Option>, + stdout: Option>, + stderr: Option>, + stdin_task: Option>>, + stdout_task: Option>>, + stderr_task: Option>>, + kill_tx: mpsc::Sender<()>, + wait: WaitRx, +} + +impl SimpleProcess { + /// Spawns a new simple process + pub fn spawn(program: S, args: I) -> io::Result + where + S: AsRef, + I: IntoIterator, + S2: AsRef, + { + let mut child = Command::new(program) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + + let stdout = child.stdout.take().unwrap(); + let (stdout_task, stdout_ch) = tasks::spawn_read_task(stdout, 1); + + let stderr = child.stderr.take().unwrap(); + let (stderr_task, stderr_ch) = tasks::spawn_read_task(stderr, 1); + + let stdin = child.stdin.take().unwrap(); + let (stdin_task, stdin_ch) = tasks::spawn_write_task(stdin, 1); + + let (kill_tx, mut kill_rx) = mpsc::channel(1); + let (mut wait_tx, wait_rx) = wait::channel(); + + tokio::spawn(async move { + tokio::select! { + _ = kill_rx.recv() => { + let status = match child.kill().await { + Ok(_) => ExitStatus::killed(), + Err(x) => ExitStatus::from(x), + }; + + // TODO: Keep track of io error + let _ = wait_tx.send(status).await; + } + status = child.wait() => { + // TODO: Keep track of io error + let _ = wait_tx.send(status).await; + } + } + }); + + Ok(Self { + id: rand::random(), + stdin: Some(Box::new(stdin_ch)), + stdout: Some(Box::new(stdout_ch)), + stderr: Some(Box::new(stderr_ch)), + stdin_task: Some(stdin_task), + stdout_task: Some(stdout_task), + stderr_task: Some(stderr_task), + kill_tx, + wait: wait_rx, + }) + } +} + +impl Process for SimpleProcess { + fn id(&self) -> usize { + self.id + } + + fn wait(&mut self) -> FutureReturn<'_, io::Result> { + async fn inner(this: &mut SimpleProcess) -> io::Result { + let mut status = this.wait.recv().await?; + + if let Some(task) = this.stdin_task.take() { + task.abort(); + } + if let Some(task) = this.stdout_task.take() { + let _ = task.await; + } + if let Some(task) = this.stderr_task.take() { + let _ = task.await; + } + + if status.success && status.code.is_none() { + status.code = Some(0); + } + Ok(status) + } + Box::pin(inner(self)) + } + + fn stdin(&self) -> Option<&dyn InputChannel> { + self.stdin.as_deref() + } + + fn mut_stdin(&mut self) -> Option<&mut (dyn InputChannel + 'static)> { + self.stdin.as_deref_mut() + } + + fn take_stdin(&mut self) -> Option> { + self.stdin.take() + } + + fn stdout(&self) -> Option<&dyn OutputChannel> { + self.stdout.as_deref() + } + + fn mut_stdout(&mut self) -> Option<&mut (dyn OutputChannel + 'static)> { + self.stdout.as_deref_mut() + } + + fn take_stdout(&mut self) -> Option> { + self.stdout.take() + } + + fn stderr(&self) -> Option<&dyn OutputChannel> { + self.stderr.as_deref() + } + + fn mut_stderr(&mut self) -> Option<&mut (dyn OutputChannel + 'static)> { + self.stderr.as_deref_mut() + } + + fn take_stderr(&mut self) -> Option> { + self.stderr.take() + } +} + +impl NoProcessPty for SimpleProcess {} + +impl ProcessKiller for SimpleProcess { + fn kill(&mut self) -> FutureReturn<'_, io::Result<()>> { + async fn inner(this: &mut SimpleProcess) -> io::Result<()> { + this.kill_tx + .send(()) + .await + .map_err(|x| io::Error::new(io::ErrorKind::BrokenPipe, x)) + } + Box::pin(inner(self)) + } + + fn clone_killer(&self) -> Box { + Box::new(self.kill_tx.clone()) + } +} + +#[derive(Clone)] +pub struct SimpleProcessKiller(mpsc::Sender<()>); + +impl ProcessKiller for SimpleProcessKiller { + fn kill(&mut self) -> FutureReturn<'_, io::Result<()>> { + async fn inner(this: &mut SimpleProcessKiller) -> io::Result<()> { + this.0 + .send(()) + .await + .map_err(|x| io::Error::new(io::ErrorKind::BrokenPipe, x)) + } + Box::pin(inner(self)) + } + + fn clone_killer(&self) -> Box { + Box::new(self.clone()) + } +} diff --git a/distant-core/src/server/distant/process/simple/tasks.rs b/distant-core/src/server/distant/process/simple/tasks.rs new file mode 100644 index 0000000..4d253af --- /dev/null +++ b/distant-core/src/server/distant/process/simple/tasks.rs @@ -0,0 +1,71 @@ +use crate::constants::{MAX_PIPE_CHUNK_SIZE, READ_PAUSE_MILLIS}; +use tokio::{ + io::{self, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}, + sync::mpsc, + task::JoinHandle, +}; + +pub fn spawn_read_task( + reader: R, + buf: usize, +) -> (JoinHandle>, mpsc::Receiver>) +where + R: AsyncRead + Send + Unpin + 'static, +{ + let (tx, rx) = mpsc::channel(buf); + let task = tokio::spawn(read_handler(reader, tx)); + (task, rx) +} + +/// Continually reads from some reader and fowards to the provided sender until the reader +/// or channel is closed +async fn read_handler(mut reader: R, channel: mpsc::Sender>) -> io::Result<()> +where + R: AsyncRead + Unpin, +{ + let mut buf: [u8; MAX_PIPE_CHUNK_SIZE] = [0; MAX_PIPE_CHUNK_SIZE]; + loop { + match reader.read(&mut buf).await { + Ok(n) if n > 0 => { + let _ = channel.send(buf[..n].to_vec()).await.map_err(|_| { + io::Error::new(io::ErrorKind::BrokenPipe, "Output channel closed") + })?; + + // Pause to allow buffer to fill up a little bit, avoiding + // spamming with a lot of smaller responses + tokio::time::sleep(tokio::time::Duration::from_millis(READ_PAUSE_MILLIS)).await; + } + Ok(_) => return Ok(()), + Err(x) if x.kind() == io::ErrorKind::WouldBlock => { + // Pause to allow buffer to fill up a little bit, avoiding + // spamming with a lot of smaller responses + tokio::time::sleep(tokio::time::Duration::from_millis(READ_PAUSE_MILLIS)).await; + } + Err(x) => return Err(x), + } + } +} + +pub fn spawn_write_task( + writer: W, + buf: usize, +) -> (JoinHandle>, mpsc::Sender>) +where + W: AsyncWrite + Send + Unpin + 'static, +{ + let (tx, rx) = mpsc::channel(buf); + let task = tokio::spawn(write_handler(writer, rx)); + (task, tx) +} + +/// Continually writes to some writer by reading data from a provided receiver until the receiver +/// or writer is closed +async fn write_handler(mut writer: W, mut channel: mpsc::Receiver>) -> io::Result<()> +where + W: AsyncWrite + Unpin, +{ + while let Some(data) = channel.recv().await { + let _ = writer.write_all(&data).await?; + } + Ok(()) +} diff --git a/distant-core/src/server/distant/process/wait.rs b/distant-core/src/server/distant/process/wait.rs new file mode 100644 index 0000000..8a77668 --- /dev/null +++ b/distant-core/src/server/distant/process/wait.rs @@ -0,0 +1,136 @@ +use tokio::{io, sync::mpsc}; + +/// Exit status of a remote process +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct ExitStatus { + pub success: bool, + pub code: Option, +} + +impl ExitStatus { + /// Produces a new exit status representing a killed process + pub fn killed() -> Self { + Self { + success: false, + code: None, + } + } +} + +impl From> for ExitStatus +where + T: Into, + E: Into, +{ + fn from(res: Result) -> Self { + match res { + Ok(x) => x.into(), + Err(x) => x.into(), + } + } +} + +impl From for ExitStatus { + fn from(err: io::Error) -> Self { + Self { + success: false, + code: err.raw_os_error(), + } + } +} + +impl From for ExitStatus { + fn from(status: std::process::ExitStatus) -> Self { + Self { + success: status.success(), + code: status.code(), + } + } +} + +/// Creates a new channel for when the exit status will be ready +pub fn channel() -> (WaitTx, WaitRx) { + let (tx, rx) = mpsc::channel(1); + (WaitTx::Pending(tx), WaitRx::Pending(rx)) +} + +/// Represents a notifier for a specific waiting state +#[derive(Debug)] +pub enum WaitTx { + /// Notification has been sent + Done, + + /// Notification has not been sent + Pending(mpsc::Sender), +} + +impl WaitTx { + /// Send exit status to receiving-side of wait + pub async fn send(&mut self, status: S) -> io::Result<()> + where + S: Into, + { + let status = status.into(); + + match self { + Self::Done => Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "Notifier is closed", + )), + Self::Pending(tx) => { + let res = tx.send(status).await; + *self = Self::Done; + + match res { + Ok(_) => Ok(()), + Err(x) => Err(io::Error::new(io::ErrorKind::Other, x)), + } + } + } + } + + /// Mark wait as completed using killed status + pub async fn kill(&mut self) -> io::Result<()> { + self.send(ExitStatus::killed()).await + } +} + +/// Represents the state of waiting for an exit status +#[derive(Debug)] +pub enum WaitRx { + /// Exit status is ready + Ready(ExitStatus), + + /// If receiver for an exit status has been dropped without receiving the status + Dropped, + + /// Exit status is not ready and has a "oneshot" to be invoked when available + Pending(mpsc::Receiver), +} + +impl WaitRx { + /// Waits until the exit status is resolved; can be called repeatedly after being + /// resolved to immediately return the exit status again + pub async fn recv(&mut self) -> io::Result { + match self { + Self::Ready(status) => Ok(*status), + Self::Dropped => Err(io::Error::new( + io::ErrorKind::Other, + "Internal resolver dropped", + )), + Self::Pending(rx) => match rx.recv().await { + Some(status) => { + *self = Self::Ready(status); + Ok(status) + } + None => { + *self = Self::Dropped; + Err(io::Error::new( + io::ErrorKind::Other, + "Internal resolver dropped", + )) + } + }, + } + } +} diff --git a/distant-core/src/server/distant/state.rs b/distant-core/src/server/distant/state.rs index bdb95c1..821e6b3 100644 --- a/distant-core/src/server/distant/state.rs +++ b/distant-core/src/server/distant/state.rs @@ -1,38 +1,37 @@ -use crate::data::PtySize; +use super::{InputChannel, ProcessKiller, ProcessPty}; use log::*; -use std::{ - collections::HashMap, - future::Future, - pin::Pin, - task::{Context, Poll}, -}; -use tokio::{ - sync::{mpsc, oneshot}, - task::{JoinError, JoinHandle}, -}; +use std::collections::HashMap; /// Holds state related to multiple connections managed by a server #[derive(Default)] pub struct State { /// Map of all processes running on the server - pub processes: HashMap, + pub processes: HashMap, /// List of processes that will be killed when a connection drops client_processes: HashMap>, } +/// Holds information related to a spawned process on the server +pub struct ProcessState { + pub cmd: String, + pub args: Vec, + pub detached: bool, + + pub id: usize, + pub stdin: Option>, + pub killer: Box, + pub pty: Box, +} + impl State { /// Pushes a new process associated with a connection - pub fn push_process(&mut self, conn_id: usize, process: Process) { + pub fn push_process_state(&mut self, conn_id: usize, process_state: ProcessState) { self.client_processes .entry(conn_id) .or_insert_with(Vec::new) - .push(process.id); - self.processes.insert(process.id, process); - } - - pub fn mut_process(&mut self, proc_id: usize) -> Option<&mut Process> { - self.processes.get_mut(&proc_id) + .push(process_state.id); + self.processes.insert(process_state.id, process_state); } /// Removes a process associated with a connection @@ -57,7 +56,7 @@ impl State { process.id ); - process.close_stdin(); + let _ = process.stdin.take(); } } } @@ -68,7 +67,7 @@ impl State { debug!(" Cleaning up state", conn_id); if let Some(ids) = self.client_processes.remove(&conn_id) { for id in ids { - if let Some(process) = self.processes.remove(&id) { + if let Some(mut process) = self.processes.remove(&id) { if !process.detached { trace!( " Requesting proc {} be killed", @@ -76,8 +75,11 @@ impl State { process.id ); let pid = process.id; - if !process.kill() { - error!("Conn {} failed to send process {} kill signal", id, pid); + if let Err(x) = process.killer.kill().await { + error!( + "Conn {} failed to send process {} kill signal: {}", + id, pid, x + ); } } else { trace!( @@ -91,97 +93,3 @@ impl State { } } } - -/// Represents an actively-running process -pub struct Process { - /// Id of the process - pub id: usize, - - /// Command used to start the process - pub cmd: String, - - /// Arguments associated with the process - pub args: Vec, - - /// Whether or not this process was run detached - pub detached: bool, - - /// Dimensions of pty associated with process, if it has one - pub pty: Option, - - /// Transport channel to send new input to the stdin of the process, - /// one line at a time - stdin_tx: Option>>, - - /// Transport channel to report that the process should be killed - kill_tx: Option>, - - /// Task used to wait on the process to complete or be killed - wait_task: Option>, -} - -impl Process { - pub fn new( - id: usize, - cmd: String, - args: Vec, - detached: bool, - pty: Option, - ) -> Self { - Self { - id, - cmd, - args, - detached, - pty, - stdin_tx: None, - kill_tx: None, - wait_task: None, - } - } - - /// Lazy initialization of process state - pub(crate) fn initialize( - &mut self, - stdin_tx: mpsc::Sender>, - kill_tx: oneshot::Sender<()>, - wait_task: JoinHandle<()>, - ) { - self.stdin_tx = Some(stdin_tx); - self.kill_tx = Some(kill_tx); - self.wait_task = Some(wait_task); - } - - pub async fn send_stdin(&self, input: impl Into>) -> bool { - if let Some(stdin) = self.stdin_tx.as_ref() { - if stdin.send(input.into()).await.is_ok() { - return true; - } - } - - false - } - - pub fn close_stdin(&mut self) { - self.stdin_tx.take(); - } - - pub fn kill(self) -> bool { - self.kill_tx - .map(|tx| tx.send(()).is_ok()) - .unwrap_or_default() - } -} - -impl Future for Process { - type Output = Result<(), JoinError>; - - fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - if let Some(task) = self.wait_task.as_mut() { - Pin::new(task).poll(cx) - } else { - // TODO: Does this work? - Poll::Pending - } - } -} diff --git a/distant-lua-tests/Cargo.toml b/distant-lua-tests/Cargo.toml index c9f310d..508a8c6 100644 --- a/distant-lua-tests/Cargo.toml +++ b/distant-lua-tests/Cargo.toml @@ -20,7 +20,7 @@ assert_fs = "1.0.4" distant-core = { path = "../distant-core" } futures = "0.3.17" indoc = "1.0.3" -mlua = { version = "0.6.6", features = ["async", "macros", "serialize"] } +mlua = { version = "0.7.3", features = ["async", "macros", "serialize"] } once_cell = "1.8.0" predicates = "2.0.2" rstest = "0.11.0" diff --git a/distant-lua-tests/tests/lua/async/mod.rs b/distant-lua-tests/tests/lua/async/mod.rs index 94f07df..6c46fd3 100644 --- a/distant-lua-tests/tests/lua/async/mod.rs +++ b/distant-lua-tests/tests/lua/async/mod.rs @@ -10,6 +10,7 @@ mod read_file_text; mod remove; mod rename; mod spawn; +mod spawn_pty; mod spawn_wait; mod system_info; mod write_file; diff --git a/distant-lua-tests/tests/lua/async/spawn.rs b/distant-lua-tests/tests/lua/async/spawn.rs index e0cf85e..bbd077b 100644 --- a/distant-lua-tests/tests/lua/async/spawn.rs +++ b/distant-lua-tests/tests/lua/async/spawn.rs @@ -174,6 +174,8 @@ fn should_return_process_that_can_retrieve_stdout(ctx: &'_ DistantServerCtx) { end end) assert(not err, "Unexpectedly failed reading stdout: " .. tostring(err)) + + stdout = string.char(unpack(stdout)) assert(stdout == "some stdout", "Unexpected stdout: " .. stdout) }) .exec(); @@ -232,7 +234,9 @@ fn should_return_process_that_can_retrieve_stderr(ctx: &'_ DistantServerCtx) { err = res end end) - assert(not err, "Unexpectedly failed reading stdout: " .. tostring(err)) + assert(not err, "Unexpectedly failed reading stderr: " .. tostring(err)) + + stderr = string.char(unpack(stderr)) assert(stderr == "some stderr", "Unexpected stderr: " .. stderr) }) .exec(); @@ -430,6 +434,8 @@ fn should_support_sending_stdin_to_spawned_process(ctx: &'_ DistantServerCtx) { end end) assert(not err, "Unexpectedly failed reading stdout: " .. tostring(err)) + + stdout = string.char(unpack(stdout)) assert(stdout == "some text\n", "Unexpected stdout received: " .. stdout) }) .exec(); diff --git a/distant-lua-tests/tests/lua/async/spawn_pty.rs b/distant-lua-tests/tests/lua/async/spawn_pty.rs new file mode 100644 index 0000000..5aaa05b --- /dev/null +++ b/distant-lua-tests/tests/lua/async/spawn_pty.rs @@ -0,0 +1,519 @@ +use crate::common::{fixtures::*, lua, poll, session}; +use assert_fs::prelude::*; +use mlua::chunk; +use once_cell::sync::Lazy; +use rstest::*; + +static TEMP_SCRIPT_DIR: Lazy = Lazy::new(|| assert_fs::TempDir::new().unwrap()); +static SCRIPT_RUNNER: Lazy = Lazy::new(|| String::from("bash")); + +static ECHO_ARGS_TO_STDOUT_SH: Lazy = Lazy::new(|| { + let script = TEMP_SCRIPT_DIR.child("echo_args_to_stdout.sh"); + script + .write_str(indoc::indoc!( + r#" + #/usr/bin/env bash + printf "%s" "$*" + "# + )) + .unwrap(); + script +}); + +static ECHO_ARGS_TO_STDERR_SH: Lazy = Lazy::new(|| { + let script = TEMP_SCRIPT_DIR.child("echo_args_to_stderr.sh"); + script + .write_str(indoc::indoc!( + r#" + #/usr/bin/env bash + printf "%s" "$*" 1>&2 + "# + )) + .unwrap(); + script +}); + +static ECHO_STDIN_TO_STDOUT_SH: Lazy = Lazy::new(|| { + let script = TEMP_SCRIPT_DIR.child("echo_stdin_to_stdout.sh"); + script + .write_str(indoc::indoc!( + r#" + #/usr/bin/env bash + while IFS= read; do echo "$REPLY"; done + "# + )) + .unwrap(); + script +}); + +static SLEEP_SH: Lazy = Lazy::new(|| { + let script = TEMP_SCRIPT_DIR.child("sleep.sh"); + script + .write_str(indoc::indoc!( + r#" + #!/usr/bin/env bash + sleep "$1" + "# + )) + .unwrap(); + script +}); + +static DOES_NOT_EXIST_BIN: Lazy = + Lazy::new(|| TEMP_SCRIPT_DIR.child("does_not_exist_bin")); + +#[rstest] +fn should_return_error_on_failure(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + let cmd = DOES_NOT_EXIST_BIN.to_str().unwrap().to_string(); + let args: Vec = Vec::new(); + + let result = lua + .load(chunk! { + local session = $new_session() + local distant = require("distant_lua") + local f = distant.utils.wrap_async(session.spawn_async, $schedule_fn) + + // Because of our scheduler, the invocation turns async -> sync + local err + f(session, { cmd = $cmd, args = $args, pty = { rows = 24, cols = 80 } }, function(success, res) + if not success then + err = res + end + end) + assert(err, "Unexpectedly succeeded") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +#[rstest] +fn should_return_back_process_on_success(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + let cmd = SCRIPT_RUNNER.to_string(); + let args = vec![ECHO_ARGS_TO_STDOUT_SH.to_str().unwrap().to_string()]; + + let result = lua + .load(chunk! { + local session = $new_session() + local distant = require("distant_lua") + local f = distant.utils.wrap_async(session.spawn_async, $schedule_fn) + + // Because of our scheduler, the invocation turns async -> sync + local err, proc + f(session, { cmd = $cmd, args = $args, pty = { rows = 24, cols = 80 } }, function(success, res) + if success then + proc = res + else + err = res + end + end) + assert(not err, "Unexpectedly failed to spawn process: " .. tostring(err)) + assert(proc.id >= 0, "Invalid process returned") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +// NOTE: Ignoring on windows because it's using WSL which wants a Linux path +// with / but thinks it's on windows and is providing \ +#[rstest] +#[cfg_attr(windows, ignore)] +fn should_return_process_that_can_retrieve_stdout(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + let cmd = SCRIPT_RUNNER.to_string(); + let args = vec![ + ECHO_ARGS_TO_STDOUT_SH.to_str().unwrap().to_string(), + String::from("some stdout"), + ]; + + let wait_fn = lua + .create_function(|_, ()| { + std::thread::sleep(std::time::Duration::from_millis(50)); + Ok(()) + }) + .unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local distant = require("distant_lua") + local f = distant.utils.wrap_async(session.spawn_async, $schedule_fn) + + // Because of our scheduler, the invocation turns async -> sync + local err, proc + f(session, { cmd = $cmd, args = $args, pty = { rows = 24, cols = 80 } }, function(success, res) + if success then + proc = res + else + err = res + end + end) + assert(not err, "Unexpectedly failed to spawn process: " .. tostring(err)) + assert(proc, "Missing proc") + + // Wait briefly to ensure the process sends stdout + $wait_fn() + + local f = distant.utils.wrap_async(proc.read_stdout_async, $schedule_fn) + local err, stdout + f(proc, function(success, res) + if success then + stdout = res + else + err = res + end + end) + assert(not err, "Unexpectedly failed reading stdout: " .. tostring(err)) + + stdout = string.char(unpack(stdout)) + assert(stdout == "some stdout", "Unexpected stdout: " .. stdout) + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +// NOTE: Ignoring on windows because it's using WSL which wants a Linux path +// with / but thinks it's on windows and is providing \ +#[rstest] +#[cfg_attr(windows, ignore)] +fn should_return_process_that_can_retrieve_stderr_as_part_of_stdout(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + let cmd = SCRIPT_RUNNER.to_string(); + let args = vec![ + ECHO_ARGS_TO_STDERR_SH.to_str().unwrap().to_string(), + String::from("some stderr"), + ]; + + let wait_fn = lua + .create_function(|_, ()| { + std::thread::sleep(std::time::Duration::from_millis(50)); + Ok(()) + }) + .unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local distant = require("distant_lua") + local f = distant.utils.wrap_async(session.spawn_async, $schedule_fn) + + // Because of our scheduler, the invocation turns async -> sync + local err, proc + f(session, { cmd = $cmd, args = $args, pty = { rows = 24, cols = 80 } }, function(success, res) + if success then + proc = res + else + err = res + end + end) + assert(not err, "Unexpectedly failed to spawn process: " .. tostring(err)) + assert(proc, "Missing proc") + + // Wait briefly to ensure the process sends stdout + $wait_fn() + + // stderr is a broken pipe as it does not exist for pty + local f = distant.utils.wrap_async(proc.read_stderr_async, $schedule_fn) + local err, stderr + f(proc, function(success, res) + if success then + stderr = res + else + err = res + end + end) + assert(err) + + // in a pty process, stderr is part of stdout + local f = distant.utils.wrap_async(proc.read_stdout_async, $schedule_fn) + local err, stdout + f(proc, function(success, res) + if success then + stdout = res + else + err = res + end + end) + assert(not err, "Unexpectedly failed reading stdout: " .. tostring(err)) + + // stdout should match what we'd normally expect from stderr + stdout = string.char(unpack(stdout)) + assert(stdout == "some stderr", "Unexpected stdout: " .. stdout) + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +#[rstest] +fn should_return_error_when_killing_dead_process(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + // Spawn a process that will exit immediately, but is a valid process + let cmd = SCRIPT_RUNNER.to_string(); + let args = vec![SLEEP_SH.to_str().unwrap().to_string(), String::from("0")]; + + let wait_fn = lua + .create_function(|_, ()| { + std::thread::sleep(std::time::Duration::from_millis(50)); + Ok(()) + }) + .unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local distant = require("distant_lua") + local f = distant.utils.wrap_async(session.spawn_async, $schedule_fn) + + // Because of our scheduler, the invocation turns async -> sync + local err, proc + f(session, { cmd = $cmd, args = $args, pty = { rows = 24, cols = 80 } }, function(success, res) + if success then + proc = res + else + err = res + end + end) + assert(not err, "Unexpectedly failed to spawn process: " .. tostring(err)) + assert(proc, "Missing proc") + + // Wait briefly to ensure the process dies + $wait_fn() + + local f = distant.utils.wrap_async(proc.kill_async, $schedule_fn) + local err + f(proc, function(success, res) + if not success then + err = res + end + end) + assert(err, "Unexpectedly succeeded in killing dead process") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +#[rstest] +fn should_support_killing_processing(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + let cmd = SCRIPT_RUNNER.to_string(); + let args = vec![SLEEP_SH.to_str().unwrap().to_string(), String::from("1")]; + + let result = lua + .load(chunk! { + local session = $new_session() + local distant = require("distant_lua") + local f = distant.utils.wrap_async(session.spawn_async, $schedule_fn) + + // Because of our scheduler, the invocation turns async -> sync + local err, proc + f(session, { cmd = $cmd, args = $args, pty = { rows = 24, cols = 80 } }, function(success, res) + if success then + proc = res + else + err = res + end + end) + assert(not err, "Unexpectedly failed to spawn process: " .. tostring(err)) + assert(proc, "Missing proc") + + local f = distant.utils.wrap_async(proc.kill_async, $schedule_fn) + local err + f(proc, function(success, res) + if not success then + err = res + end + end) + assert(not err, "Unexpectedly failed to kill process: " .. tostring(err)) + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +#[rstest] +fn should_return_error_if_sending_stdin_to_dead_process(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + // Spawn a process that will exit immediately, but is a valid process + let cmd = SCRIPT_RUNNER.to_string(); + let args = vec![SLEEP_SH.to_str().unwrap().to_string(), String::from("0")]; + + let wait_fn = lua + .create_function(|_, ()| { + std::thread::sleep(std::time::Duration::from_millis(50)); + Ok(()) + }) + .unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local distant = require("distant_lua") + local f = distant.utils.wrap_async(session.spawn_async, $schedule_fn) + + // Because of our scheduler, the invocation turns async -> sync + local err, proc + f(session, { cmd = $cmd, args = $args, pty = { rows = 24, cols = 80 } }, function(success, res) + if success then + proc = res + else + err = res + end + end) + assert(not err, "Unexpectedly failed to spawn process: " .. tostring(err)) + assert(proc, "Missing proc") + + // Wait briefly to ensure the process dies + $wait_fn() + + local f = distant.utils.wrap_async(proc.write_stdin_async, $schedule_fn) + local err + f(proc, "some text\n", function(success, res) + if not success then + err = res + end + end) + assert(err, "Unexpectedly succeeded") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +// NOTE: Ignoring on windows because it's using WSL which wants a Linux path +// with / but thinks it's on windows and is providing \ +#[rstest] +#[cfg_attr(windows, ignore)] +fn should_support_sending_stdin_to_spawned_process(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + let cmd = SCRIPT_RUNNER.to_string(); + let args = vec![ECHO_STDIN_TO_STDOUT_SH.to_str().unwrap().to_string()]; + + let wait_fn = lua + .create_function(|_, ()| { + std::thread::sleep(std::time::Duration::from_millis(50)); + Ok(()) + }) + .unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local distant = require("distant_lua") + local f = distant.utils.wrap_async(session.spawn_async, $schedule_fn) + + // Because of our scheduler, the invocation turns async -> sync + local err, proc + f(session, { cmd = $cmd, args = $args, pty = { rows = 24, cols = 80 } }, function(success, res) + if success then + proc = res + else + err = res + end + end) + assert(not err, "Unexpectedly failed spawning process: " .. tostring(err)) + assert(proc, "Missing proc") + + local f = distant.utils.wrap_async(proc.write_stdin_async, $schedule_fn) + local err + f(proc, "some text\n", function(success, res) + if not success then + err = res + end + end) + assert(not err, "Unexpectedly failed writing stdin: " .. tostring(err)) + + // Wait briefly to ensure that pty reflects everything + $wait_fn() + + local f = distant.utils.wrap_async(proc.read_stdout_async, $schedule_fn) + local err, stdout + f(proc, function(success, res) + if success then + stdout = res + else + err = res + end + end) + assert(not err, "Unexpectedly failed reading stdout: " .. tostring(err)) + + // NOTE: We're removing whitespace as there's some issue with properly comparing + // due to something else being captured from pty + stdout = string.gsub(string.char(unpack(stdout)), "%s+", "") + + // TODO: Sometimes this comes back as "sometextsometext" (double) and I'm assuming + // this is part of pty output, but the tests seem to have a race condition + // to produce it, so we're just checking for either right now + assert( + stdout == "sometext" or stdout == "sometextsometext", + "Unexpected stdout received: " .. stdout + ) + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +// NOTE: Ignoring on windows because it's using WSL which wants a Linux path +// with / but thinks it's on windows and is providing \ +#[rstest] +#[cfg_attr(windows, ignore)] +fn should_support_resizing_pty(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + let cmd = SCRIPT_RUNNER.to_string(); + let args: Vec = Vec::new(); + + let result = lua + .load(chunk! { + local session = $new_session() + local distant = require("distant_lua") + local f = distant.utils.wrap_async(session.spawn_async, $schedule_fn) + + // Because of our scheduler, the invocation turns async -> sync + local err, proc + f(session, { cmd = $cmd, args = $args, pty = { rows = 24, cols = 80 } }, function(success, res) + if success then + proc = res + else + err = res + end + end) + assert(not err, "Unexpectedly failed spawning process: " .. tostring(err)) + assert(proc, "Missing proc") + + local f = distant.utils.wrap_async(proc.resize_async, $schedule_fn) + local err + f(proc, { rows = 16, cols = 40 }, function(success, res) + if not success then + err = res + end + end) + assert(not err, "Unexpectedly failed resizing proc: " .. tostring(err)) + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} diff --git a/distant-lua-tests/tests/lua/sync/mod.rs b/distant-lua-tests/tests/lua/sync/mod.rs index 94f07df..6c46fd3 100644 --- a/distant-lua-tests/tests/lua/sync/mod.rs +++ b/distant-lua-tests/tests/lua/sync/mod.rs @@ -10,6 +10,7 @@ mod read_file_text; mod remove; mod rename; mod spawn; +mod spawn_pty; mod spawn_wait; mod system_info; mod write_file; diff --git a/distant-lua-tests/tests/lua/sync/spawn.rs b/distant-lua-tests/tests/lua/sync/spawn.rs index 3d8bc2d..c0f2dc5 100644 --- a/distant-lua-tests/tests/lua/sync/spawn.rs +++ b/distant-lua-tests/tests/lua/sync/spawn.rs @@ -131,6 +131,7 @@ fn should_return_process_that_can_retrieve_stdout(ctx: &'_ DistantServerCtx) { $wait_fn() local stdout = proc:read_stdout() + stdout = string.char(unpack(stdout)) assert(stdout == "some stdout", "Unexpected stdout: " .. stdout) }) .exec(); @@ -167,6 +168,7 @@ fn should_return_process_that_can_retrieve_stderr(ctx: &'_ DistantServerCtx) { $wait_fn() local stderr = proc:read_stderr() + stderr = string.char(unpack(stderr)) assert(stderr == "some stderr", "Unexpected stderr: " .. stderr) }) .exec(); @@ -281,6 +283,7 @@ fn should_support_sending_stdin_to_spawned_process(ctx: &'_ DistantServerCtx) { $wait_fn() local stdout = proc:read_stdout() + stdout = string.char(unpack(stdout)) assert(stdout == "some text\n", "Unexpected stdin sent: " .. stdout) }) .exec(); diff --git a/distant-lua-tests/tests/lua/sync/spawn_pty.rs b/distant-lua-tests/tests/lua/sync/spawn_pty.rs new file mode 100644 index 0000000..2ab2767 --- /dev/null +++ b/distant-lua-tests/tests/lua/sync/spawn_pty.rs @@ -0,0 +1,324 @@ +use crate::common::{fixtures::*, lua, session}; +use assert_fs::prelude::*; +use mlua::chunk; +use once_cell::sync::Lazy; +use rstest::*; + +static TEMP_SCRIPT_DIR: Lazy = Lazy::new(|| assert_fs::TempDir::new().unwrap()); +static SCRIPT_RUNNER: Lazy = Lazy::new(|| String::from("bash")); + +static ECHO_ARGS_TO_STDOUT_SH: Lazy = Lazy::new(|| { + let script = TEMP_SCRIPT_DIR.child("echo_args_to_stdout.sh"); + script + .write_str(indoc::indoc!( + r#" + #/usr/bin/env bash + printf "%s" "$*" + "# + )) + .unwrap(); + script +}); + +static ECHO_ARGS_TO_STDERR_SH: Lazy = Lazy::new(|| { + let script = TEMP_SCRIPT_DIR.child("echo_args_to_stderr.sh"); + script + .write_str(indoc::indoc!( + r#" + #/usr/bin/env bash + printf "%s" "$*" 1>&2 + "# + )) + .unwrap(); + script +}); + +static ECHO_STDIN_TO_STDOUT_SH: Lazy = Lazy::new(|| { + let script = TEMP_SCRIPT_DIR.child("echo_stdin_to_stdout.sh"); + script + .write_str(indoc::indoc!( + r#" + #/usr/bin/env bash + while IFS= read; do echo "$REPLY"; done + "# + )) + .unwrap(); + script +}); + +static SLEEP_SH: Lazy = Lazy::new(|| { + let script = TEMP_SCRIPT_DIR.child("sleep.sh"); + script + .write_str(indoc::indoc!( + r#" + #!/usr/bin/env bash + sleep "$1" + "# + )) + .unwrap(); + script +}); + +static DOES_NOT_EXIST_BIN: Lazy = + Lazy::new(|| TEMP_SCRIPT_DIR.child("does_not_exist_bin")); + +#[rstest] +fn should_return_error_on_failure(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + let cmd = DOES_NOT_EXIST_BIN.to_str().unwrap().to_string(); + let args: Vec = Vec::new(); + + let result = lua + .load(chunk! { + local session = $new_session() + local status, _ = pcall(session.spawn, session, { + cmd = $cmd, + args = $args, + pty = { rows = 24, cols = 80 } + }) + assert(not status, "Unexpectedly succeeded!") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +#[rstest] +fn should_return_back_process_on_success(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + let cmd = SCRIPT_RUNNER.to_string(); + let args = vec![ECHO_ARGS_TO_STDOUT_SH.to_str().unwrap().to_string()]; + + let result = lua + .load(chunk! { + local session = $new_session() + local proc = session:spawn({ cmd = $cmd, args = $args, pty = { rows = 24, cols = 80 } }) + assert(proc.id >= 0, "Invalid process returned") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +// NOTE: Ignoring on windows because it's using WSL which wants a Linux path +// with / but thinks it's on windows and is providing \ +#[rstest] +#[cfg_attr(windows, ignore)] +fn should_return_process_that_can_retrieve_stdout(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + let cmd = SCRIPT_RUNNER.to_string(); + let args = vec![ + ECHO_ARGS_TO_STDOUT_SH.to_str().unwrap().to_string(), + String::from("some stdout"), + ]; + + let wait_fn = lua + .create_function(|_, ()| { + std::thread::sleep(std::time::Duration::from_millis(50)); + Ok(()) + }) + .unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local proc = session:spawn({ cmd = $cmd, args = $args, rows = 24, cols = 80 }) + + // Wait briefly to ensure the process sends stdout + $wait_fn() + + local stdout = proc:read_stdout() + stdout = string.char(unpack(stdout)) + assert(stdout == "some stdout", "Unexpected stdout: " .. stdout) + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +// NOTE: Ignoring on windows because it's using WSL which wants a Linux path +// with / but thinks it's on windows and is providing \ +#[rstest] +#[cfg_attr(windows, ignore)] +fn should_return_process_that_can_retrieve_stderr(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + let cmd = SCRIPT_RUNNER.to_string(); + let args = vec![ + ECHO_ARGS_TO_STDERR_SH.to_str().unwrap().to_string(), + String::from("some stderr"), + ]; + + let wait_fn = lua + .create_function(|_, ()| { + std::thread::sleep(std::time::Duration::from_millis(50)); + Ok(()) + }) + .unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local proc = session:spawn({ cmd = $cmd, args = $args, rows = 24, cols = 80 }) + + // Wait briefly to ensure the process sends stdout + $wait_fn() + + local stderr = proc:read_stderr() + stderr = string.char(unpack(stderr)) + assert(stderr == "some stderr", "Unexpected stderr: " .. stderr) + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +#[rstest] +fn should_return_error_when_killing_dead_process(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + // Spawn a process that will exit immediately, but is a valid process + let cmd = SCRIPT_RUNNER.to_string(); + let args = vec![SLEEP_SH.to_str().unwrap().to_string(), String::from("0")]; + + let wait_fn = lua + .create_function(|_, ()| { + std::thread::sleep(std::time::Duration::from_millis(50)); + Ok(()) + }) + .unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local proc = session:spawn({ cmd = $cmd, args = $args, rows = 24, cols = 80 }) + + // Wait briefly to ensure the process dies + $wait_fn() + + local status, _ = pcall(proc.kill, proc) + assert(not status, "Unexpectedly succeeded") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +#[rstest] +fn should_support_killing_processing(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + let cmd = SCRIPT_RUNNER.to_string(); + let args = vec![SLEEP_SH.to_str().unwrap().to_string(), String::from("1")]; + + let result = lua + .load(chunk! { + local session = $new_session() + local proc = session:spawn({ cmd = $cmd, args = $args, rows = 24, cols = 80 }) + proc:kill() + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +#[rstest] +fn should_return_error_if_sending_stdin_to_dead_process(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + // Spawn a process that will exit immediately, but is a valid process + let cmd = SCRIPT_RUNNER.to_string(); + let args = vec![SLEEP_SH.to_str().unwrap().to_string(), String::from("0")]; + + let wait_fn = lua + .create_function(|_, ()| { + std::thread::sleep(std::time::Duration::from_millis(50)); + Ok(()) + }) + .unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local proc = session:spawn({ cmd = $cmd, args = $args, rows = 24, cols = 80 }) + + // Wait briefly to ensure the process dies + $wait_fn() + + local status, _ = pcall(proc.write_stdin, proc, "some text") + assert(not status, "Unexpectedly succeeded") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +// NOTE: Ignoring on windows because it's using WSL which wants a Linux path +// with / but thinks it's on windows and is providing \ +#[rstest] +#[cfg_attr(windows, ignore)] +fn should_support_sending_stdin_to_spawned_process(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + let cmd = SCRIPT_RUNNER.to_string(); + let args = vec![ECHO_STDIN_TO_STDOUT_SH.to_str().unwrap().to_string()]; + + let wait_fn = lua + .create_function(|_, ()| { + std::thread::sleep(std::time::Duration::from_millis(50)); + Ok(()) + }) + .unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local proc = session:spawn({ cmd = $cmd, args = $args, rows = 24, cols = 80 }) + proc:write_stdin("some text\n") + + // Wait briefly to ensure the process echoes stdin + $wait_fn() + + local stdout = proc:read_stdout() + stdout = string.char(unpack(stdout)) + assert(stdout == "some text\n", "Unexpected stdin sent: " .. stdout) + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +// NOTE: Ignoring on windows because it's using WSL which wants a Linux path +// with / but thinks it's on windows and is providing \ +#[rstest] +#[cfg_attr(windows, ignore)] +fn should_support_resizing_pty(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + let cmd = SCRIPT_RUNNER.to_string(); + let args: Vec = Vec::new(); + + let wait_fn = lua + .create_function(|_, ()| { + std::thread::sleep(std::time::Duration::from_millis(50)); + Ok(()) + }) + .unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local proc = session:spawn({ cmd = $cmd, args = $args, rows = 24, cols = 80 }) + + // Wait briefly to ensure the process starts + $wait_fn() + + proc:resize({ rows = 16, cols = 40 }) + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} diff --git a/distant-lua-tests/tests/lua/sync/spawn_wait.rs b/distant-lua-tests/tests/lua/sync/spawn_wait.rs index 79f898d..16ea86e 100644 --- a/distant-lua-tests/tests/lua/sync/spawn_wait.rs +++ b/distant-lua-tests/tests/lua/sync/spawn_wait.rs @@ -96,8 +96,13 @@ fn should_capture_all_stdout(ctx: &'_ DistantServerCtx) { local output = session:spawn_wait({ cmd = $cmd, args = $args }) assert(output, "Missing process output") assert(output.success, "Process unexpectedly failed") - assert(output.stdout == "some stdout", "Unexpected stdout: " .. output.stdout) - assert(output.stderr == "", "Unexpected stderr: " .. output.stderr) + + local stdout, stderr + stdout = string.char(unpack(output.stdout)) + stderr = string.char(unpack(output.stderr)) + + assert(stdout == "some stdout", "Unexpected stdout: " .. stdout) + assert(stderr == "", "Unexpected stderr: " .. stderr) }) .exec(); assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); @@ -123,8 +128,13 @@ fn should_capture_all_stderr(ctx: &'_ DistantServerCtx) { local output = session:spawn_wait({ cmd = $cmd, args = $args }) assert(output, "Missing process output") assert(output.success, "Process unexpectedly failed") - assert(output.stdout == "", "Unexpected stdout: " .. output.stdout) - assert(output.stderr == "some stderr", "Unexpected stderr: " .. output.stderr) + + local stdout, stderr + stdout = string.char(unpack(output.stdout)) + stderr = string.char(unpack(output.stderr)) + + assert(stdout == "", "Unexpected stdout: " .. stdout) + assert(stderr == "some stderr", "Unexpected stderr: " .. stderr) }) .exec(); assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); diff --git a/distant-lua/Cargo.toml b/distant-lua/Cargo.toml index 4c12beb..4c9b19f 100644 --- a/distant-lua/Cargo.toml +++ b/distant-lua/Cargo.toml @@ -3,7 +3,7 @@ name = "distant-lua" description = "Lua bindings to the distant Rust crates" categories = ["api-bindings", "network-programming"] keywords = ["api", "async"] -version = "0.15.1" +version = "0.16.0" authors = ["Chip Senkbeil "] edition = "2018" homepage = "https://github.com/chipsenkbeil/distant" @@ -24,11 +24,11 @@ luajit = ["mlua/luajit"] vendored = ["mlua/vendored"] [dependencies] -distant-core = { version = "=0.15.1", path = "../distant-core" } -distant-ssh2 = { version = "=0.15.1", features = ["serde"], path = "../distant-ssh2" } +distant-core = { version = "=0.16.0", path = "../distant-core" } +distant-ssh2 = { version = "=0.16.0", features = ["serde"], path = "../distant-ssh2" } futures = "0.3.17" log = "0.4.14" -mlua = { version = "0.6.6", features = ["async", "macros", "module", "serialize"] } +mlua = { version = "0.7.3", features = ["async", "macros", "module", "serialize"] } once_cell = "1.8.0" oorandom = "11.1.3" paste = "1.0.5" diff --git a/distant-lua/src/session/api.rs b/distant-lua/src/session/api.rs index 11e0747..d559616 100644 --- a/distant-lua/src/session/api.rs +++ b/distant-lua/src/session/api.rs @@ -3,8 +3,8 @@ use crate::{ session::proc::{Output, RemoteProcess as LuaRemoteProcess}, }; use distant_core::{ - DirEntry, Error as Failure, Metadata, RemoteLspProcess, RemoteProcess, SessionChannel, - SessionChannelExt, SystemInfo, + data::PtySize, DirEntry, Error as Failure, Metadata, RemoteLspProcess, RemoteProcess, + SessionChannel, SessionChannelExt, SystemInfo, }; use mlua::prelude::*; use once_cell::sync::Lazy; @@ -164,22 +164,23 @@ make_api!( make_api!( spawn, RemoteProcess, - { cmd: String, #[serde(default)] args: Vec, #[serde(default)] detached: bool }, + { cmd: String, #[serde(default)] args: Vec, #[serde(default)] pty: Option, #[serde(default)] detached: bool }, |channel, tenant, params| { - channel.spawn(tenant, params.cmd, params.args, params.detached).await + channel.spawn(tenant, params.cmd, params.args, params.detached, params.pty).await } ); make_api!( spawn_wait, Output, - { cmd: String, #[serde(default)] args: Vec, #[serde(default)] detached: bool }, + { cmd: String, #[serde(default)] args: Vec, #[serde(default)] pty: Option, #[serde(default)] detached: bool }, |channel, tenant, params| { let proc = channel.spawn( tenant, params.cmd, params.args, params.detached, + params.pty, ).await.to_lua_err()?; let id = LuaRemoteProcess::from_distant_async(proc).await?.id; LuaRemoteProcess::output_async(id).await @@ -189,9 +190,9 @@ make_api!( make_api!( spawn_lsp, RemoteLspProcess, - { cmd: String, #[serde(default)] args: Vec, #[serde(default)] detached: bool }, + { cmd: String, #[serde(default)] args: Vec, #[serde(default)] pty: Option, #[serde(default)] detached: bool }, |channel, tenant, params| { - channel.spawn_lsp(tenant, params.cmd, params.args, params.detached).await + channel.spawn_lsp(tenant, params.cmd, params.args, params.detached, params.pty).await } ); diff --git a/distant-lua/src/session/proc.rs b/distant-lua/src/session/proc.rs index a80094d..41485bc 100644 --- a/distant-lua/src/session/proc.rs +++ b/distant-lua/src/session/proc.rs @@ -1,6 +1,7 @@ use crate::{constants::PROC_POLL_TIMEOUT, runtime}; use distant_core::{ - RemoteLspProcess as DistantRemoteLspProcess, RemoteProcess as DistantRemoteProcess, + data::PtySize, RemoteLspProcess as DistantRemoteLspProcess, + RemoteProcess as DistantRemoteProcess, }; use mlua::{prelude::*, UserData, UserDataFields, UserDataMethods}; use once_cell::sync::Lazy; @@ -86,7 +87,7 @@ macro_rules! impl_process { .ok_or_else(|| { io::Error::new(io::ErrorKind::BrokenPipe, "Stdin closed").to_lua_err() })? - .try_write(data.as_str()); + .try_write(data.as_bytes()); match res { Ok(_) => Ok(true), Err(x) if x.kind() == io::ErrorKind::WouldBlock => Ok(false), @@ -112,7 +113,7 @@ macro_rules! impl_process { }) } - fn read_stdout(id: usize) -> LuaResult> { + fn read_stdout(id: usize) -> LuaResult>> { with_proc!($map_name, id, proc -> { proc.stdout .as_mut() @@ -124,7 +125,7 @@ macro_rules! impl_process { }) } - async fn read_stdout_async(id: usize) -> LuaResult { + async fn read_stdout_async(id: usize) -> LuaResult> { // NOTE: We must spawn a task that continually tries to read stdout as // if we wait until successful then we hold the lock the entire time runtime::spawn(async move { @@ -146,7 +147,7 @@ macro_rules! impl_process { }).await } - fn read_stderr(id: usize) -> LuaResult> { + fn read_stderr(id: usize) -> LuaResult>> { with_proc!($map_name, id, proc -> { proc.stderr .as_mut() @@ -158,7 +159,7 @@ macro_rules! impl_process { }) } - async fn read_stderr_async(id: usize) -> LuaResult { + async fn read_stderr_async(id: usize) -> LuaResult> { // NOTE: We must spawn a task that continually tries to read stdout as // if we wait until successful then we hold the lock the entire time runtime::spawn(async move { @@ -180,6 +181,16 @@ macro_rules! impl_process { }).await } + fn resize(id: usize, size: PtySize) -> LuaResult<()> { + runtime::block_on(Self::resize_async(id, size)) + } + + async fn resize_async(id: usize, size: PtySize) -> LuaResult<()> { + with_proc_async!($map_name, id, proc -> { + proc.resize(size).await.to_lua_err() + }) + } + fn kill(id: usize) -> LuaResult<()> { runtime::block_on(Self::kill_async(id)) } @@ -245,14 +256,14 @@ macro_rules! impl_process { // Gather stdout and stderr after process completes let (success, exit_code) = proc.wait().await.to_lua_err()?; - let mut stdout_buf = String::new(); + let mut stdout_buf = Vec::new(); while let Ok(Some(data)) = stdout.try_read() { - stdout_buf.push_str(&data); + stdout_buf.extend(data); } - let mut stderr_buf = String::new(); + let mut stderr_buf = Vec::new(); while let Ok(Some(data)) = stderr.try_read() { - stderr_buf.push_str(&data); + stderr_buf.extend(data); } Ok(Output { @@ -304,6 +315,17 @@ macro_rules! impl_process { methods.add_async_method("output_async", |_, this, ()| { runtime::spawn(Self::output_async(this.id)) }); + methods.add_method("resize", |lua, this, value: LuaValue| { + let size: PtySize = lua.from_value(value)?; + Self::resize(this.id, size) + }); + methods.add_async_method("resize_async", |lua, this, value: LuaValue| { + let size: LuaResult = lua.from_value(value); + runtime::spawn(async move { + let size = size?; + Self::resize_async(this.id, size).await + }) + }); methods.add_method("kill", |_, this, ()| Self::kill(this.id)); methods.add_async_method("kill_async", |_, this, ()| { runtime::spawn(Self::kill_async(this.id)) @@ -342,16 +364,16 @@ impl UserData for Status { pub struct Output { pub success: bool, pub exit_code: Option, - pub stdout: String, - pub stderr: String, + pub stdout: Vec, + pub stderr: Vec, } impl UserData for Output { fn add_fields<'lua, F: UserDataFields<'lua, Self>>(fields: &mut F) { fields.add_field_method_get("success", |_, this| Ok(this.success)); fields.add_field_method_get("exit_code", |_, this| Ok(this.exit_code)); - fields.add_field_method_get("stdout", |_, this| Ok(this.stdout.to_string())); - fields.add_field_method_get("stderr", |_, this| Ok(this.stderr.to_string())); + fields.add_field_method_get("stdout", |_, this| Ok(this.stdout.to_vec())); + fields.add_field_method_get("stderr", |_, this| Ok(this.stderr.to_vec())); } fn add_methods<'lua, M: UserDataMethods<'lua, Self>>(methods: &mut M) { @@ -359,8 +381,8 @@ impl UserData for Output { let tbl = lua.create_table()?; tbl.set("success", this.success)?; tbl.set("exit_code", this.exit_code)?; - tbl.set("stdout", this.stdout.to_string())?; - tbl.set("stderr", this.stdout.to_string())?; + tbl.set("stdout", this.stdout.to_vec())?; + tbl.set("stderr", this.stdout.to_vec())?; Ok(tbl) }); } diff --git a/distant-ssh2/Cargo.toml b/distant-ssh2/Cargo.toml index 8b47e3f..18d6829 100644 --- a/distant-ssh2/Cargo.toml +++ b/distant-ssh2/Cargo.toml @@ -2,7 +2,7 @@ name = "distant-ssh2" description = "Library to enable native ssh-2 protocol for use with distant sessions" categories = ["network-programming"] -version = "0.15.1" +version = "0.16.0" authors = ["Chip Senkbeil "] edition = "2018" homepage = "https://github.com/chipsenkbeil/distant" @@ -12,7 +12,7 @@ license = "MIT OR Apache-2.0" [dependencies] async-compat = "0.2.1" -distant-core = { version = "=0.15.1", path = "../distant-core" } +distant-core = { version = "=0.16.0", path = "../distant-core" } futures = "0.3.16" log = "0.4.14" rand = { version = "0.8.4", features = ["getrandom"] } @@ -20,7 +20,7 @@ rpassword = "5.0.1" shell-words = "1.0" smol = "1.2" tokio = { version = "1.12.0", features = ["full"] } -wezterm-ssh = { version = "0.2.0", features = ["vendored-openssl"] } +wezterm-ssh = { version = "0.4.0", features = ["vendored-openssl"] } # Optional serde support for data structures serde = { version = "1.0.126", features = ["derive"], optional = true } diff --git a/distant-ssh2/src/handler.rs b/distant-ssh2/src/handler.rs index 87339e7..ade7b16 100644 --- a/distant-ssh2/src/handler.rs +++ b/distant-ssh2/src/handler.rs @@ -1,6 +1,9 @@ +use crate::process::{self, SpawnResult}; use async_compat::CompatExt; use distant_core::{ - data::{DirEntry, Error as DistantError, FileType, Metadata, RunningProcess, SystemInfo}, + data::{ + DirEntry, Error as DistantError, FileType, Metadata, PtySize, RunningProcess, SystemInfo, + }, Request, RequestData, Response, ResponseData, }; use futures::future; @@ -8,18 +11,13 @@ use log::*; use std::{ collections::HashMap, future::Future, - io::{self, Read, Write}, + io, path::{Component, PathBuf}, pin::Pin, sync::Arc, }; use tokio::sync::{mpsc, Mutex}; -use wezterm_ssh::{ - Child, ExecResult, FilePermissions, OpenFileType, OpenOptions, Session as WezSession, WriteMode, -}; - -const MAX_PIPE_CHUNK_SIZE: usize = 8192; -const READ_PAUSE_MILLIS: u64 = 50; +use wezterm_ssh::{FilePermissions, OpenFileType, OpenOptions, Session as WezSession, WriteMode}; fn to_other_error(err: E) -> io::Error where @@ -38,13 +36,14 @@ struct Process { cmd: String, args: Vec, detached: bool, - stdin_tx: mpsc::Sender, + stdin_tx: mpsc::Sender>, kill_tx: mpsc::Sender<()>, + resize_tx: mpsc::Sender, } type ReplyRet = Pin + Send + 'static>>; -type PostHook = Box; +type PostHook = Box>) + Send>; struct Outgoing { data: ResponseData, post_hook: Option, @@ -66,15 +65,11 @@ pub(super) async fn process( req: Request, tx: mpsc::Sender, ) -> Result<(), mpsc::error::SendError> { - async fn inner( + async fn inner( session: WezSession, state: Arc>, data: RequestData, - reply: F, - ) -> io::Result - where - F: FnMut(Vec) -> ReplyRet + Clone + Send + 'static, - { + ) -> io::Result { match data { RequestData::FileRead { path } => file_read(session, path).await, RequestData::FileReadText { path } => file_read_text(session, path).await, @@ -103,7 +98,11 @@ pub(super) async fn process( cmd, args, detached, - } => proc_run(session, state, reply, cmd, args, detached).await, + pty, + } => proc_spawn(session, state, cmd, args, detached, pty).await, + RequestData::ProcResizePty { id, size } => { + proc_resize_pty(session, state, id, size).await + } RequestData::ProcKill { id } => proc_kill(session, state, id).await, RequestData::ProcStdin { id, data } => proc_stdin(session, state, id, data).await, RequestData::ProcList {} => proc_list(session, state).await, @@ -126,10 +125,9 @@ pub(super) async fn process( let mut payload_tasks = Vec::new(); for data in req.payload { let state_2 = Arc::clone(&state); - let reply_2 = reply.clone(); let session = session.clone(); payload_tasks.push(tokio::spawn(async move { - match inner(session, state_2, data, reply_2).await { + match inner(session, state_2, data).await { Ok(outgoing) => outgoing, Err(x) => Outgoing::from(ResponseData::from(x)), } @@ -156,9 +154,18 @@ pub(super) async fn process( // Send out our primary response from processing the request let result = tx.send(res).await; + let (tx, mut rx) = mpsc::channel(1); + tokio::spawn(async move { + while let Some(payload) = rx.recv().await { + if !reply(payload).await { + break; + } + } + }); + // Invoke all post hooks for hook in post_hooks { - hook(); + hook(tx.clone()); } result @@ -602,54 +609,33 @@ async fn metadata( }))) } -async fn proc_run( +async fn proc_spawn( session: WezSession, state: Arc>, - reply: F, cmd: String, args: Vec, detached: bool, -) -> io::Result -where - F: FnMut(Vec) -> ReplyRet + Clone + Send + 'static, -{ - let id = rand::random(); + pty: Option, +) -> io::Result { let cmd_string = format!("{} {}", cmd, args.join(" ")); + debug!(" Spawning {} (pty: {:?})", cmd_string, pty); - debug!(" Spawning {}", id, cmd_string); - let ExecResult { - mut stdin, - mut stdout, - mut stderr, - mut child, - } = session - .exec(&cmd_string, None) - .compat() - .await - .map_err(to_other_error)?; + let state_2 = Arc::clone(&state); + let cleanup = |id: usize| async move { + state_2.lock().await.processes.remove(&id); + }; - // Force stdin, stdout, and stderr to be nonblocking - stdin - .set_non_blocking(true) - .map_err(|x| io::Error::new(io::ErrorKind::Other, x))?; - stdout - .set_non_blocking(true) - .map_err(|x| io::Error::new(io::ErrorKind::Other, x))?; - stderr - .set_non_blocking(true) - .map_err(|x| io::Error::new(io::ErrorKind::Other, x))?; - - // Check if the process died immediately and report - // an error if that's the case - if let Ok(Some(exit_status)) = child.try_wait() { - return Err(io::Error::new( - io::ErrorKind::BrokenPipe, - format!("Process exited early: {:?}", exit_status), - )); - } + let SpawnResult { + id, + stdin, + killer, + resizer, + initialize, + } = match pty { + None => process::spawn_simple(&session, &cmd_string, cleanup).await?, + Some(size) => process::spawn_pty(&session, &cmd_string, size, cleanup).await?, + }; - let (stdin_tx, mut stdin_rx) = mpsc::channel(1); - let (kill_tx, mut kill_rx) = mpsc::channel(1); state.lock().await.processes.insert( id, Process { @@ -657,195 +643,40 @@ where cmd, args, detached, - stdin_tx, - kill_tx, + stdin_tx: stdin, + kill_tx: killer, + resize_tx: resizer, }, ); - let post_hook = Box::new(move || { - // Spawn a task that sends stdout as a response - let mut reply_2 = reply.clone(); - let stdout_task = tokio::spawn(async move { - let mut buf: [u8; MAX_PIPE_CHUNK_SIZE] = [0; MAX_PIPE_CHUNK_SIZE]; - loop { - match stdout.read(&mut buf) { - Ok(n) if n > 0 => match String::from_utf8(buf[..n].to_vec()) { - Ok(data) => { - let payload = vec![ResponseData::ProcStdout { id, data }]; - if !reply_2(payload).await { - error!(" Stdout channel closed", id); - break; - } - - // Pause to allow buffer to fill up a little bit, avoiding - // spamming with a lot of smaller responses - tokio::time::sleep(tokio::time::Duration::from_millis( - READ_PAUSE_MILLIS, - )) - .await; - } - Err(x) => { - error!( - " Invalid data read from stdout pipe: {}", - id, x - ); - break; - } - }, - Ok(_) => break, - Err(x) if x.kind() == io::ErrorKind::WouldBlock => { - // Pause to allow buffer to fill up a little bit, avoiding - // spamming with a lot of smaller responses - tokio::time::sleep(tokio::time::Duration::from_millis(READ_PAUSE_MILLIS)) - .await; - } - Err(x) => { - error!(" Stdout unexpectedly closed: {}", id, x); - break; - } - } - } - }); - - // Spawn a task that sends stderr as a response - let mut reply_2 = reply.clone(); - let stderr_task = tokio::spawn(async move { - let mut buf: [u8; MAX_PIPE_CHUNK_SIZE] = [0; MAX_PIPE_CHUNK_SIZE]; - loop { - match stderr.read(&mut buf) { - Ok(n) if n > 0 => match String::from_utf8(buf[..n].to_vec()) { - Ok(data) => { - let payload = vec![ResponseData::ProcStderr { id, data }]; - if !reply_2(payload).await { - error!(" Stderr channel closed", id); - break; - } - - // Pause to allow buffer to fill up a little bit, avoiding - // spamming with a lot of smaller responses - tokio::time::sleep(tokio::time::Duration::from_millis( - READ_PAUSE_MILLIS, - )) - .await; - } - Err(x) => { - error!( - " Invalid data read from stderr pipe: {}", - id, x - ); - break; - } - }, - Ok(_) => break, - Err(x) if x.kind() == io::ErrorKind::WouldBlock => { - // Pause to allow buffer to fill up a little bit, avoiding - // spamming with a lot of smaller responses - tokio::time::sleep(tokio::time::Duration::from_millis(READ_PAUSE_MILLIS)) - .await; - } - Err(x) => { - error!(" Stderr unexpectedly closed: {}", id, x); - break; - } - } - } - }); - - let stdin_task = tokio::spawn(async move { - while let Some(line) = stdin_rx.recv().await { - if let Err(x) = stdin.write_all(line.as_bytes()) { - error!(" Failed to send stdin: {}", id, x); - break; - } - } - }); - - // Spawn a task that waits on the process to exit but can also - // kill the process when triggered - let state_2 = Arc::clone(&state); - let mut reply_2 = reply.clone(); - tokio::spawn(async move { - let mut should_kill = false; - let mut success = false; - tokio::select! { - _ = kill_rx.recv() => { - should_kill = true; - } - result = child.async_wait().compat() => { - match result { - Ok(status) => { - success = status.success(); - } - Err(x) => { - error!(" Waiting on process failed: {}", id, x); - } - } - } - } - - // Force stdin task to abort if it hasn't exited as there is no - // point to sending any more stdin - stdin_task.abort(); - - if should_kill { - debug!(" Killing", id); - - if let Err(x) = child.kill() { - error!(" Unable to kill process: {}", id, x); - } - - // NOTE: At the moment, child.kill does nothing for wezterm_ssh::SshChildProcess; - // so, we need to manually run kill/taskkill to make sure that the - // process is sent a kill signal - if let Some(pid) = child.process_id() { - let _ = session - .exec(&format!("kill -9 {}", pid), None) - .compat() - .await; - let _ = session - .exec(&format!("taskkill /F /PID {}", pid), None) - .compat() - .await; - } - } else { - debug!( - " Completed and waiting on stdout & stderr tasks", - id - ); - } - - if let Err(x) = stderr_task.await { - error!(" Join on stderr task failed: {}", id, x); - } - - if let Err(x) = stdout_task.await { - error!(" Join on stdout task failed: {}", id, x); - } - - state_2.lock().await.processes.remove(&id); - - let payload = vec![ResponseData::ProcDone { - id, - success: !should_kill && success, - code: if success { Some(0) } else { None }, - }]; - - if !reply_2(payload).await { - error!(" Failed to send done", id,); - } - }); - }); - debug!( " Spawned successfully! Will enter post hook later", id ); Ok(Outgoing { data: ResponseData::ProcSpawned { id }, - post_hook: Some(post_hook), + post_hook: Some(initialize), }) } +async fn proc_resize_pty( + _session: WezSession, + state: Arc>, + id: usize, + size: PtySize, +) -> io::Result { + if let Some(process) = state.lock().await.processes.get(&id) { + if process.resize_tx.send(size).await.is_ok() { + return Ok(Outgoing::from(ResponseData::Ok)); + } + } + + Err(io::Error::new( + io::ErrorKind::BrokenPipe, + format!(" Unable to resize process", id), + )) +} + async fn proc_kill( _session: WezSession, state: Arc>, @@ -867,7 +698,7 @@ async fn proc_stdin( _session: WezSession, state: Arc>, id: usize, - data: String, + data: Vec, ) -> io::Result { if let Some(process) = state.lock().await.processes.get_mut(&id) { if process.stdin_tx.send(data).await.is_ok() { @@ -892,6 +723,8 @@ async fn proc_list(_session: WezSession, state: Arc>) -> io::Result cmd: p.cmd.to_string(), args: p.args.clone(), detached: p.detached, + // TODO: Support pty size from ssh + pty: None, id: p.id, }) .collect(), diff --git a/distant-ssh2/src/lib.rs b/distant-ssh2/src/lib.rs index d5536bb..3534a3f 100644 --- a/distant-ssh2/src/lib.rs +++ b/distant-ssh2/src/lib.rs @@ -7,6 +7,7 @@ use log::*; use smol::channel::Receiver as SmolReceiver; use std::{ collections::BTreeMap, + fmt, io::{self, Write}, net::{IpAddr, SocketAddr}, path::PathBuf, @@ -17,6 +18,38 @@ use tokio::sync::{mpsc, Mutex}; use wezterm_ssh::{Config as WezConfig, Session as WezSession, SessionEvent as WezSessionEvent}; mod handler; +mod process; + +/// Represents the backend to use for ssh operations +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))] +pub enum SshBackend { + /// Use libssh as backend + LibSsh, + + /// Use ssh2 as backend + Ssh2, +} + +impl Default for SshBackend { + /// Defaults to ssh2 + /// + /// NOTE: There are currently bugs in libssh that cause our implementation to hang related to + /// process stdout/stderr and maybe other logic. + fn default() -> Self { + Self::Ssh2 + } +} + +impl fmt::Display for SshBackend { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::LibSsh => write!(f, "libssh"), + Self::Ssh2 => write!(f, "ssh2"), + } + } +} /// Represents a singular authentication prompt for a new ssh session #[derive(Debug)] @@ -50,6 +83,9 @@ pub struct Ssh2AuthEvent { #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(default))] pub struct Ssh2SessionOpts { + /// Represents the backend to use for ssh operations + pub backend: SshBackend, + /// List of files from which the user's DSA, ECDSA, Ed25519, or RSA authentication identity /// is read, defaulting to /// @@ -80,6 +116,9 @@ pub struct Ssh2SessionOpts { /// - `~/.ssh/known_hosts2` pub user_known_hosts_files: Vec, + /// If true, will output tracing information from the underlying ssh implementation + pub verbose: bool, + /// Additional options to provide as defined by `ssh_config(5)` pub other: BTreeMap, } @@ -249,6 +288,12 @@ impl Ssh2Session { ); } + // Set verbosity optin for ssh lib + config.insert("wezterm_ssh_verbose".to_string(), opts.verbose.to_string()); + + // Set the backend to use going forward + config.insert("wezterm_ssh_backend".to_string(), opts.backend.to_string()); + // Add in any of the other options provided config.extend(opts.other); @@ -459,7 +504,7 @@ impl Ssh2Session { // ssh session is closed debug!("Executing {} {}", bin, args.join(" ")); let mut proc = session - .spawn("", bin, args, true) + .spawn("", bin, args, true, None) .await .map_err(|x| io::Error::new(io::ErrorKind::Other, x))?; let mut stdout = proc.stdout.take().unwrap(); @@ -472,17 +517,19 @@ impl Ssh2Session { // Close out ssh session session.abort(); let _ = session.wait().await; - let mut output = String::new(); + let mut output = Vec::new(); // If successful, grab the session information and establish a connection // with the distant server if success { while let Ok(data) = stdout.read().await { - output.push_str(&data); + output.extend(&data); } + // Iterate over output as individual lines, looking for session info let maybe_info = output - .lines() + .split(|&b| b == b'\n') + .map(String::from_utf8_lossy) .find_map(|line| line.parse::().ok()); match maybe_info { Some(mut info) => { @@ -496,7 +543,7 @@ impl Ssh2Session { } } else { while let Ok(data) = stderr.read().await { - output.push_str(&data); + output.extend(&data); } Err(io::Error::new( @@ -505,7 +552,10 @@ impl Ssh2Session { "Spawning distant failed [{}]: {}", code.map(|x| x.to_string()) .unwrap_or_else(|| String::from("???")), - output + match String::from_utf8(output) { + Ok(output) => output, + Err(x) => x.to_string(), + } ), )) } diff --git a/distant-ssh2/src/process.rs b/distant-ssh2/src/process.rs new file mode 100644 index 0000000..05ccd49 --- /dev/null +++ b/distant-ssh2/src/process.rs @@ -0,0 +1,411 @@ +use async_compat::CompatExt; +use distant_core::{PtySize, ResponseData}; +use log::*; +use std::{ + future::Future, + io::{self, Read, Write}, + time::Duration, +}; +use tokio::{sync::mpsc, task::JoinHandle}; +use wezterm_ssh::{ + Child, ChildKiller, ExecResult, MasterPty, PtySize as PortablePtySize, Session, SshChildProcess, +}; + +const MAX_PIPE_CHUNK_SIZE: usize = 8192; +const THREAD_PAUSE_MILLIS: u64 = 50; + +/// Result of spawning a process, containing means to send stdin, means to kill the process, +/// and the initialization function to use to start processing stdin, stdout, and stderr +pub struct SpawnResult { + pub id: usize, + pub stdin: mpsc::Sender>, + pub killer: mpsc::Sender<()>, + pub resizer: mpsc::Sender, + pub initialize: Box>) + Send>, +} + +/// Spawns a non-pty process, returning a function that initializes processing +/// stdin, stdout, and stderr once called (for lazy processing) +pub async fn spawn_simple(session: &Session, cmd: &str, cleanup: F) -> io::Result +where + F: FnOnce(usize) -> R + Send + 'static, + R: Future + Send + 'static, +{ + let ExecResult { + mut stdin, + mut stdout, + mut stderr, + mut child, + } = session + .exec(cmd, None) + .compat() + .await + .map_err(to_other_error)?; + + // Update to be nonblocking for reading and writing + stdin.set_non_blocking(true).map_err(to_other_error)?; + stdout.set_non_blocking(true).map_err(to_other_error)?; + stderr.set_non_blocking(true).map_err(to_other_error)?; + + // Check if the process died immediately and report + // an error if that's the case + if let Ok(Some(exit_status)) = child.try_wait() { + return Err(io::Error::new( + io::ErrorKind::BrokenPipe, + format!("Process exited early: {:?}", exit_status), + )); + } + + let (stdin_tx, stdin_rx) = mpsc::channel(1); + let (kill_tx, kill_rx) = mpsc::channel(1); + + let id = rand::random(); + let session = session.clone(); + let initialize = Box::new(move |reply: mpsc::Sender>| { + let stdout_task = spawn_nonblocking_stdout_task(id, stdout, reply.clone()); + let stderr_task = spawn_nonblocking_stderr_task(id, stderr, reply.clone()); + let stdin_task = spawn_nonblocking_stdin_task(id, stdin, stdin_rx); + let _ = spawn_cleanup_task( + session, + id, + child, + kill_rx, + stdin_task, + stdout_task, + Some(stderr_task), + reply, + cleanup, + ); + }); + + // Create a resizer that is already closed since a simple process does not resize + let resizer = mpsc::channel(1).0; + + Ok(SpawnResult { + id, + stdin: stdin_tx, + killer: kill_tx, + resizer, + initialize, + }) +} + +/// Spawns a pty process, returning a function that initializes processing +/// stdin and stdout/stderr once called (for lazy processing) +pub async fn spawn_pty( + session: &Session, + cmd: &str, + size: PtySize, + cleanup: F, +) -> io::Result +where + F: FnOnce(usize) -> R + Send + 'static, + R: Future + Send + 'static, +{ + // TODO: Do we need to support other terminal types for TERM? + let (pty, mut child) = session + .request_pty("xterm-256color", to_portable_size(size), Some(cmd), None) + .compat() + .await + .map_err(to_other_error)?; + + // Check if the process died immediately and report + // an error if that's the case + if let Ok(Some(exit_status)) = child.try_wait() { + return Err(io::Error::new( + io::ErrorKind::BrokenPipe, + format!("Process exited early: {:?}", exit_status), + )); + } + + let reader = pty + .try_clone_reader() + .map_err(|x| io::Error::new(io::ErrorKind::Other, x))?; + let writer = pty + .try_clone_writer() + .map_err(|x| io::Error::new(io::ErrorKind::Other, x))?; + + let (stdin_tx, stdin_rx) = mpsc::channel(1); + let (kill_tx, kill_rx) = mpsc::channel(1); + + let id = rand::random(); + let session = session.clone(); + let initialize = Box::new(move |reply: mpsc::Sender>| { + let stdout_task = spawn_blocking_stdout_task(id, reader, reply.clone()); + let stdin_task = spawn_blocking_stdin_task(id, writer, stdin_rx); + let _ = spawn_cleanup_task( + session, + id, + child, + kill_rx, + stdin_task, + stdout_task, + None, + reply, + cleanup, + ); + }); + + let (resize_tx, mut resize_rx) = mpsc::channel::(1); + tokio::spawn(async move { + while let Some(size) = resize_rx.recv().await { + if pty.resize(to_portable_size(size)).is_err() { + break; + } + } + }); + + Ok(SpawnResult { + id, + stdin: stdin_tx, + killer: kill_tx, + resizer: resize_tx, + initialize, + }) +} + +fn spawn_blocking_stdout_task( + id: usize, + mut reader: impl Read + Send + 'static, + tx: mpsc::Sender>, +) -> JoinHandle<()> { + tokio::task::spawn_blocking(move || { + let mut buf: [u8; MAX_PIPE_CHUNK_SIZE] = [0; MAX_PIPE_CHUNK_SIZE]; + loop { + match reader.read(&mut buf) { + Ok(n) if n > 0 => { + let payload = vec![ResponseData::ProcStdout { + id, + data: buf[..n].to_vec(), + }]; + if tx.blocking_send(payload).is_err() { + error!(" Stdout channel closed", id); + break; + } + + std::thread::sleep(Duration::from_millis(THREAD_PAUSE_MILLIS)); + } + Ok(_) => break, + Err(x) => { + error!(" Stdout unexpectedly closed: {}", id, x); + break; + } + } + } + }) +} + +fn spawn_nonblocking_stdout_task( + id: usize, + mut reader: impl Read + Send + 'static, + tx: mpsc::Sender>, +) -> JoinHandle<()> { + tokio::spawn(async move { + let mut buf: [u8; MAX_PIPE_CHUNK_SIZE] = [0; MAX_PIPE_CHUNK_SIZE]; + loop { + match reader.read(&mut buf) { + Ok(n) if n > 0 => { + let payload = vec![ResponseData::ProcStdout { + id, + data: buf[..n].to_vec(), + }]; + if tx.send(payload).await.is_err() { + error!(" Stdout channel closed", id); + break; + } + + tokio::time::sleep(Duration::from_millis(THREAD_PAUSE_MILLIS)).await; + } + Ok(_) => break, + Err(x) if x.kind() == io::ErrorKind::WouldBlock => { + tokio::time::sleep(Duration::from_millis(THREAD_PAUSE_MILLIS)).await; + } + Err(x) => { + error!(" Stdout unexpectedly closed: {}", id, x); + break; + } + } + } + }) +} + +fn spawn_nonblocking_stderr_task( + id: usize, + mut reader: impl Read + Send + 'static, + tx: mpsc::Sender>, +) -> JoinHandle<()> { + tokio::spawn(async move { + let mut buf: [u8; MAX_PIPE_CHUNK_SIZE] = [0; MAX_PIPE_CHUNK_SIZE]; + loop { + match reader.read(&mut buf) { + Ok(n) if n > 0 => { + let payload = vec![ResponseData::ProcStderr { + id, + data: buf[..n].to_vec(), + }]; + if tx.send(payload).await.is_err() { + error!(" Stderr channel closed", id); + break; + } + + tokio::time::sleep(Duration::from_millis(THREAD_PAUSE_MILLIS)).await; + } + Ok(_) => break, + Err(x) if x.kind() == io::ErrorKind::WouldBlock => { + tokio::time::sleep(Duration::from_millis(THREAD_PAUSE_MILLIS)).await; + } + Err(x) => { + error!(" Stderr unexpectedly closed: {}", id, x); + break; + } + } + } + }) +} + +fn spawn_blocking_stdin_task( + id: usize, + mut writer: impl Write + Send + 'static, + mut rx: mpsc::Receiver>, +) -> JoinHandle<()> { + tokio::task::spawn_blocking(move || { + while let Some(data) = rx.blocking_recv() { + if let Err(x) = writer.write_all(&data) { + error!(" Failed to send stdin: {}", id, x); + break; + } + + std::thread::sleep(Duration::from_millis(THREAD_PAUSE_MILLIS)); + } + }) +} + +fn spawn_nonblocking_stdin_task( + id: usize, + mut writer: impl Write + Send + 'static, + mut rx: mpsc::Receiver>, +) -> JoinHandle<()> { + tokio::spawn(async move { + while let Some(data) = rx.recv().await { + if let Err(x) = writer.write_all(&data) { + // In non-blocking mode, we'll just pause and try again if + // the IO would block here; otherwise, stop the task + if x.kind() != io::ErrorKind::WouldBlock { + error!(" Failed to send stdin: {}", id, x); + break; + } + } + + tokio::time::sleep(Duration::from_millis(THREAD_PAUSE_MILLIS)).await; + } + }) +} + +#[allow(clippy::too_many_arguments)] +fn spawn_cleanup_task( + session: Session, + id: usize, + mut child: SshChildProcess, + mut kill_rx: mpsc::Receiver<()>, + stdin_task: JoinHandle<()>, + stdout_task: JoinHandle<()>, + stderr_task: Option>, + tx: mpsc::Sender>, + cleanup: F, +) -> JoinHandle<()> +where + F: FnOnce(usize) -> R + Send + 'static, + R: Future + Send + 'static, +{ + tokio::spawn(async move { + let mut should_kill = false; + let mut success = false; + tokio::select! { + _ = kill_rx.recv() => { + should_kill = true; + } + result = child.async_wait().compat() => { + match result { + Ok(status) => { + success = status.success(); + } + Err(x) => { + error!(" Waiting on process failed: {}", id, x); + } + } + } + } + + // Force stdin task to abort if it hasn't exited as there is no + // point to sending any more stdin + stdin_task.abort(); + + if should_kill { + debug!(" Killing", id); + + if let Err(x) = child.kill() { + error!(" Unable to kill process: {}", id, x); + } + + // NOTE: At the moment, child.kill does nothing for wezterm_ssh::SshChildProcess; + // so, we need to manually run kill/taskkill to make sure that the + // process is sent a kill signal + if let Some(pid) = child.process_id() { + let _ = session + .exec(&format!("kill -9 {}", pid), None) + .compat() + .await; + let _ = session + .exec(&format!("taskkill /F /PID {}", pid), None) + .compat() + .await; + } + } else { + debug!( + " Completed and waiting on stdout & stderr tasks", + id + ); + } + + // We're done with the child, so drop it + drop(child); + + if let Some(task) = stderr_task { + if let Err(x) = task.await { + error!(" Join on stderr task failed: {}", id, x); + } + } + + if let Err(x) = stdout_task.await { + error!(" Join on stdout task failed: {}", id, x); + } + + cleanup(id).await; + + let payload = vec![ResponseData::ProcDone { + id, + success: !should_kill && success, + code: if success { Some(0) } else { None }, + }]; + + if tx.send(payload).await.is_err() { + error!(" Failed to send done", id,); + } + }) +} + +fn to_other_error(err: E) -> io::Error +where + E: Into>, +{ + io::Error::new(io::ErrorKind::Other, err) +} + +fn to_portable_size(size: PtySize) -> PortablePtySize { + PortablePtySize { + rows: size.rows, + cols: size.cols, + pixel_width: size.pixel_width, + pixel_height: size.pixel_height, + } +} diff --git a/distant-ssh2/tests/ssh2/session.rs b/distant-ssh2/tests/ssh2/session.rs index 603c5a4..92ecdf9 100644 --- a/distant-ssh2/tests/ssh2/session.rs +++ b/distant-ssh2/tests/ssh2/session.rs @@ -1348,7 +1348,7 @@ async fn metadata_should_resolve_file_type_of_symlink_if_flag_specified( #[rstest] #[tokio::test] -async fn proc_run_should_send_error_over_stderr_on_failure(#[future] session: Session) { +async fn proc_spawn_should_send_error_over_stderr_on_failure(#[future] session: Session) { let mut session = session.await; let req = Request::new( "test-tenant", @@ -1356,6 +1356,7 @@ async fn proc_run_should_send_error_over_stderr_on_failure(#[future] session: Se cmd: DOES_NOT_EXIST_BIN.to_str().unwrap().to_string(), args: Vec::new(), detached: false, + pty: None, }], ); @@ -1394,7 +1395,7 @@ async fn proc_run_should_send_error_over_stderr_on_failure(#[future] session: Se #[rstest] #[tokio::test] -async fn proc_run_should_send_back_proc_start_on_success(#[future] session: Session) { +async fn proc_spawn_should_send_back_proc_start_on_success(#[future] session: Session) { let mut session = session.await; let req = Request::new( "test-tenant", @@ -1402,6 +1403,7 @@ async fn proc_run_should_send_back_proc_start_on_success(#[future] session: Sess cmd: SCRIPT_RUNNER.to_string(), args: vec![ECHO_ARGS_TO_STDOUT_SH.to_str().unwrap().to_string()], detached: false, + pty: None, }], ); @@ -1419,7 +1421,9 @@ async fn proc_run_should_send_back_proc_start_on_success(#[future] session: Sess #[rstest] #[tokio::test] #[cfg_attr(windows, ignore)] -async fn proc_run_should_send_back_stdout_periodically_when_available(#[future] session: Session) { +async fn proc_spawn_should_send_back_stdout_periodically_when_available( + #[future] session: Session, +) { let mut session = session.await; // Run a program that echoes to stdout let req = Request::new( @@ -1431,6 +1435,7 @@ async fn proc_run_should_send_back_stdout_periodically_when_available(#[future] String::from("'some stdout'"), ], detached: false, + pty: None, }], ); @@ -1461,7 +1466,7 @@ async fn proc_run_should_send_back_stdout_periodically_when_available(#[future] assert_eq!(res.payload.len(), 1, "Wrong payload size"); match &res.payload[0] { ResponseData::ProcStdout { data, .. } => { - assert_eq!(data, "some stdout", "Got wrong stdout"); + assert_eq!(data, b"some stdout", "Got wrong stdout"); got_stdout = true; } ResponseData::ProcDone { success, .. } => { @@ -1483,7 +1488,9 @@ async fn proc_run_should_send_back_stdout_periodically_when_available(#[future] #[rstest] #[tokio::test] #[cfg_attr(windows, ignore)] -async fn proc_run_should_send_back_stderr_periodically_when_available(#[future] session: Session) { +async fn proc_spawn_should_send_back_stderr_periodically_when_available( + #[future] session: Session, +) { let mut session = session.await; // Run a program that echoes to stderr let req = Request::new( @@ -1495,6 +1502,7 @@ async fn proc_run_should_send_back_stderr_periodically_when_available(#[future] String::from("'some stderr'"), ], detached: false, + pty: None, }], ); @@ -1525,7 +1533,7 @@ async fn proc_run_should_send_back_stderr_periodically_when_available(#[future] assert_eq!(res.payload.len(), 1, "Wrong payload size"); match &res.payload[0] { ResponseData::ProcStderr { data, .. } => { - assert_eq!(data, "some stderr", "Got wrong stderr"); + assert_eq!(data, b"some stderr", "Got wrong stderr"); got_stderr = true; } ResponseData::ProcDone { success, .. } => { @@ -1547,7 +1555,7 @@ async fn proc_run_should_send_back_stderr_periodically_when_available(#[future] #[rstest] #[tokio::test] #[cfg_attr(windows, ignore)] -async fn proc_run_should_clear_process_from_state_when_done(#[future] session: Session) { +async fn proc_spawn_should_clear_process_from_state_when_done(#[future] session: Session) { let mut session = session.await; // Run a program that ends after a little bit let req = Request::new( @@ -1556,6 +1564,7 @@ async fn proc_run_should_clear_process_from_state_when_done(#[future] session: S cmd: SCRIPT_RUNNER.to_string(), args: vec![SLEEP_SH.to_str().unwrap().to_string(), String::from("0.1")], detached: false, + pty: None, }], ); let mut mailbox = session.mail(req).await.unwrap(); @@ -1595,7 +1604,7 @@ async fn proc_run_should_clear_process_from_state_when_done(#[future] session: S #[rstest] #[tokio::test] -async fn proc_run_should_clear_process_from_state_when_killed(#[future] session: Session) { +async fn proc_spawn_should_clear_process_from_state_when_killed(#[future] session: Session) { let mut session = session.await; // Run a program that ends slowly let req = Request::new( @@ -1604,6 +1613,7 @@ async fn proc_run_should_clear_process_from_state_when_killed(#[future] session: cmd: SCRIPT_RUNNER.to_string(), args: vec![SLEEP_SH.to_str().unwrap().to_string(), String::from("1")], detached: false, + pty: None, }], ); @@ -1678,6 +1688,7 @@ async fn proc_kill_should_send_ok_and_done_responses_on_success(#[future] sessio cmd: SCRIPT_RUNNER.to_string(), args: vec![SLEEP_SH.to_str().unwrap().to_string(), String::from("1")], detached: false, + pty: None, }], ); @@ -1721,7 +1732,7 @@ async fn proc_stdin_should_send_error_on_failure(#[future] session: Session) { "test-tenant", vec![RequestData::ProcStdin { id: 0xDEADBEEF, - data: String::from("some input"), + data: b"some input".to_vec(), }], ); @@ -1753,6 +1764,7 @@ async fn proc_stdin_should_send_ok_on_success_and_properly_send_stdin_to_process cmd: SCRIPT_RUNNER.to_string(), args: vec![ECHO_STDIN_TO_STDOUT_SH.to_str().unwrap().to_string()], detached: false, + pty: None, }], ); let mut mailbox = session.mail(req).await.unwrap(); @@ -1773,7 +1785,7 @@ async fn proc_stdin_should_send_ok_on_success_and_properly_send_stdin_to_process "test-tenant", vec![RequestData::ProcStdin { id, - data: String::from("hello world\n"), + data: b"hello world\n".to_vec(), }], ); let res = session.send(req).await.unwrap(); @@ -1786,7 +1798,7 @@ async fn proc_stdin_should_send_ok_on_success_and_properly_send_stdin_to_process let res = mailbox.next().await.unwrap(); match &res.payload[0] { ResponseData::ProcStdout { data, .. } => { - assert_eq!(data, "hello world\n", "Mirrored data didn't match"); + assert_eq!(data, b"hello world\n", "Mirrored data didn't match"); } x => panic!("Unexpected response: {:?}", x), } @@ -1802,6 +1814,7 @@ async fn proc_list_should_send_proc_entry_list(#[future] session: Session) { cmd: SCRIPT_RUNNER.to_string(), args: vec![SLEEP_SH.to_str().unwrap().to_string(), String::from("10")], detached: false, + pty: None, }], ); @@ -1827,6 +1840,7 @@ async fn proc_list_should_send_proc_entry_list(#[future] session: Session) { cmd: SCRIPT_RUNNER.to_string(), args: vec![SLEEP_SH.to_str().unwrap().to_string(), String::from("10")], detached: false, + pty: None, id, }], }, diff --git a/src/link.rs b/src/link.rs index 2c601aa..b3f99a7 100644 --- a/src/link.rs +++ b/src/link.rs @@ -23,7 +23,7 @@ macro_rules! from_pipes { let stdin_task = tokio::spawn(async move { loop { if let Some(input) = stdin_rx.recv().await { - if let Err(x) = $stdin.write(input.as_str()).await { + if let Err(x) = $stdin.write(&*input).await { break Err(x); } } else { @@ -37,7 +37,7 @@ macro_rules! from_pipes { match $stdout.read().await { Ok(output) => { let mut out = handle.lock(); - out.write_all(output.as_bytes())?; + out.write_all(&output)?; out.flush()?; } Err(x) => break Err(x), @@ -50,7 +50,7 @@ macro_rules! from_pipes { match $stderr.read().await { Ok(output) => { let mut out = handle.lock(); - out.write_all(output.as_bytes())?; + out.write_all(&output)?; out.flush()?; } Err(x) => break Err(x), diff --git a/src/opt.rs b/src/opt.rs index b528f55..02cd9ce 100644 --- a/src/opt.rs +++ b/src/opt.rs @@ -150,6 +150,9 @@ pub enum Subcommand { /// Specialized treatment of running a remote LSP process Lsp(LspSubcommand), + + /// Specialized treatment of running a remote shell process + Shell(ShellSubcommand), } impl Subcommand { @@ -160,6 +163,7 @@ impl Subcommand { Self::Launch(cmd) => subcommand::launch::run(cmd, opt)?, Self::Listen(cmd) => subcommand::listen::run(cmd, opt)?, Self::Lsp(cmd) => subcommand::lsp::run(cmd, opt)?, + Self::Shell(cmd) => subcommand::shell::run(cmd, opt)?, } Ok(()) @@ -171,7 +175,7 @@ impl Subcommand { Self::Action(cmd) => cmd .operation .as_ref() - .map(|req| req.is_proc_run()) + .map(|req| req.is_proc_spawn()) .unwrap_or_default(), Self::Lsp(_) => true, _ => false, @@ -678,9 +682,72 @@ pub struct LspSubcommand { #[structopt(long)] pub detached: bool, + /// If provided, will run LSP in a pty + #[structopt(long)] + pub pty: bool, + /// Command to run on the remote machine that represents an LSP server pub cmd: String, /// Additional arguments to supply to the remote machine pub args: Vec, } + +/// Represents subcommand to execute some shell on a remote machine +#[derive(Clone, Debug, StructOpt)] +#[structopt(verbatim_doc_comment)] +pub struct ShellSubcommand { + /// Represents the format that results should be returned + /// + /// Currently, there are two possible formats: + /// + /// 1. "json": printing out JSON for external program usage + /// + /// 2. "shell": printing out human-readable results for interactive shell usage + #[structopt( + short, + long, + case_insensitive = true, + default_value = Format::Shell.into(), + possible_values = Format::VARIANTS + )] + pub format: Format, + + /// Method to communicate with a remote machine + #[structopt( + short, + long, + case_insensitive = true, + default_value = Method::default().into(), + possible_values = Method::VARIANTS + )] + pub method: Method, + + /// Represents the medium for retrieving a session to use when running a remote LSP server + #[structopt( + long, + case_insensitive = true, + default_value = SessionInput::default().into(), + possible_values = SessionInput::VARIANTS + )] + pub session: SessionInput, + + /// Contains additional information related to sessions + #[structopt(flatten)] + pub session_data: SessionOpt, + + /// SSH connection settings when method is ssh + #[structopt(flatten)] + pub ssh_connection: SshConnectionOpts, + + /// If provided, will run in detached mode, meaning that the process will not be killed if the + /// client disconnects from the server + #[structopt(long)] + pub detached: bool, + + /// Command to run on the remote machine as the shell (defaults to $TERM) + pub cmd: Option, + + /// Additional arguments to supply to the shell (defaults to nothing) + pub args: Vec, +} diff --git a/src/output.rs b/src/output.rs index 6ba97cd..e7d5273 100644 --- a/src/output.rs +++ b/src/output.rs @@ -5,13 +5,14 @@ use distant_core::{ }; use log::*; use std::io; +use std::io::Write; /// Represents the output content and destination pub enum ResponseOut { - Stdout(String), - StdoutLine(String), - Stderr(String), - StderrLine(String), + Stdout(Vec), + StdoutLine(Vec), + Stderr(Vec), + StderrLine(Vec), None, } @@ -22,7 +23,7 @@ impl ResponseOut { Ok(match format { Format::Json => ResponseOut::StdoutLine( - serde_json::to_string(&res) + serde_json::to_vec(&res) .map_err(|x| io::Error::new(io::ErrorKind::InvalidData, x))?, ), @@ -49,21 +50,46 @@ impl ResponseOut { // LSP protocol, the JSON content is not followed by a // newline and was not picked up when the response was // sent back to the client; so, we need to manually flush - use std::io::Write; - print!("{}", x); - if let Err(x) = std::io::stdout().lock().flush() { + if let Err(x) = io::stdout().lock().write_all(&x) { + error!("Failed to write stdout: {}", x); + } + + if let Err(x) = io::stdout().lock().flush() { error!("Failed to flush stdout: {}", x); } } - Self::StdoutLine(x) => println!("{}", x), + Self::StdoutLine(x) => { + if let Err(x) = io::stdout().lock().write_all(&x) { + error!("Failed to write stdout: {}", x); + } + + if let Err(x) = io::stdout().lock().write(b"\n") { + error!("Failed to write stdout newline: {}", x); + } + } Self::Stderr(x) => { - use std::io::Write; - eprint!("{}", x); - if let Err(x) = std::io::stderr().lock().flush() { + // NOTE: Because we are not including a newline in the output, + // it is not guaranteed to be written out. In the case of + // LSP protocol, the JSON content is not followed by a + // newline and was not picked up when the response was + // sent back to the client; so, we need to manually flush + if let Err(x) = io::stderr().lock().write_all(&x) { + error!("Failed to write stderr: {}", x); + } + + if let Err(x) = io::stderr().lock().flush() { error!("Failed to flush stderr: {}", x); } } - Self::StderrLine(x) => eprintln!("{}", x), + Self::StderrLine(x) => { + if let Err(x) = io::stderr().lock().write_all(&x) { + error!("Failed to write stderr: {}", x); + } + + if let Err(x) = io::stderr().lock().write(b"\n") { + error!("Failed to write stderr newline: {}", x); + } + } Self::None => {} } } @@ -73,12 +99,10 @@ fn format_shell(data: ResponseData) -> ResponseOut { match data { ResponseData::Ok => ResponseOut::None, ResponseData::Error(Error { kind, description }) => { - ResponseOut::StderrLine(format!("Failed ({}): '{}'.", kind, description)) - } - ResponseData::Blob { data } => { - ResponseOut::StdoutLine(String::from_utf8_lossy(&data).to_string()) + ResponseOut::StderrLine(format!("Failed ({}): '{}'.", kind, description).into_bytes()) } - ResponseData::Text { data } => ResponseOut::StdoutLine(data), + ResponseData::Blob { data } => ResponseOut::StdoutLine(data), + ResponseData::Text { data } => ResponseOut::StdoutLine(data.into_bytes()), ResponseData::DirEntries { entries, .. } => ResponseOut::StdoutLine( entries .into_iter() @@ -100,13 +124,14 @@ fn format_shell(data: ResponseData) -> ResponseOut { ) }) .collect::>() - .join("\n"), + .join("\n") + .into_bytes(), ), ResponseData::Exists { value: exists } => { if exists { - ResponseOut::StdoutLine("true".to_string()) + ResponseOut::StdoutLine(b"true".to_vec()) } else { - ResponseOut::StdoutLine("false".to_string()) + ResponseOut::StdoutLine(b"false".to_vec()) } } ResponseData::Metadata(Metadata { @@ -117,32 +142,36 @@ fn format_shell(data: ResponseData) -> ResponseOut { accessed, created, modified, - }) => ResponseOut::StdoutLine(format!( - concat!( - "{}", - "Type: {}\n", - "Len: {}\n", - "Readonly: {}\n", - "Created: {}\n", - "Last Accessed: {}\n", - "Last Modified: {}", - ), - canonicalized_path - .map(|p| format!("Canonicalized Path: {:?}\n", p)) - .unwrap_or_default(), - file_type.as_ref(), - len, - readonly, - created.unwrap_or_default(), - accessed.unwrap_or_default(), - modified.unwrap_or_default(), - )), + }) => ResponseOut::StdoutLine( + format!( + concat!( + "{}", + "Type: {}\n", + "Len: {}\n", + "Readonly: {}\n", + "Created: {}\n", + "Last Accessed: {}\n", + "Last Modified: {}", + ), + canonicalized_path + .map(|p| format!("Canonicalized Path: {:?}\n", p)) + .unwrap_or_default(), + file_type.as_ref(), + len, + readonly, + created.unwrap_or_default(), + accessed.unwrap_or_default(), + modified.unwrap_or_default(), + ) + .into_bytes(), + ), ResponseData::ProcEntries { entries } => ResponseOut::StdoutLine( entries .into_iter() .map(|entry| format!("{}: {} {}", entry.id, entry.cmd, entry.args.join(" "))) .collect::>() - .join("\n"), + .join("\n") + .into_bytes(), ), ResponseData::ProcSpawned { .. } => ResponseOut::None, ResponseData::ProcStdout { data, .. } => ResponseOut::Stdout(data), @@ -151,9 +180,11 @@ fn format_shell(data: ResponseData) -> ResponseOut { if success { ResponseOut::None } else if let Some(code) = code { - ResponseOut::StderrLine(format!("Proc {} failed with code {}", id, code)) + ResponseOut::StderrLine( + format!("Proc {} failed with code {}", id, code).into_bytes(), + ) } else { - ResponseOut::StderrLine(format!("Proc {} failed", id)) + ResponseOut::StderrLine(format!("Proc {} failed", id).into_bytes()) } } ResponseData::SystemInfo(SystemInfo { @@ -162,15 +193,18 @@ fn format_shell(data: ResponseData) -> ResponseOut { arch, current_dir, main_separator, - }) => ResponseOut::StdoutLine(format!( - concat!( - "Family: {:?}\n", - "Operating System: {:?}\n", - "Arch: {:?}\n", - "Cwd: {:?}\n", - "Path Sep: {:?}", - ), - family, os, arch, current_dir, main_separator, - )), + }) => ResponseOut::StdoutLine( + format!( + concat!( + "Family: {:?}\n", + "Operating System: {:?}\n", + "Arch: {:?}\n", + "Cwd: {:?}\n", + "Path Sep: {:?}", + ), + family, os, arch, current_dir, main_separator, + ) + .into_bytes(), + ), } } diff --git a/src/session.rs b/src/session.rs index 22f18ca..3d6de63 100644 --- a/src/session.rs +++ b/src/session.rs @@ -31,7 +31,7 @@ impl CliSession { tenant: String, session: Session, format: Format, - stdin_rx: mpsc::Receiver, + stdin_rx: mpsc::Receiver>, ) -> Self { let map_line = move |line: &str| match format { Format::Json => serde_json::from_str(line) @@ -86,7 +86,7 @@ async fn process_mailbox(mut mailbox: Mailbox, format: Format, exit: oneshot::Re /// responses async fn process_outgoing_requests( mut session: Session, - mut stdin_rx: mpsc::Receiver, + mut stdin_rx: mpsc::Receiver>, format: Format, map_line: F, ) where @@ -96,6 +96,15 @@ async fn process_outgoing_requests( let mut mailbox_exits = Vec::new(); while let Some(data) = stdin_rx.recv().await { + // TODO: Should we support raw bytes? If so, we need to rewrite map_line to take Vec + let data = match String::from_utf8(data) { + Ok(data) => data, + Err(x) => { + error!("Bad stdin: {}", x); + continue; + } + }; + // Update our buffer with the new data and split it into concrete lines and remainder buf.push_str(&data); let (lines, new_buf) = buf.into_full_lines(); diff --git a/src/stdin.rs b/src/stdin.rs index d13ded6..0a84d46 100644 --- a/src/stdin.rs +++ b/src/stdin.rs @@ -7,7 +7,7 @@ use tokio::sync::mpsc; /// Creates a new thread that performs stdin reads in a blocking fashion, returning /// a handle to the thread and a receiver that will be sent input as it becomes available -pub fn spawn_channel(buffer: usize) -> (thread::JoinHandle<()>, mpsc::Receiver) { +pub fn spawn_channel(buffer: usize) -> (thread::JoinHandle<()>, mpsc::Receiver>) { let (tx, rx) = mpsc::channel(1); // NOTE: Using blocking I/O per tokio's advice to read from stdin line-by-line and then @@ -22,16 +22,9 @@ pub fn spawn_channel(buffer: usize) -> (thread::JoinHandle<()>, mpsc::Receiver break, Ok(n) => { - match String::from_utf8(buf[..n].to_vec()) { - Ok(text) => { - if let Err(x) = tx.blocking_send(text) { - error!("Stdin channel closed: {}", x); - break; - } - } - Err(x) => { - error!("Input over stdin is invalid: {}", x); - } + if let Err(x) = tx.blocking_send(buf[..n].to_vec()) { + error!("Stdin channel closed: {}", x); + break; } thread::yield_now(); } diff --git a/src/subcommand/action.rs b/src/subcommand/action.rs index 58d66ad..4d82b56 100644 --- a/src/subcommand/action.rs +++ b/src/subcommand/action.rs @@ -92,6 +92,7 @@ async fn start( cmd, args, detached, + pty, }), ) if is_shell_format => { let mut proc = RemoteProcess::spawn( @@ -100,6 +101,7 @@ async fn start( cmd, args, detached, + pty, ) .await?; diff --git a/src/subcommand/lsp.rs b/src/subcommand/lsp.rs index 4601e32..cf8701b 100644 --- a/src/subcommand/lsp.rs +++ b/src/subcommand/lsp.rs @@ -6,7 +6,8 @@ use crate::{ utils, }; use derive_more::{Display, Error, From}; -use distant_core::{LspData, RemoteLspProcess, RemoteProcessError, Session}; +use distant_core::{LspData, PtySize, RemoteLspProcess, RemoteProcessError, Session}; +use terminal_size::{terminal_size, Height, Width}; use tokio::io; #[derive(Debug, Display, Error, From)] @@ -74,6 +75,12 @@ async fn start( cmd.cmd, cmd.args, cmd.detached, + if cmd.pty { + terminal_size() + .map(|(Width(width), Height(height))| PtySize::from_rows_and_cols(height, width)) + } else { + None + }, ) .await?; @@ -83,7 +90,7 @@ async fn start( proc.stdin .as_mut() .unwrap() - .write(&data.to_string()) + .write(data.to_string().as_bytes()) .await?; } diff --git a/src/subcommand/mod.rs b/src/subcommand/mod.rs index e09a964..cddb2d2 100644 --- a/src/subcommand/mod.rs +++ b/src/subcommand/mod.rs @@ -15,6 +15,7 @@ pub mod action; pub mod launch; pub mod listen; pub mod lsp; +pub mod shell; struct CommandRunner { method: Method, diff --git a/src/subcommand/shell.rs b/src/subcommand/shell.rs new file mode 100644 index 0000000..c5825d2 --- /dev/null +++ b/src/subcommand/shell.rs @@ -0,0 +1,164 @@ +use crate::{ + exit::{ExitCode, ExitCodeError}, + link::RemoteProcessLink, + opt::{CommonOpt, ShellSubcommand}, + subcommand::CommandRunner, + utils, +}; +use derive_more::{Display, Error, From}; +use distant_core::{LspData, PtySize, RemoteProcess, RemoteProcessError, RemoteStdin, Session}; +use log::*; +use terminal_size::{terminal_size, Height, Width}; +use termwiz::{ + caps::Capabilities, + input::{InputEvent, KeyCodeEncodeModes}, + terminal::{new_terminal, Terminal}, +}; +use tokio::{io, time::Duration}; + +#[derive(Debug, Display, Error, From)] +pub enum Error { + #[display(fmt = "Process failed with exit code: {}", _0)] + BadProcessExit(#[error(not(source))] i32), + Io(io::Error), + RemoteProcess(RemoteProcessError), +} + +impl ExitCodeError for Error { + fn is_silent(&self) -> bool { + match self { + Self::RemoteProcess(x) => x.is_silent(), + _ => false, + } + } + + fn to_exit_code(&self) -> ExitCode { + match self { + Self::BadProcessExit(x) => ExitCode::Custom(*x), + Self::Io(x) => x.to_exit_code(), + Self::RemoteProcess(x) => x.to_exit_code(), + } + } +} + +pub fn run(cmd: ShellSubcommand, opt: CommonOpt) -> Result<(), Error> { + let rt = tokio::runtime::Runtime::new()?; + + rt.block_on(async { run_async(cmd, opt).await }) +} + +async fn run_async(cmd: ShellSubcommand, opt: CommonOpt) -> Result<(), Error> { + let method = cmd.method; + let timeout = opt.to_timeout_duration(); + let ssh_connection = cmd.ssh_connection.clone(); + let session_input = cmd.session; + let session_file = cmd.session_data.session_file.clone(); + let session_socket = cmd.session_data.session_socket.clone(); + + CommandRunner { + method, + ssh_connection, + session_input, + session_file, + session_socket, + timeout, + } + .run( + |session, _, lsp_data| Box::pin(start(cmd, session, lsp_data)), + Error::Io, + ) + .await +} + +async fn start( + cmd: ShellSubcommand, + session: Session, + lsp_data: Option, +) -> Result<(), Error> { + let mut proc = RemoteProcess::spawn( + utils::new_tenant(), + session.clone_channel(), + cmd.cmd.unwrap_or_else(|| "/bin/sh".to_string()), + cmd.args, + cmd.detached, + terminal_size().map(|(Width(cols), Height(rows))| PtySize::from_rows_and_cols(rows, cols)), + ) + .await?; + + // If we also parsed an LSP's initialize request for its session, we want to forward + // it along in the case of a process call + if let Some(data) = lsp_data { + proc.stdin + .as_mut() + .unwrap() + .write(data.to_string().as_bytes()) + .await?; + } + + // Create a new terminal in raw mode + let mut terminal = new_terminal( + Capabilities::new_from_env().map_err(|x| io::Error::new(io::ErrorKind::Other, x))?, + ) + .map_err(|x| io::Error::new(io::ErrorKind::Other, x))?; + terminal + .set_raw_mode() + .map_err(|x| io::Error::new(io::ErrorKind::Other, x))?; + + let mut stdin = proc.stdin.take().unwrap(); + let resizer = proc.clone_resizer(); + tokio::spawn(async move { + while let Ok(input) = terminal.poll_input(Some(Duration::new(0, 0))) { + match input { + Some(InputEvent::Key(ev)) => { + if let Ok(input) = ev.key.encode( + ev.modifiers, + KeyCodeEncodeModes { + enable_csi_u_key_encoding: false, + application_cursor_keys: false, + newline_mode: false, + }, + ) { + if let Err(x) = stdin.write_str(input).await { + error!("Failed to write to stdin of remote process: {}", x); + break; + } + } + } + Some(InputEvent::Resized { cols, rows }) => { + if let Err(x) = resizer + .resize(PtySize::from_rows_and_cols(rows as u16, cols as u16)) + .await + { + error!("Failed to resize remote process: {}", x); + break; + } + } + Some(_) => continue, + None => tokio::time::sleep(Duration::from_millis(1)).await, + } + } + }); + + // Now, map the remote LSP server's stdin/stdout/stderr to our own process + let link = RemoteProcessLink::from_remote_pipes( + RemoteStdin::disconnected(), + proc.stdout.take().unwrap(), + proc.stderr.take().unwrap(), + ); + + // Continually loop to check for terminal resize changes while the process is still running + let (success, exit_code) = proc.wait().await?; + + // Shut down our link + link.shutdown().await; + + if !success { + if let Some(code) = exit_code { + return Err(Error::BadProcessExit(code)); + } else { + return Err(Error::BadProcessExit(1)); + } + } + + Ok(()) +} diff --git a/tests/cli/action/mod.rs b/tests/cli/action/mod.rs index 6e4aa73..5170a65 100644 --- a/tests/cli/action/mod.rs +++ b/tests/cli/action/mod.rs @@ -9,7 +9,7 @@ mod file_read_text; mod file_write; mod file_write_text; mod metadata; -mod proc_run; +mod proc_spawn; mod remove; mod rename; mod system_info; diff --git a/tests/cli/action/proc_run.rs b/tests/cli/action/proc_spawn.rs similarity index 94% rename from tests/cli/action/proc_run.rs rename to tests/cli/action/proc_spawn.rs index 0c4ab84..7609206 100644 --- a/tests/cli/action/proc_run.rs +++ b/tests/cli/action/proc_spawn.rs @@ -83,9 +83,9 @@ macro_rules! next_two_msgs { #[rstest] fn should_execute_program_and_return_exit_status(mut action_cmd: Command) { - // distant action proc-run -- {cmd} [args] + // distant action proc-spawn -- {cmd} [args] action_cmd - .args(&["proc-run", "--"]) + .args(&["proc-spawn", "--"]) .arg(SCRIPT_RUNNER.as_str()) .arg(EXIT_CODE_SH.to_str().unwrap()) .arg("0") @@ -97,9 +97,9 @@ fn should_execute_program_and_return_exit_status(mut action_cmd: Command) { #[rstest] fn should_capture_and_print_stdout(mut action_cmd: Command) { - // distant action proc-run {cmd} [args] + // distant action proc-spawn {cmd} [args] action_cmd - .args(&["proc-run", "--"]) + .args(&["proc-spawn", "--"]) .arg(SCRIPT_RUNNER.as_str()) .arg(ECHO_ARGS_TO_STDOUT_SH.to_str().unwrap()) .arg("hello world") @@ -111,9 +111,9 @@ fn should_capture_and_print_stdout(mut action_cmd: Command) { #[rstest] fn should_capture_and_print_stderr(mut action_cmd: Command) { - // distant action proc-run {cmd} [args] + // distant action proc-spawn {cmd} [args] action_cmd - .args(&["proc-run", "--"]) + .args(&["proc-spawn", "--"]) .arg(SCRIPT_RUNNER.as_str()) .arg(ECHO_ARGS_TO_STDERR_SH.to_str().unwrap()) .arg("hello world") @@ -125,9 +125,9 @@ fn should_capture_and_print_stderr(mut action_cmd: Command) { #[rstest] fn should_forward_stdin_to_remote_process(mut action_cmd: Command) { - // distant action proc-run {cmd} [args] + // distant action proc-spawn {cmd} [args] action_cmd - .args(&["proc-run", "--"]) + .args(&["proc-spawn", "--"]) .arg(SCRIPT_RUNNER.as_str()) .arg(ECHO_STDIN_TO_STDOUT_SH.to_str().unwrap()) .write_stdin("hello world\n") @@ -139,9 +139,9 @@ fn should_forward_stdin_to_remote_process(mut action_cmd: Command) { #[rstest] fn reflect_the_exit_code_of_the_process(mut action_cmd: Command) { - // distant action proc-run {cmd} [args] + // distant action proc-spawn {cmd} [args] action_cmd - .args(&["proc-run", "--"]) + .args(&["proc-spawn", "--"]) .arg(SCRIPT_RUNNER.as_str()) .arg(EXIT_CODE_SH.to_str().unwrap()) .arg("99") @@ -153,9 +153,9 @@ fn reflect_the_exit_code_of_the_process(mut action_cmd: Command) { #[rstest] fn yield_an_error_when_fails(mut action_cmd: Command) { - // distant action proc-run {cmd} [args] + // distant action proc-spawn {cmd} [args] action_cmd - .args(&["proc-run", "--"]) + .args(&["proc-spawn", "--"]) .arg(DOES_NOT_EXIST_BIN.to_str().unwrap()) .assert() .code(ExitCode::IoError.to_i32()) @@ -172,6 +172,7 @@ fn should_support_json_to_execute_program_and_return_exit_status(mut action_cmd: cmd: SCRIPT_RUNNER.to_string(), args: vec![ECHO_ARGS_TO_STDOUT_SH.to_str().unwrap().to_string()], detached: false, + pty: None, }], }; @@ -205,6 +206,7 @@ fn should_support_json_to_capture_and_print_stdout(ctx: &'_ DistantServerCtx) { output.to_string(), ], detached: false, + pty: None, }], }; @@ -240,7 +242,7 @@ fn should_support_json_to_capture_and_print_stdout(ctx: &'_ DistantServerCtx) { friendly_recv_line(&stdout, Duration::from_secs(1)).expect("Failed to get proc stdout"); let res: Response = serde_json::from_str(&out).unwrap(); match &res.payload[0] { - ResponseData::ProcStdout { data, .. } => assert_eq!(data, &output), + ResponseData::ProcStdout { data, .. } => assert_eq!(data, output.as_bytes()), x => panic!("Unexpected response: {:?}", x), }; @@ -274,6 +276,7 @@ fn should_support_json_to_capture_and_print_stderr(ctx: &'_ DistantServerCtx) { output.to_string(), ], detached: false, + pty: None, }], }; @@ -309,7 +312,7 @@ fn should_support_json_to_capture_and_print_stderr(ctx: &'_ DistantServerCtx) { friendly_recv_line(&stdout, Duration::from_secs(1)).expect("Failed to get proc stderr"); let res: Response = serde_json::from_str(&out).unwrap(); match &res.payload[0] { - ResponseData::ProcStderr { data, .. } => assert_eq!(data, &output), + ResponseData::ProcStderr { data, .. } => assert_eq!(data, output.as_bytes()), x => panic!("Unexpected response: {:?}", x), }; @@ -339,6 +342,7 @@ fn should_support_json_to_forward_stdin_to_remote_process(ctx: &'_ DistantServer cmd: SCRIPT_RUNNER.to_string(), args: vec![ECHO_STDIN_TO_STDOUT_SH.to_str().unwrap().to_string()], detached: false, + pty: None, }], }; @@ -374,7 +378,7 @@ fn should_support_json_to_forward_stdin_to_remote_process(ctx: &'_ DistantServer tenant: random_tenant(), payload: vec![RequestData::ProcStdin { id, - data: String::from("hello world\n"), + data: b"hello world\n".to_vec(), }], }; let req_string = format!("{}\n", serde_json::to_string(&req).unwrap()); @@ -385,10 +389,10 @@ fn should_support_json_to_forward_stdin_to_remote_process(ctx: &'_ DistantServer let (res1, res2) = next_two_msgs!(&stdout); match (&res1.payload[0], &res2.payload[0]) { (ResponseData::Ok, ResponseData::ProcStdout { data, .. }) => { - assert_eq!(data, "hello world\n") + assert_eq!(data, b"hello world\n") } (ResponseData::ProcStdout { data, .. }, ResponseData::Ok) => { - assert_eq!(data, "hello world\n") + assert_eq!(data, b"hello world\n") } x => panic!("Unexpected responses: {:?}", x), }; @@ -433,6 +437,7 @@ fn should_support_json_output_for_error(mut action_cmd: Command) { cmd: DOES_NOT_EXIST_BIN.to_str().unwrap().to_string(), args: Vec::new(), detached: false, + pty: None, }], };