├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .prettierrc ├── Cargo.lock ├── Cargo.toml ├── README.md ├── src ├── line_reader.rs ├── log.rs ├── main.rs ├── signal_closure.rs └── waitpid.rs ├── tests ├── signals.py ├── test_cli.rs └── zombie_creator.py └── tools └── release /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - "release/v*" 5 | 6 | name: Make Release 7 | 8 | env: 9 | BINARY_PATH: target/x86_64-unknown-linux-musl/release/multip 10 | 11 | jobs: 12 | build_and_test: 13 | name: Rust project 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@master 17 | 18 | - name: Use Node.js 19 | uses: actions/setup-node@v1 20 | with: 21 | node-version: 12 22 | 23 | - name: Use Rust toolchain 24 | uses: actions-rs/toolchain@v1 25 | with: 26 | target: x86_64-unknown-linux-musl 27 | toolchain: stable 28 | override: true 29 | 30 | - name: Build and test 31 | run: | 32 | cargo test 33 | export MULTIP_VERSION="$(echo "$GITHUB_REF" | cut -d / -f 4)" 34 | echo "::set-env name=NEW_VERSION::${MULTIP_VERSION}" 35 | cargo build --release --target x86_64-unknown-linux-musl 36 | 37 | strip ${{ env.BINARY_PATH }} 38 | 39 | # npm ci 40 | # npm test 41 | - name: Create Gitub Release 42 | id: create_release 43 | uses: actions/create-release@becafb2f617803255b25498cda6d14dfb29adfe5 44 | env: 45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 46 | with: 47 | tag_name: ${{ env.NEW_VERSION }} 48 | release_name: ${{ env.NEW_VERSION }} 49 | body: Changelog WIP 50 | draft: false 51 | prerelease: false 52 | 53 | - name: Upload Release Asset 54 | id: upload-release-asset 55 | uses: actions/upload-release-asset@v1.0.1 56 | env: 57 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 58 | with: 59 | # This pulls from the CREATE RELEASE step above, 60 | # referencing it's ID to get its outputs object, which 61 | # include a `upload_url`. See this blog post for more info: 62 | # https://jasonet.co/posts/new-features-of-github-actions/#passing-data-to-future-steps 63 | upload_url: ${{ steps.create_release.outputs.upload_url }} 64 | asset_path: ${{ env.BINARY_PATH }} 65 | asset_name: multip-amd64 66 | asset_content_type: application/octet-stream 67 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: [push] 2 | 3 | name: CI 4 | 5 | jobs: 6 | build_and_test: 7 | name: Rust project 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@master 11 | 12 | - name: Use Rust toolchain 13 | uses: actions-rs/toolchain@v1 14 | with: 15 | target: x86_64-unknown-linux-musl 16 | toolchain: stable 17 | override: true 18 | 19 | - name: Run cargo test 20 | run: | 21 | cargo test 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | node_modules 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 4 4 | } 5 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | [[package]] 4 | name = "aho-corasick" 5 | version = "0.7.6" 6 | source = "registry+https://github.com/rust-lang/crates.io-index" 7 | dependencies = [ 8 | "memchr 2.2.1 (registry+https://github.com/rust-lang/crates.io-index)", 9 | ] 10 | 11 | [[package]] 12 | name = "bitflags" 13 | version = "1.2.1" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | 16 | [[package]] 17 | name = "cc" 18 | version = "1.0.48" 19 | source = "registry+https://github.com/rust-lang/crates.io-index" 20 | 21 | [[package]] 22 | name = "cfg-if" 23 | version = "0.1.10" 24 | source = "registry+https://github.com/rust-lang/crates.io-index" 25 | 26 | [[package]] 27 | name = "lazy_static" 28 | version = "1.4.0" 29 | source = "registry+https://github.com/rust-lang/crates.io-index" 30 | 31 | [[package]] 32 | name = "libc" 33 | version = "0.2.66" 34 | source = "registry+https://github.com/rust-lang/crates.io-index" 35 | 36 | [[package]] 37 | name = "memchr" 38 | version = "2.2.1" 39 | source = "registry+https://github.com/rust-lang/crates.io-index" 40 | 41 | [[package]] 42 | name = "multip" 43 | version = "0.0.0" 44 | dependencies = [ 45 | "lazy_static 1.4.0 (registry+https://github.com/rust-lang/crates.io-index)", 46 | "libc 0.2.66 (registry+https://github.com/rust-lang/crates.io-index)", 47 | "memchr 2.2.1 (registry+https://github.com/rust-lang/crates.io-index)", 48 | "nix 0.16.0 (registry+https://github.com/rust-lang/crates.io-index)", 49 | "regex 1.3.3 (registry+https://github.com/rust-lang/crates.io-index)", 50 | ] 51 | 52 | [[package]] 53 | name = "nix" 54 | version = "0.16.0" 55 | source = "registry+https://github.com/rust-lang/crates.io-index" 56 | dependencies = [ 57 | "bitflags 1.2.1 (registry+https://github.com/rust-lang/crates.io-index)", 58 | "cc 1.0.48 (registry+https://github.com/rust-lang/crates.io-index)", 59 | "cfg-if 0.1.10 (registry+https://github.com/rust-lang/crates.io-index)", 60 | "libc 0.2.66 (registry+https://github.com/rust-lang/crates.io-index)", 61 | "void 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)", 62 | ] 63 | 64 | [[package]] 65 | name = "regex" 66 | version = "1.3.3" 67 | source = "registry+https://github.com/rust-lang/crates.io-index" 68 | dependencies = [ 69 | "aho-corasick 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)", 70 | "memchr 2.2.1 (registry+https://github.com/rust-lang/crates.io-index)", 71 | "regex-syntax 0.6.13 (registry+https://github.com/rust-lang/crates.io-index)", 72 | "thread_local 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)", 73 | ] 74 | 75 | [[package]] 76 | name = "regex-syntax" 77 | version = "0.6.13" 78 | source = "registry+https://github.com/rust-lang/crates.io-index" 79 | 80 | [[package]] 81 | name = "thread_local" 82 | version = "1.0.1" 83 | source = "registry+https://github.com/rust-lang/crates.io-index" 84 | dependencies = [ 85 | "lazy_static 1.4.0 (registry+https://github.com/rust-lang/crates.io-index)", 86 | ] 87 | 88 | [[package]] 89 | name = "void" 90 | version = "1.0.2" 91 | source = "registry+https://github.com/rust-lang/crates.io-index" 92 | 93 | [metadata] 94 | "checksum aho-corasick 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)" = "58fb5e95d83b38284460a5fda7d6470aa0b8844d283a0b614b8535e880800d2d" 95 | "checksum bitflags 1.2.1 (registry+https://github.com/rust-lang/crates.io-index)" = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" 96 | "checksum cc 1.0.48 (registry+https://github.com/rust-lang/crates.io-index)" = "f52a465a666ca3d838ebbf08b241383421412fe7ebb463527bba275526d89f76" 97 | "checksum cfg-if 0.1.10 (registry+https://github.com/rust-lang/crates.io-index)" = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" 98 | "checksum lazy_static 1.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" 99 | "checksum libc 0.2.66 (registry+https://github.com/rust-lang/crates.io-index)" = "d515b1f41455adea1313a4a2ac8a8a477634fbae63cc6100e3aebb207ce61558" 100 | "checksum memchr 2.2.1 (registry+https://github.com/rust-lang/crates.io-index)" = "88579771288728879b57485cc7d6b07d648c9f0141eb955f8ab7f9d45394468e" 101 | "checksum nix 0.16.0 (registry+https://github.com/rust-lang/crates.io-index)" = "19a8300bf427d432716764070ff70d5b2b7801c958b9049686e6cbd8b06fad92" 102 | "checksum regex 1.3.3 (registry+https://github.com/rust-lang/crates.io-index)" = "b5508c1941e4e7cb19965abef075d35a9a8b5cdf0846f30b4050e9b55dc55e87" 103 | "checksum regex-syntax 0.6.13 (registry+https://github.com/rust-lang/crates.io-index)" = "e734e891f5b408a29efbf8309e656876276f49ab6a6ac208600b4419bd893d90" 104 | "checksum thread_local 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)" = "d40c6d1b69745a6ec6fb1ca717914848da4b44ae29d9b3080cbee91d72a69b14" 105 | "checksum void 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)" = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" 106 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "multip" 3 | version = "0.0.0" 4 | authors = ["Esa-Matti Suuronen "] 5 | edition = "2018" 6 | 7 | [dependencies] 8 | nix = "0.16.0" 9 | libc = "0.2" 10 | memchr = "2.2.1" 11 | lazy_static = "1.4.0" 12 | regex = "1.3.3" -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # multip 2 | 3 | Tiny multi process `init` for containers written in Rust. For example if you 4 | want to run nginx and php-fpm in a single container. It's just a single 5 | statically linked binary. 6 | 7 | This is very similiar to [concurrently][] but also acts as a `init` by 8 | implementing zombie process reaping and signal forwarding. You could think 9 | this as a combination of `tini` (the default `init` in Docker) and 10 | `concurrently`. 11 | 12 | > Wait. Containers should run only a single process, right? [Read this](#multi-process-containers) 13 | 14 | [concurrently]: https://www.npmjs.com/package/concurrently 15 | 16 | ## Features 17 | 18 | - If one the started processes exits it will bring all others down too so 19 | your container orchestration can handle the error (report, restart, whatever) 20 | - Reap zombies 21 | - Prefix process stdout&stderr with labels so you can know which process sent 22 | which message 23 | - Signal forwarding to child processes 24 | - Second SIGINT (ctrl-c) sends SIGTERM instead to the children and third 25 | sends SIGKILL. 26 | - The exit code of `multip` will be the one used by the first dead child 27 | 28 | ## Installation 29 | 30 | Grab a pre-build binary from the releases [page][]. 31 | 32 | [page]: https://github.com/esamattis/multip/releases 33 | 34 | The binary is statically linked with musl libc so it will run in bare bones 35 | distros too such as Alpine Linux. 36 | 37 | ## Usage 38 | 39 | multip "web: nginx" "php: php-fpm" 40 | 41 | The `web:` and `php:` are the prefixes for each processes output. The rest is 42 | passed to `/bin/sh` with `exec`. Ex. `/bin/sh -c "exec nginx"`. 43 | 44 | ## Advanced features 45 | 46 | There are none but you can delegate to wrapper scripts. 47 | 48 | ### Setting enviroment variables 49 | 50 | Create `start.sh` with 51 | 52 | ```sh 53 | #/bin/sh 54 | 55 | set -eu 56 | 57 | export API_ENDPOINT=http://api.example/graphql 58 | exec node /app/server.js 59 | ``` 60 | 61 | and call `multip "server: /app/start.sh" "other: /path/to/some/executable"`. 62 | 63 | Remember call the actual command with `exec` so it will replace the wrapper 64 | script process instead of starting new subprocess. 65 | 66 | ### Dropping privileges 67 | 68 | If you start `multip` as root you can drop the root privileges with `setpriv` for example 69 | 70 | ```sh 71 | #!/bin/sh 72 | 73 | set -eu 74 | 75 | exec setpriv \ 76 | --ruid www-data \ 77 | --rgid www-data \ 78 | --clear-groups \ 79 | node /app/server.js 80 | ``` 81 | 82 | ### Automatic restart 83 | 84 | ```sh 85 | #!/bin/sh 86 | 87 | set -eu 88 | 89 | while true; do 90 | ret=0 91 | node /app/server.js || ret=$? 92 | 93 | echo "Server died with $ret. Restarting soon..." 94 | sleep 1 95 | done 96 | ``` 97 | 98 | Note that here we cannot use `exec` because we need to keep the script alive 99 | for restarts. 100 | 101 | ### Keep running on success 102 | 103 | `multip` brings all processes down even when child exits with success status 104 | code (zero). You can keep others running with `sleep infinity`. 105 | 106 | ```sh 107 | #!/bin/sh 108 | 109 | set -eu 110 | 111 | ret=0 112 | node /app/server.js || ret=$? 113 | 114 | if [ "$ret" = "0" ]; then 115 | exec sleep infinity 116 | fi 117 | 118 | exit $ret 119 | ``` 120 | 121 | # Similar tools 122 | 123 | Single process inits 124 | 125 | - tini https://github.com/krallin/tini 126 | - The default `init` shipped with Docker 127 | - dump-init https://github.com/Yelp/dumb-init 128 | - catatonit https://github.com/openSUSE/catatonit 129 | 130 | Multi process inits 131 | 132 | - s6 https://skarnet.org/software/s6-linux-init/ 133 | - s6 is a suite of programs for (multi) process supervision that can be ran as an init 134 | - Installation is non-trivial on some systems 135 | - systemd 136 | - This is the mother of all inits. It has every feature you can hope of and more 137 | - Your linux distro is probably using it 138 | 139 | Plain multi process runners 140 | 141 | - concurrently https://www.npmjs.com/package/concurrently 142 | - GNU Parallel https://www.gnu.org/software/parallel/ 143 | - Alternatives https://www.gnu.org/software/parallel/parallel_alternatives.html 144 | 145 | # Multi process containers? 146 | 147 | In reality most your containers are multiprocess containers any way if they 148 | happen to use worker processes or spawn out processes to do one off tasks. So 149 | there's nothing technically wrong with them. 150 | 151 | It is usually a good design to create single purpose containers but it's not 152 | always the best approach. For example if you want to serve PHP apps with 153 | php-fpm it is difficult to do with single process containers because php-fpm 154 | does not speak HTTP but FastCGI so you need some web server to translate 155 | FastCGI to HTTP. You can run nginx in a separate container which proxies to 156 | the php-fpm container but because php-fpm cannot share static files you must 157 | deploy your code to both containers which can be a hassle to manage. 158 | 159 | With `multip` it is possible to create a container which runs both but acts 160 | like it has only one process with minimal overhead. This way the fact that it 161 | uses FastCGI is internal to the container and for users of the container it's 162 | like any other container speaking HTTP. 163 | -------------------------------------------------------------------------------- /src/line_reader.rs: -------------------------------------------------------------------------------- 1 | use core::str::from_utf8; 2 | use std::cmp; 3 | use std::convert::TryFrom; 4 | use std::fmt; 5 | use std::io::{BufRead, BufReader, Read}; 6 | use std::io::{Error, ErrorKind}; 7 | 8 | struct Guard<'a> { 9 | buf: &'a mut Vec, 10 | len: usize, 11 | } 12 | 13 | impl Drop for Guard<'_> { 14 | fn drop(&mut self) { 15 | unsafe { 16 | self.buf.set_len(self.len); 17 | } 18 | } 19 | } 20 | 21 | fn to_usize(i: isize) -> usize { 22 | usize::try_from(i).unwrap_or(0) 23 | } 24 | 25 | fn to_isize(i: usize) -> isize { 26 | isize::try_from(i).unwrap_or(0) 27 | } 28 | 29 | impl Line { 30 | pub fn as_line<'a>(&'a self) -> &'a str { 31 | match self { 32 | Line::PartialLine(s) => &s, 33 | Line::FullLine(s) => &s, 34 | Line::EOF(s) => &s, 35 | } 36 | } 37 | 38 | pub fn len(&self) -> usize { 39 | self.as_line().trim_end().len() 40 | } 41 | } 42 | 43 | // https://github.com/rust-lang/rust/blob/b69f6e65c081f9a628ef5db83ba77e3861e60e60/src/libstd/io/mod.rs#L333-L349 44 | fn append_to_string(buf: &mut String, f: F) -> Result 45 | where 46 | F: FnOnce(&mut Vec) -> Result, 47 | { 48 | unsafe { 49 | let mut g = Guard { 50 | len: buf.len(), 51 | buf: buf.as_mut_vec(), 52 | }; 53 | let ret = f(g.buf); 54 | if from_utf8(&g.buf[g.len..]).is_err() { 55 | ret.and_then(|_| { 56 | Err(Error::new( 57 | ErrorKind::InvalidData, 58 | "stream did not contain valid UTF-8", 59 | )) 60 | }) 61 | } else { 62 | g.len = g.buf.len(); 63 | ret 64 | } 65 | } 66 | } 67 | 68 | pub enum Line { 69 | FullLine(String), 70 | PartialLine(String), 71 | EOF(String), 72 | } 73 | 74 | impl fmt::Display for Line { 75 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 76 | match self { 77 | Line::PartialLine(s) => write!(f, "PartialLine({})", s), 78 | Line::FullLine(s) => write!(f, "FullLine({})", s), 79 | Line::EOF(s) => write!(f, "EOF({})", s), 80 | } 81 | } 82 | } 83 | 84 | enum Status { 85 | Full(usize), 86 | Partial(usize), 87 | Missing(usize), 88 | Error(usize, Error), 89 | } 90 | 91 | pub struct SafeLineReader { 92 | inner: BufReader, 93 | max_line_size: usize, 94 | sent_partial: bool, 95 | } 96 | 97 | impl SafeLineReader { 98 | pub fn new(inner: BufReader, max_line_size: usize) -> SafeLineReader { 99 | SafeLineReader { 100 | inner, 101 | max_line_size, 102 | sent_partial: false, 103 | } 104 | } 105 | 106 | pub fn read_line(&mut self) -> Result { 107 | // b'\n' 108 | let mut buf = String::new(); 109 | 110 | loop { 111 | let status = { 112 | let available = match self.inner.fill_buf() { 113 | Ok(n) => n, 114 | Err(ref e) if e.kind() == ErrorKind::Interrupted => continue, 115 | Err(e) => return Err(e), 116 | }; 117 | 118 | let space_available = to_usize(to_isize(self.max_line_size) - to_isize(buf.len())); 119 | 120 | match memchr::memchr(b'\n', available) { 121 | Some(i) => { 122 | let overflow = 123 | to_isize(self.max_line_size) - (to_isize(buf.len()) + to_isize(i)); 124 | 125 | if overflow >= 0 { 126 | let res = append_to_string(&mut buf, |b| { 127 | b.extend_from_slice(&available[..=i]); 128 | Ok(available[..=i].len()) 129 | }); 130 | 131 | if let Err(err) = res { 132 | Status::Error(i + 1, err) 133 | } else if self.sent_partial { 134 | self.sent_partial = false; 135 | Status::Partial(i + 1) 136 | } else { 137 | Status::Full(i + 1) 138 | } 139 | } else { 140 | let remaining = cmp::min(space_available, i + 1); 141 | let res = append_to_string(&mut buf, |b| { 142 | b.extend_from_slice(&available[..remaining]); 143 | Ok(available[..remaining].len()) 144 | }); 145 | 146 | if let Err(err) = res { 147 | Status::Error(i + 1, err) 148 | } else { 149 | self.sent_partial = true; 150 | Status::Partial(remaining) 151 | } 152 | } 153 | } 154 | None => { 155 | let overflow = to_isize(self.max_line_size) 156 | - (to_isize(buf.len()) + to_isize(available.len())); 157 | if overflow < 0 { 158 | let res = append_to_string(&mut buf, |b| { 159 | b.extend_from_slice(&available[..space_available]); 160 | Ok(available[..space_available].len()) 161 | }); 162 | 163 | if let Err(err) = res { 164 | Status::Error(space_available, err) 165 | } else { 166 | self.sent_partial = true; 167 | Status::Partial(space_available) 168 | } 169 | } else { 170 | let res = append_to_string(&mut buf, |b| { 171 | b.extend_from_slice(available); 172 | Ok(available.len()) 173 | }); 174 | 175 | if let Err(err) = res { 176 | Status::Error(available.len(), err) 177 | } else { 178 | Status::Missing(available.len()) 179 | } 180 | } 181 | } 182 | } 183 | }; 184 | 185 | match status { 186 | Status::Full(used) => { 187 | self.inner.consume(used); 188 | return Ok(Line::FullLine(buf)); 189 | } 190 | Status::Partial(used) => { 191 | self.inner.consume(used); 192 | return Ok(Line::PartialLine(buf)); 193 | } 194 | Status::Missing(used) => { 195 | if used == 0 { 196 | return Ok(Line::EOF(buf)); 197 | } 198 | self.inner.consume(used); 199 | } 200 | Status::Error(used, err) => { 201 | self.inner.consume(used); 202 | return Err(err); 203 | } 204 | } 205 | } 206 | } 207 | } 208 | 209 | #[cfg(test)] 210 | fn get_full_line(s: Line) -> String { 211 | match s { 212 | Line::FullLine(s) => s, 213 | Line::PartialLine(s) => panic!("Expected full line but got partial with: `{}`", s), 214 | Line::EOF(s) => panic!("Expected full line but got EOF with: `{}`", s), 215 | } 216 | } 217 | 218 | #[cfg(test)] 219 | fn get_partial_line(s: Line) -> String { 220 | match s { 221 | Line::PartialLine(s) => s, 222 | Line::FullLine(s) => panic!("Expected partial line but got full with: `{}`", s), 223 | Line::EOF(s) => panic!("Expected partial line but got EOF with: `{}`", s), 224 | } 225 | } 226 | 227 | #[test] 228 | fn can_read_multiple_lines() { 229 | let in_buf: &[u8] = b"a\nb\nc"; 230 | 231 | let mut reader = SafeLineReader::new(BufReader::with_capacity(2, in_buf), 100); 232 | 233 | let s = reader.read_line().unwrap(); 234 | 235 | assert_eq!(get_full_line(s), "a\n"); 236 | } 237 | 238 | #[test] 239 | fn can_read_multiple_lines_with_words() { 240 | let in_buf: &[u8] = b"first\nsecond\nthird\n"; 241 | let mut reader = SafeLineReader::new(BufReader::with_capacity(2, in_buf), 100); 242 | 243 | let s = get_full_line(reader.read_line().unwrap()); 244 | assert_eq!(s, "first\n"); 245 | 246 | let s = get_full_line(reader.read_line().unwrap()); 247 | assert_eq!(s, "second\n"); 248 | 249 | let s = get_full_line(reader.read_line().unwrap()); 250 | assert_eq!(s, "third\n"); 251 | } 252 | 253 | #[test] 254 | fn can_split_too_long_lines_large_buffer() { 255 | let in_buf: &[u8] = b"too long line\nsecond line\n"; 256 | 257 | let mut reader = SafeLineReader::new(BufReader::with_capacity(100, in_buf), 7); 258 | 259 | let s = get_partial_line(reader.read_line().unwrap()); 260 | assert_eq!(s, "too lon"); 261 | 262 | let s = get_partial_line(reader.read_line().unwrap()); 263 | assert_eq!(s, "g line\n"); 264 | 265 | let s = get_partial_line(reader.read_line().unwrap()); 266 | assert_eq!(s, "second "); 267 | } 268 | 269 | #[test] 270 | fn can_split_too_long_lines_small_buffer() { 271 | let in_buf: &[u8] = b"too long line\nsecond line\n"; 272 | 273 | let mut reader = SafeLineReader::new(BufReader::with_capacity(3, in_buf), 7); 274 | 275 | let s = get_partial_line(reader.read_line().unwrap()); 276 | assert_eq!(s, "too lon"); 277 | 278 | let s = get_partial_line(reader.read_line().unwrap()); 279 | assert_eq!(s, "g line\n"); 280 | 281 | let s = get_partial_line(reader.read_line().unwrap()); 282 | assert_eq!(s, "second "); 283 | } 284 | 285 | #[test] 286 | fn really_long_line() { 287 | let in_buf: &[u8] = b"too long line hubba bubba dubba\n"; 288 | 289 | let mut reader = SafeLineReader::new(BufReader::with_capacity(3, in_buf), 5); 290 | 291 | let s = get_partial_line(reader.read_line().unwrap()); 292 | assert_eq!(s, "too l"); 293 | 294 | let s = get_partial_line(reader.read_line().unwrap()); 295 | assert_eq!(s, "ong l"); 296 | 297 | let s = get_partial_line(reader.read_line().unwrap()); 298 | assert_eq!(s, "ine h"); 299 | 300 | let s = get_partial_line(reader.read_line().unwrap()); 301 | assert_eq!(s, "ubba "); 302 | 303 | let s = get_partial_line(reader.read_line().unwrap()); 304 | assert_eq!(s, "bubba"); 305 | 306 | let s = get_partial_line(reader.read_line().unwrap()); 307 | assert_eq!(s, " dubb"); 308 | 309 | let s = get_partial_line(reader.read_line().unwrap()); 310 | assert_eq!(s, "a\n"); 311 | } 312 | 313 | #[test] 314 | fn empty_lines() { 315 | let in_buf: &[u8] = b"\n\n\n\n"; 316 | let mut reader = SafeLineReader::new(BufReader::with_capacity(2, in_buf), 5); 317 | 318 | let s = get_full_line(reader.read_line().unwrap()); 319 | assert_eq!(s, "\n"); 320 | 321 | let s = get_full_line(reader.read_line().unwrap()); 322 | assert_eq!(s, "\n"); 323 | 324 | let s = get_full_line(reader.read_line().unwrap()); 325 | assert_eq!(s, "\n"); 326 | 327 | let s = get_full_line(reader.read_line().unwrap()); 328 | assert_eq!(s, "\n"); 329 | } 330 | 331 | #[test] 332 | fn invalid_unicode() { 333 | let in_buf: &[u8] = &[32, 255, 6, 2, 3]; 334 | let mut reader = SafeLineReader::new(BufReader::with_capacity(2, in_buf), 5); 335 | 336 | let err = match reader.read_line() { 337 | Err(err) => format!("{}", err), 338 | _ => String::from("no error"), 339 | }; 340 | 341 | assert_eq!(err, "stream did not contain valid UTF-8"); 342 | } 343 | 344 | #[test] 345 | fn test_eof() { 346 | let in_buf: &[u8] = b"12345678"; 347 | let mut reader = SafeLineReader::new(BufReader::with_capacity(2, in_buf), 5); 348 | 349 | let line = reader.read_line().unwrap(); 350 | assert_eq!(format!("{}", line), "PartialLine(12345)"); 351 | 352 | let line = reader.read_line().unwrap(); 353 | assert_eq!(format!("{}", line), "EOF(678)"); 354 | } 355 | 356 | #[test] 357 | fn long_line_with_large_buffer() { 358 | let in_buf: &[u8] = b"abcdefghikjlmnopqrstxyz\nabcdefghikjlmnopqrstxyz\n"; 359 | let mut reader = SafeLineReader::new(BufReader::with_capacity(1000, in_buf), 5); 360 | 361 | let s = get_partial_line(reader.read_line().unwrap()); 362 | assert_eq!(s, "abcde"); 363 | 364 | let s = get_partial_line(reader.read_line().unwrap()); 365 | assert_eq!(s, "fghik"); 366 | 367 | let s = get_partial_line(reader.read_line().unwrap()); 368 | assert_eq!(s, "jlmno"); 369 | 370 | let s = get_partial_line(reader.read_line().unwrap()); 371 | assert_eq!(s, "pqrst"); 372 | 373 | let s = get_partial_line(reader.read_line().unwrap()); 374 | assert_eq!(s, "xyz\n"); 375 | 376 | let s = get_partial_line(reader.read_line().unwrap()); 377 | assert_eq!(s, "abcde"); 378 | } 379 | 380 | #[test] 381 | fn long_line_in_the_middle() { 382 | let in_buf: &[u8] = b"foo\nlong stuff\nbar\n"; 383 | let mut reader = SafeLineReader::new(BufReader::with_capacity(1000, in_buf), 5); 384 | 385 | let s = get_full_line(reader.read_line().unwrap()); 386 | assert_eq!(s, "foo\n"); 387 | 388 | let s = get_partial_line(reader.read_line().unwrap()); 389 | assert_eq!(s, "long "); 390 | 391 | let s = get_partial_line(reader.read_line().unwrap()); 392 | assert_eq!(s, "stuff\n"); 393 | 394 | let s = get_full_line(reader.read_line().unwrap()); 395 | assert_eq!(s, "bar\n"); 396 | } 397 | -------------------------------------------------------------------------------- /src/log.rs: -------------------------------------------------------------------------------- 1 | #[macro_export] 2 | macro_rules! log { 3 | () => { 4 | println!(); 5 | }; 6 | ($($arg:tt)+) => { 7 | println!($($arg)*); 8 | } 9 | } 10 | 11 | #[macro_export] 12 | macro_rules! debug { 13 | () => { 14 | if std::env::var("MULTIP_DEBUG").is_ok() { 15 | println!(); 16 | } 17 | }; 18 | ($($arg:tt)+) => { 19 | if std::env::var("MULTIP_DEBUG").is_ok() { 20 | print!(" "); 21 | println!($($arg)*); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #[cfg(target_os = "linux")] 2 | use libc::{prctl, PR_SET_CHILD_SUBREAPER}; 3 | 4 | use nix::sys::signal; 5 | use nix::sys::signal::kill; 6 | use nix::sys::signal::Signal; 7 | use nix::unistd::Pid; 8 | use std::env; 9 | use std::fmt; 10 | use std::io::{BufReader, Error, Read}; 11 | use std::marker::Send; 12 | use std::process::{id, Command, Stdio}; 13 | use std::sync::mpsc; 14 | use std::sync::mpsc::RecvTimeoutError; 15 | use std::sync::{Arc, Mutex}; 16 | use std::thread; 17 | use std::time::Duration; 18 | 19 | mod line_reader; 20 | mod log; 21 | mod signal_closure; 22 | mod waitpid; 23 | 24 | struct Line { 25 | name: String, 26 | line: Result, 27 | } 28 | 29 | impl Line { 30 | fn print(&self) { 31 | print!("{}", self); 32 | } 33 | } 34 | 35 | impl fmt::Display for Line { 36 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 37 | match &self.line { 38 | Err(err) => writeln!(f, "<{}> Error: {}", self.name, err), 39 | Ok(line_reader::Line::PartialLine(s)) => { 40 | writeln!(f, "[{}...] {}", self.name, s.trim_end()) 41 | } 42 | Ok(line_reader::Line::EOF(s)) => { 43 | let s = s.trim_end(); 44 | if s.len() > 0 { 45 | writeln!(f, "[{}] {}", self.name, s) 46 | } else { 47 | write!(f, "") 48 | } 49 | } 50 | Ok(line_reader::Line::FullLine(s)) => writeln!(f, "[{}] {}", self.name, s.trim_end()), 51 | } 52 | } 53 | } 54 | 55 | fn read_env_as_number(env: &str, default: N) -> N 56 | where 57 | N: std::str::FromStr + std::string::ToString, 58 | { 59 | return env::var(env) 60 | .unwrap_or(default.to_string()) 61 | .parse::() 62 | .unwrap_or(default); 63 | } 64 | 65 | enum Message { 66 | Line(Line), 67 | ParentSignal(Signal), 68 | } 69 | 70 | type Channel = std::sync::mpsc::Sender; 71 | 72 | struct MultipChild<'a> { 73 | name: &'a str, 74 | kill_sent: Option, 75 | is_dead: bool, 76 | tx: &'a Channel, 77 | cmd: std::process::Child, 78 | stdout_eof: Arc>, 79 | stderr_eof: Arc>, 80 | } 81 | 82 | impl fmt::Display for MultipChild<'_> { 83 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 84 | write!(f, "{}({})", self.name, self.cmd.id()) 85 | } 86 | } 87 | 88 | impl MultipChild<'_> { 89 | fn spawn<'a>(name: &'a str, command: &str, tx: &'a Channel) -> MultipChild<'a> { 90 | let mut cmd = Command::new("/bin/sh") 91 | .arg("-c") 92 | // Add implicit exec to avoid extra process 93 | .arg(format!("exec {}", command)) 94 | .stdout(Stdio::piped()) 95 | .stderr(Stdio::piped()) 96 | .spawn() 97 | .ok() 98 | .expect("failed to spawn command"); 99 | 100 | let stdout = cmd.stdout.take().expect("failed to take stdout"); 101 | let stderr = cmd.stderr.take().expect("failed to take stderr"); 102 | 103 | let pid = cmd.id(); 104 | log!("Started [{}] with pid {}", name, pid); 105 | 106 | let child = MultipChild { 107 | name, 108 | tx, 109 | cmd, 110 | is_dead: false, 111 | kill_sent: None, 112 | stdout_eof: Arc::new(Mutex::new(false)), 113 | stderr_eof: Arc::new(Mutex::new(false)), 114 | }; 115 | 116 | child.monitor_ouput(Arc::clone(&child.stdout_eof), stdout); 117 | child.monitor_ouput(Arc::clone(&child.stderr_eof), stderr); 118 | 119 | child 120 | } 121 | 122 | fn monitor_ouput( 123 | &self, 124 | eof_mutex: Arc>, 125 | stream: impl Read + Send + 'static, 126 | ) -> std::thread::JoinHandle<()> { 127 | let name = self.name.to_string(); 128 | let tx = mpsc::Sender::clone(self.tx); 129 | thread::spawn(move || { 130 | let buf = BufReader::new(stream); 131 | 132 | let line_length = read_env_as_number("MULTIP_MAX_LINE_LENGTH", 1000); 133 | 134 | let mut reader = line_reader::SafeLineReader::new(buf, line_length); 135 | 136 | loop { 137 | let name = name.to_string(); 138 | let line = reader.read_line(); 139 | 140 | let exit = match line { 141 | Ok(line_reader::Line::EOF(_)) => true, 142 | _ => false, 143 | }; 144 | 145 | tx.send(Message::Line(Line { name, line })).unwrap(); 146 | 147 | if exit { 148 | break; 149 | } 150 | } 151 | 152 | let mut eof = eof_mutex.lock().unwrap(); 153 | *eof = true; 154 | }) 155 | } 156 | 157 | fn pid(&self) -> Pid { 158 | nix::unistd::Pid::from_raw(self.cmd.id() as i32) 159 | } 160 | 161 | fn kill(&mut self, sig: Signal) { 162 | if !self.is_process_alive() { 163 | return; 164 | } 165 | 166 | // Don't send the same signal twice 167 | if let Some(prev) = self.kill_sent { 168 | if prev == sig { 169 | return; 170 | } 171 | } 172 | 173 | self.kill_sent = Some(sig); 174 | 175 | let pid = self.pid(); 176 | 177 | log!("Sending {} to {}({})", sig, self.name, pid); 178 | if let Err(err) = kill(pid, sig) { 179 | log!("kill failed for [{}] {}", self.name, err); 180 | } 181 | } 182 | 183 | fn is_process_alive(&self) -> bool { 184 | !self.is_dead 185 | } 186 | 187 | fn is_alive(&self) -> bool { 188 | if !self.is_dead { 189 | return true; 190 | } 191 | 192 | let stdout_eof = self.stdout_eof.lock().unwrap(); 193 | 194 | if !*stdout_eof { 195 | return true; 196 | } 197 | 198 | let stderr_eof = self.stderr_eof.lock().unwrap(); 199 | 200 | if !*stderr_eof { 201 | return true; 202 | } 203 | 204 | return false; 205 | } 206 | } 207 | 208 | fn command_with_name(s: &String) -> (&str, &str) { 209 | let bytes = s.as_bytes(); 210 | 211 | for (i, &item) in bytes.iter().enumerate() { 212 | if item == b':' { 213 | return (&s[0..i], (&s[i + 1..]).trim()); 214 | } 215 | } 216 | 217 | panic!("cannot parse name from> {}", s); 218 | } 219 | 220 | #[cfg(not(target_os = "linux"))] 221 | fn become_subreaper() -> Result<(), String> { 222 | Ok(()) 223 | } 224 | 225 | #[cfg(target_os = "linux")] 226 | fn become_subreaper() -> Result<(), String> { 227 | // pid 1 does not need become subreaper because it is _the_ reaper 228 | if id() == 1 { 229 | return Ok(()); 230 | } 231 | 232 | let ret = unsafe { prctl(PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0) }; 233 | 234 | if ret == -1 { 235 | Err(format!("Failed to become subreaper. Code {}", ret)) 236 | } else { 237 | Ok(()) 238 | } 239 | } 240 | 241 | fn main() { 242 | let args: Vec = env::args().collect(); 243 | 244 | for command in args[1..].iter() { 245 | if command == "--version" { 246 | println!("version {}", option_env!("MULTIP_VERSION").unwrap_or("DEV")); 247 | println!("git rev {}", option_env!("GITHUB_SHA").unwrap_or("DEV")); 248 | return; 249 | } 250 | } 251 | 252 | if let Err(fail_msg) = become_subreaper() { 253 | eprintln!("{}", fail_msg); 254 | std::process::exit(1); 255 | } 256 | 257 | log!("Started multip with pid {}", id()); 258 | 259 | let (tx, rx) = mpsc::channel::(); 260 | 261 | signal_closure::trap_signal(signal::SIGINT); 262 | signal_closure::trap_signal(signal::SIGTERM); 263 | signal_closure::trap_signal(signal::SIGQUIT); 264 | signal_closure::trap_signal(signal::SIGCHLD); 265 | 266 | let t = mpsc::Sender::clone(&tx); 267 | signal_closure::poll_signals(move |sig| { 268 | t.send(Message::ParentSignal(sig)).unwrap(); 269 | }); 270 | 271 | let mut children: Vec = Vec::new(); 272 | 273 | for command in args[1..].iter() { 274 | let (name, command) = command_with_name(command); 275 | let child = MultipChild::spawn(name, command, &tx); 276 | children.push(child) 277 | } 278 | 279 | let mut killall: Option = None; 280 | let mut sigint_count = 0; 281 | let mut multip_exit_code: Option = None; 282 | 283 | loop { 284 | // Manually check for dead children with the given timeout 285 | let msg = rx.recv_timeout(Duration::from_millis(100)); 286 | let somebody_is_alive = children.iter().any(|child| child.is_alive()); 287 | let mut forward: Option = None; 288 | 289 | // Look for dead chilren on every event 290 | // AKA reap zombies 291 | for (pid, exit_code) in waitpid::iter_dead_children() { 292 | let child = children.iter_mut().find(|child| child.pid() == pid); 293 | 294 | match child { 295 | Some(child) => { 296 | log!("Child {} died with exit code {}", child, exit_code); 297 | child.is_dead = true; 298 | if killall.is_none() { 299 | log!("Killing all other children too"); 300 | killall = Some(Signal::SIGTERM); 301 | } 302 | 303 | if multip_exit_code.is_none() { 304 | multip_exit_code = Some(exit_code); 305 | } 306 | } 307 | None => { 308 | log!( 309 | "Reaped zombie process({}) with exit code {}", 310 | pid, 311 | exit_code 312 | ); 313 | } 314 | } 315 | } 316 | 317 | match msg { 318 | Err(RecvTimeoutError::Timeout) => { 319 | // loop tick 320 | } 321 | 322 | Ok(Message::ParentSignal(Signal::SIGCHLD)) => { 323 | // no-op signal just for looking dead children 324 | } 325 | 326 | Ok(Message::ParentSignal(Signal::SIGINT)) => { 327 | forward = Some(Signal::SIGINT); 328 | sigint_count += 1; 329 | 330 | if sigint_count == 2 { 331 | log!("Got second SIGINT, converting it to SIGKILL"); 332 | forward = Some(Signal::SIGTERM); 333 | } else if sigint_count > 2 { 334 | log!("Got third SIGINT, converting it to SIGKILL"); 335 | forward = Some(Signal::SIGKILL); 336 | } 337 | } 338 | 339 | Ok(Message::ParentSignal(parent_signal)) => { 340 | log!("Forwarding parent signal {} to children", parent_signal); 341 | forward = Some(parent_signal); 342 | } 343 | 344 | Ok(Message::Line(line)) => { 345 | line.print(); 346 | } 347 | 348 | Err(RecvTimeoutError::Disconnected) => { 349 | println!("Channel disconnected"); 350 | break; 351 | } 352 | } 353 | 354 | for child in children.iter_mut() { 355 | if let Some(sig) = forward { 356 | child.kill(sig); 357 | } 358 | 359 | if let Some(sig) = killall { 360 | child.kill(sig); 361 | } 362 | } 363 | 364 | if !somebody_is_alive { 365 | log!("All processes died. Exiting..."); 366 | break; 367 | } 368 | } 369 | 370 | // Print all pending message from the buffers 371 | for msg in rx.try_iter() { 372 | match msg { 373 | Message::Line(line) => { 374 | line.print(); 375 | } 376 | Message::ParentSignal(_) => { 377 | // Ignore signals on exit 378 | } 379 | } 380 | } 381 | 382 | std::process::exit(multip_exit_code.unwrap_or(0)); 383 | } 384 | -------------------------------------------------------------------------------- /src/signal_closure.rs: -------------------------------------------------------------------------------- 1 | use lazy_static::lazy_static; 2 | use libc::c_int; 3 | use nix::sys::signal::Signal; 4 | use nix::sys::signal::{sigaction, SaFlags, SigAction, SigHandler, SigSet}; 5 | use std::convert::TryFrom; 6 | use std::marker::Send; 7 | use std::sync::{Arc, Condvar, Mutex}; 8 | use std::thread; 9 | 10 | use crate::log; 11 | 12 | // Store last received signal here 13 | lazy_static! { 14 | static ref SIGNAL: Arc> = Arc::new(Mutex::new(0)); 15 | static ref CONDVAR: Arc = Arc::new(Condvar::new()); 16 | } 17 | 18 | extern "C" fn handle_os_signal(s: c_int) { 19 | let mut current_signal = SIGNAL.lock().expect("signal fail"); 20 | *current_signal = s; 21 | CONDVAR.notify_one(); 22 | } 23 | 24 | pub fn trap_signal(s: Signal) { 25 | let handler = SigHandler::Handler(handle_os_signal); 26 | 27 | // https://www.gnu.org/software/libc/manual/html_node/Flags-for-Sigaction.html 28 | let sa_flags = SaFlags::SA_RESTART; 29 | 30 | // Block all other signals while the signal handler is executing 31 | let sig_set = SigSet::all(); 32 | 33 | unsafe { sigaction(s, &SigAction::new(handler, sa_flags, sig_set)) } 34 | .expect("Failed to set signal handler"); 35 | } 36 | 37 | // Poll for the sotred signal and send it back via the channel 38 | pub fn poll_signals(cb: F) 39 | where 40 | F: 'static + Send + Fn(Signal) -> (), 41 | { 42 | thread::spawn(move || loop { 43 | let current_signal = SIGNAL.lock().expect("signal fail"); 44 | let sig = *CONDVAR.wait(current_signal).expect("wait fail"); 45 | 46 | if sig == 0 { 47 | log!("Got weird signal 0"); 48 | continue; 49 | } 50 | 51 | let try_sig = Signal::try_from(sig); 52 | let sig = match try_sig { 53 | Ok(sig) => sig, 54 | _ => { 55 | log!("Signal parsing failed"); 56 | continue; 57 | } 58 | }; 59 | 60 | cb(sig); 61 | }); 62 | } 63 | -------------------------------------------------------------------------------- /src/waitpid.rs: -------------------------------------------------------------------------------- 1 | use nix::errno::Errno; 2 | use nix::sys::wait::WaitStatus::{Exited, Signaled, StillAlive}; 3 | use nix::sys::wait::{waitpid, WaitPidFlag}; 4 | use nix::unistd::Pid; 5 | use nix::Error::Sys; 6 | 7 | use crate::*; 8 | 9 | pub struct ProcessWaiter {} 10 | 11 | impl Iterator for ProcessWaiter { 12 | type Item = (Pid, i32); 13 | 14 | fn next(&mut self) -> Option { 15 | // -1 meaning wait for any child process. 16 | // WNOHANG return immediately if no child has exited. 17 | let status = waitpid(Pid::from_raw(-1), Some(WaitPidFlag::WNOHANG)); 18 | 19 | match status { 20 | Ok(Exited(pid, exit_code)) => Some((pid, exit_code)), 21 | 22 | Ok(Signaled(pid, signal, _core_dumped)) => { 23 | debug!("waitpid(): {} killed with signal {}", pid, signal); 24 | Some((pid, 0)) 25 | } 26 | 27 | Ok(StillAlive) => None, 28 | 29 | Ok(status) => { 30 | log!("Unknown status from waitpid() {:#?}", status); 31 | None 32 | } 33 | 34 | Err(Sys(Errno::ECHILD)) => { 35 | // log!("No child processess"); 36 | None 37 | } 38 | 39 | Err(err) => { 40 | log!("Failed to waitpid() {}", err); 41 | None 42 | } 43 | } 44 | } 45 | } 46 | 47 | pub fn iter_dead_children() -> ProcessWaiter { 48 | ProcessWaiter {} 49 | } 50 | -------------------------------------------------------------------------------- /tests/signals.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys, time, signal 4 | 5 | def log(msg): 6 | print(msg) 7 | sys.stdout.flush() 8 | 9 | 10 | def handle_signal(sig, frame): 11 | log("got signal {}".format(sig)) 12 | 13 | signal.signal(signal.SIGINT, handle_signal) 14 | signal.signal(signal.SIGTERM, handle_signal) 15 | 16 | log("starting") 17 | time.sleep(2) 18 | sys.exit(55) -------------------------------------------------------------------------------- /tests/test_cli.rs: -------------------------------------------------------------------------------- 1 | use nix::sys::signal::{kill, Signal}; 2 | use regex::Regex; 3 | use std::io::{BufRead, BufReader}; 4 | use std::process::{ChildStdout, Command, Stdio}; 5 | use std::thread; 6 | use std::time::Duration; 7 | 8 | fn run_multip(args: Vec<&str>) -> Command { 9 | let mut cmd = Command::new("target/debug/multip"); 10 | 11 | cmd.stdout(Stdio::piped()); 12 | cmd.stderr(Stdio::piped()); 13 | cmd.args(args); 14 | 15 | cmd 16 | } 17 | 18 | fn get_lines(out: Option) -> Vec { 19 | let out = out.expect("get stdout/stderr"); 20 | let mut lines: Vec = Vec::new(); 21 | let buf = BufReader::new(out); 22 | for line in buf.lines() { 23 | let line = line.unwrap_or("".to_string()); 24 | lines.push(line); 25 | } 26 | lines 27 | } 28 | 29 | fn assert_has_line(lines: &Vec, needle_line: &str) { 30 | let found = lines.iter().find(|&line| line.trim() == needle_line); 31 | 32 | if found.is_some() { 33 | return; 34 | } 35 | 36 | for line in lines { 37 | eprintln!("LINE> {}", line); 38 | } 39 | 40 | panic!("Failed to find line: {}", needle_line); 41 | } 42 | 43 | fn assert_line_matches(lines: &Vec, pat: &str, count: i32) { 44 | let re = Regex::new(pat).unwrap(); 45 | 46 | let mut match_count = 0; 47 | 48 | for line in lines { 49 | if re.is_match(line.trim()) { 50 | match_count = match_count + 1; 51 | } 52 | } 53 | 54 | if match_count == count { 55 | return; 56 | } 57 | 58 | for line in lines { 59 | eprintln!("LINE> {}", line); 60 | } 61 | 62 | panic!( 63 | "Failed to get {} (got {}) matches with RegExp: {}", 64 | count, match_count, pat 65 | ); 66 | } 67 | 68 | #[test] 69 | fn run_single_command() { 70 | let mut cmd = run_multip(vec!["foo: sh -c 'echo hello'"]).spawn().unwrap(); 71 | 72 | let lines = get_lines(cmd.stdout.take()); 73 | 74 | assert_has_line(&lines, "[foo] hello"); 75 | 76 | cmd.wait().unwrap(); 77 | } 78 | 79 | #[test] 80 | fn run_multiple_commands() { 81 | let mut cmd = run_multip(vec![ 82 | "foo: sh -c 'echo hello foo && sleep 0.1'", 83 | "bar: sh -c 'echo hello bar && sleep 0.1'", 84 | ]) 85 | .spawn() 86 | .unwrap(); 87 | 88 | let lines = get_lines(cmd.stdout.take()); 89 | 90 | assert_has_line(&lines, "[foo] hello foo"); 91 | assert_has_line(&lines, "[bar] hello bar"); 92 | 93 | cmd.wait().unwrap(); 94 | } 95 | 96 | #[test] 97 | fn uses_exit_code_of_first_dead_child() { 98 | let mut cmd = run_multip(vec![ 99 | "foo: sh -c 'sleep 0.1 && exit 11'", 100 | "bar: sh -c 'sleep 0.2 && exit 22'", 101 | ]) 102 | .spawn() 103 | .unwrap(); 104 | 105 | let status_code = cmd.wait().unwrap().code().unwrap(); 106 | assert_eq!(status_code, 11); 107 | } 108 | 109 | #[test] 110 | fn uses_exit_code_of_first_dead_child_with_zore() { 111 | let mut cmd = run_multip(vec![ 112 | "foo: sh -c 'sleep 0.1 && exit 0'", 113 | "bar: sh -c 'sleep 0.2 && exit 22'", 114 | ]) 115 | .spawn() 116 | .unwrap(); 117 | 118 | let status_code = cmd.wait().unwrap().code().unwrap(); 119 | assert_eq!(status_code, 0); 120 | } 121 | 122 | #[test] 123 | #[cfg(target_os = "linux")] 124 | fn reaps_zombies() { 125 | let mut cmd = run_multip(vec!["test: ./tests/zombie_creator.py"]) 126 | .spawn() 127 | .unwrap(); 128 | 129 | let lines = get_lines(cmd.stdout.take()); 130 | cmd.wait().unwrap(); 131 | 132 | assert_line_matches(&lines, r"Reaped zombie process(.*) with exit code 12", 1); 133 | } 134 | 135 | #[test] 136 | fn wraps_long_lines() { 137 | let mut cmd = run_multip(vec!["foo: sh -c 'echo 1234567890'"]) 138 | .env("MULTIP_MAX_LINE_LENGTH", "5") 139 | .spawn() 140 | .unwrap(); 141 | 142 | let lines = get_lines(cmd.stdout.take()); 143 | cmd.wait().unwrap(); 144 | 145 | assert_has_line(&lines, "[foo...] 12345"); 146 | assert_has_line(&lines, "[foo...] 67890"); 147 | } 148 | 149 | #[test] 150 | fn signal_handling() { 151 | let mut cmd = run_multip(vec!["test: ./tests/signals.py"]) 152 | .spawn() 153 | .unwrap(); 154 | 155 | let pid = nix::unistd::Pid::from_raw(cmd.id() as i32); 156 | 157 | thread::sleep(Duration::from_millis(100)); 158 | kill(pid, Signal::SIGINT).unwrap(); 159 | thread::sleep(Duration::from_millis(50)); 160 | kill(pid, Signal::SIGINT).unwrap(); 161 | thread::sleep(Duration::from_millis(50)); 162 | kill(pid, Signal::SIGINT).unwrap(); 163 | 164 | let lines = get_lines(cmd.stdout.take()); 165 | cmd.wait().unwrap(); 166 | assert_has_line(&lines, "[test] got signal 2"); 167 | assert_has_line(&lines, "[test] got signal 15"); 168 | } 169 | -------------------------------------------------------------------------------- /tests/zombie_creator.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import time, os, sys 4 | 5 | def log(msg): 6 | print(msg) 7 | sys.stdout.flush() 8 | 9 | 10 | # Split into a worker 11 | worker_pid = os.fork() 12 | 13 | if worker_pid != 0: 14 | # The parent just waits for the worker to exit 15 | os.waitpid(worker_pid, 0) 16 | log("Main: Worker exit captured. Sleeping.") 17 | # and wait a bit for it to out live orphans 18 | time.sleep(0.3) 19 | log("Main exiting...") 20 | sys.exit(11) 21 | 22 | 23 | log("Started worker {}".format(os.getpid())) 24 | child_pid = os.fork() 25 | 26 | if child_pid == 0: 27 | # Sleep longer than the parent so this becomes a zombie when exiting as 28 | # it has no parent that can wait on it 29 | log("Child {} started with parent {}".format( os.getpid(), os.getppid())) 30 | time.sleep(0.2) 31 | log("Orpan parent is now {}".format(os.getppid())) 32 | log("Orphan exiting and becoming zombie") 33 | sys.exit(12) 34 | else: 35 | log("Created child {}".format(child_pid)) 36 | time.sleep(0.1) 37 | log("Worker exiting, making the child orphan") 38 | # Exit before the child without waiting for it so it becomes an orphan 39 | sys.exit(13) 40 | -------------------------------------------------------------------------------- /tools/release: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -eu 4 | 5 | if [ "$(git status --porcelain .)" != "" ]; then 6 | echo "Dirty git" 7 | exit 1 8 | fi 9 | 10 | if [ "$(git rev-parse --abbrev-ref HEAD)" != "master" ]; then 11 | echo "Bad branch" 12 | exit 1 13 | fi 14 | 15 | git fetch 16 | git push origin master:master 17 | 18 | git tag --sort=committerdate --list 'v[0-9]*' 19 | 20 | echo "New version without the 'v'" 21 | read -p "version> " version 22 | 23 | git push origin HEAD:release/v${version} --------------------------------------------------------------------------------