diff --git a/History.md b/History.md index 94aab2e..24cf8e2 100644 --- a/History.md +++ b/History.md @@ -1,3 +1,8 @@ +### v3.1.0 + - [#40](https://github.com/darkguy2008/parallelshell/issues/40) Add + `-t, --timeout ` to stop remaining commands after a deadline, + including with `--wait`. + ### v3.0.4 - [#41](https://github.com/darkguy2008/parallelshell/issues/41) Mention `& wait` in the README. diff --git a/README.md b/README.md index 4d36d4b..21e2652 100644 --- a/README.md +++ b/README.md @@ -67,5 +67,24 @@ Available options: -h, --help output usage information -v, --verbose verbose logging -w, --wait will not close sibling processes on error +-t, --timeout stop remaining commands after the deadline ``` + +Use `--timeout` (or `-t`) to limit the overall run, including when `--wait` is +enabled: + +```bash +parallelshell --timeout 10 "node server.js" "node request.js" +``` + +The deadline starts after the commands are launched. Seconds may be fractional +and must be positive and no greater than 2147483.647. If commands are still +running at the deadline, parallelshell reports the timeout and exits with code +124, or preserves an earlier non-zero command exit code with `--wait`. Commands +that finish before the deadline retain their normal exit status. + +Timeouts use the existing shutdown behavior: SIGINT to direct children on Unix, +and forced process-tree termination on Windows. On Unix this is not a guaranteed +hard deadline: commands that ignore SIGINT or leave descendants running may keep +the run alive. diff --git a/index.js b/index.js index 52dbf53..2bb684c 100755 --- a/index.js +++ b/index.js @@ -7,15 +7,26 @@ var signals = require('constants'); var SIGNAL_EXIT_CODE_BASE = 128; var FORWARDED_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP']; var WINDOWS_CONTROL_C_EXIT = 0xC000013A; +var TIMEOUT_EXIT_CODE = 124; +var MILLISECONDS_PER_SECOND = 1000; +var MAX_TIMEOUT_MS = Math.pow(2, 31) - 1; var WINDOWS = process.platform === 'win32'; var commandPrefix = WINDOWS ? '' : 'exec '; -var children, args, wait, cmds, verbose, i ,len; +var children, args, wait, cmds, verbose, timeout, timer, i ,len; cmds = []; args = process.argv.slice(2); for (i = 0, len = args.length; i < len; i++) { if (args[i][0] === '-') { switch (args[i]) { + case '-t': + case '--timeout': + timeout = Number(args[++i]) * MILLISECONDS_PER_SECOND; + if (!isFinite(timeout) || timeout <= 0 || timeout > MAX_TIMEOUT_MS) { + console.error('--timeout requires positive seconds no greater than ' + MAX_TIMEOUT_MS / MILLISECONDS_PER_SECOND); + process.exit(1); + } + break; case '-w': case '--wait': wait = true; @@ -29,6 +40,7 @@ for (i = 0, len = args.length; i < len; i++) { console.log('-h, --help output usage information'); console.log('-v, --verbose verbose logging') console.log('-w, --wait will not close sibling processes on error') + console.log('-t, --timeout stop remaining commands after the deadline'); process.exit(); break; } @@ -38,6 +50,9 @@ for (i = 0, len = args.length; i < len; i++) { } function childClose (code, signal) { + if (children.every(function (child) { + return child.exitCode !== null || child.signalCode !== null; + })) clearTimeout(timer); code = signal ? SIGNAL_EXIT_CODE_BASE + signals[signal] : code; if (verbose) { if (code > 0) { @@ -74,6 +89,7 @@ function status () { } function close (signal) { + clearTimeout(timer); var running = children.filter(function (child) { return child.exitCode === null && child.signalCode === null; }); @@ -126,3 +142,11 @@ children = cmds.map(function (cmd) { child.cmd = command; return child; }); + +if (timeout && children.length) { + timer = setTimeout(function () { + console.error('parallelshell timed out after ' + timeout / MILLISECONDS_PER_SECOND + ' seconds'); + process.exitCode = process.exitCode || TIMEOUT_EXIT_CODE; + close(); + }, timeout); +} diff --git a/package.json b/package.json index ca2630e..825ca3b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parallelshell", - "version": "3.0.4", + "version": "3.1.0", "description": "Invoke multiple commands, running in parallel", "homepage": "https://github.com/darkguy2008/parallelshell", "repository": { diff --git a/test/index.coffee b/test/index.coffee index ba512fb..277fc1c 100644 --- a/test/index.coffee +++ b/test/index.coffee @@ -27,6 +27,9 @@ POWERSHELL_ARGS = ["-NoProfile", "-ExecutionPolicy", "Bypass"] ENV_NAME = "PARALLELSHELL_TEST_ENV" ENV_VALUE = "passed-through" QUOTED_TEXT = "two spaces" +TIMEOUT_SECONDS = 2 +TIMEOUT_EXIT_CODE = 124 +COMPLETION_TIMEOUT_SECONDS = 60 fixture = (name, args...) -> [process.execPath, path.join(FIXTURES_DIR, name)].concat(args).join " " exitProcess = (code) -> fixture "exit.js", code @@ -39,6 +42,7 @@ usageInfo = """ -h, --help output usage information -v, --verbose verbose logging -w, --wait will not close sibling processes on error +-t, --timeout stop remaining commands after the deadline """ + "\n" spawned = [] @@ -114,6 +118,62 @@ afterEach -> fs.rmdirSync directory describe "parallelshell", -> + ["-t", "--timeout"].forEach (flag) -> + it "should stop running commands at the deadline with #{flag}", -> + ps = spawnParallelshell flag, String(TIMEOUT_SECONDS), succeedingProcess, waitingProcess(), waitingProcess() + waitForReady(ps, 2).then (pids) -> + ps.exited.then (result) -> + result.should.deep.equal {code: TIMEOUT_EXIT_CODE, signal: null} + ps.errorOutput.should.contain "timed out after #{TIMEOUT_SECONDS} seconds" + pids.filter(isAlive).should.be.empty + + it "should time out with --wait and preserve an earlier failure", -> + ps = spawnParallelshell "--wait", "-v", "--timeout", String(TIMEOUT_SECONDS), exitProcess(FAILURE_EXIT_CODE), waitingProcess() + Promise.all [waitForReady(ps, 1), waitForOutput(ps, hasLineEndingWith ERRORED_SUFFIX)] + .then ([pids]) -> + ps.exited.then (result) -> + result.code.should.equal FAILURE_EXIT_CODE + ps.errorOutput.should.contain "timed out" + pids.filter(isAlive).should.be.empty + + it "should time out successful commands that remain running with --wait", -> + ps = spawnParallelshell "--wait", "--timeout", String(TIMEOUT_SECONDS), waitingProcess() + ps.exited.then (result) -> result.code.should.equal TIMEOUT_EXIT_CODE + + it "should cancel a timeout when every command finishes", -> + ps = spawnParallelshell "--timeout", String(COMPLETION_TIMEOUT_SECONDS), succeedingProcess, succeedingProcess + ps.exited.then (result) -> + result.code.should.equal 0 + ps.errorOutput.should.equal "" + + it "should cancel a timeout when a command fails", -> + ps = spawnParallelshell "--timeout", String(COMPLETION_TIMEOUT_SECONDS), exitProcess FAILURE_EXIT_CODE + ps.exited.then (result) -> + result.code.should.equal FAILURE_EXIT_CODE + ps.errorOutput.should.not.contain "timed out" + + it "should retain the failure code when all commands finish with --wait", -> + ps = spawnParallelshell "--wait", "--timeout", String(COMPLETION_TIMEOUT_SECONDS), exitProcess(FAILURE_EXIT_CODE), succeedingProcess + ps.exited.then (result) -> + result.code.should.equal FAILURE_EXIT_CODE + ps.errorOutput.should.not.contain "timed out" + + it "should exit successfully with a timeout and no commands", -> + spawnParallelshell("--timeout", String(COMPLETION_TIMEOUT_SECONDS)).exited.then (result) -> result.code.should.equal 0 + + it "should accept fractional timeout seconds", -> + spawnParallelshell("--timeout", "0.001", waitingProcess()).exited.then (result) -> result.code.should.equal TIMEOUT_EXIT_CODE + + [undefined, "0", "-1", "nope", "Infinity", "2147483.648", "--wait"].forEach (value) -> + it "should reject invalid timeout #{value} before launching commands", -> + args = [succeedingProcess, "--timeout"] + args.push value if value != undefined + ps = spawnParallelshell args... + ps.exited.then (result) -> + result.code.should.equal 1 + ps.output.should.equal "" + ps.errorOutput.should.contain "--timeout requires positive seconds" + it "should print on -h and --help", -> Promise.all ["-h", "--help"].map (flag) -> ps = spawnParallelshell flag @@ -203,7 +263,7 @@ describe "parallelshell", -> FORWARDED_SIGNALS.forEach (signal) -> onPosix "should stop its children with #{signal} and die by #{signal} without crashing", -> - ps = spawnParallelshell waitingProcess(), waitingProcess() + ps = spawnParallelshell "--timeout", String(COMPLETION_TIMEOUT_SECONDS), waitingProcess(), waitingProcess() waitForReady(ps, 2).then (pids) -> ps.kill signal ps.exited.then (result) ->