From d4767fae8e0b3890187312ae9c7fe6c3805ce30c Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 9 Sep 2025 22:32:33 +0300 Subject: [PATCH 1/4] Initial commit with task details for issue #22 Adding CLAUDE.md with task information for AI processing. This file will be removed when the task is complete. Issue: https://github.com/link-foundation/command-stream/issues/22 --- CLAUDE.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..91e4846e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +Issue to solve: https://github.com/link-foundation/command-stream/issues/22 +Your prepared branch: issue-22-51ab60ad +Your prepared working directory: /tmp/gh-issue-solver-1757446346826 + +Proceed. \ No newline at end of file From 6be0a4de1a9aa0854bb3017ba04121c7bae53bdc Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 9 Sep 2025 22:32:49 +0300 Subject: [PATCH 2/4] Remove CLAUDE.md - PR created successfully --- CLAUDE.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 91e4846e..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,5 +0,0 @@ -Issue to solve: https://github.com/link-foundation/command-stream/issues/22 -Your prepared branch: issue-22-51ab60ad -Your prepared working directory: /tmp/gh-issue-solver-1757446346826 - -Proceed. \ No newline at end of file From 5d15cbca6bc0564109ca2639b0b64ab8e9b101bf Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 9 Sep 2025 22:37:03 +0300 Subject: [PATCH 3/4] Add comprehensive parallel sleep commands test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements test for issue #22 that validates the ability to execute 2-3 commands with sleep inside in parallel. The test suite includes: - 2 parallel sleep commands with timing validation - 3 parallel sleep commands with timing validation - Mixed duration sleep commands executed in parallel - Parallel commands with output verification - Individual execution context verification for parallel commands All tests verify that commands execute truly in parallel (not sequentially) by measuring execution time and ensuring it matches the longest sleep duration rather than the sum of all sleep durations. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- tests/parallel-sleep.test.mjs | 147 ++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 tests/parallel-sleep.test.mjs diff --git a/tests/parallel-sleep.test.mjs b/tests/parallel-sleep.test.mjs new file mode 100644 index 00000000..a02cac96 --- /dev/null +++ b/tests/parallel-sleep.test.mjs @@ -0,0 +1,147 @@ +import { test, expect, describe, beforeEach, afterEach } from 'bun:test'; +import './test-helper.mjs'; // Automatically sets up beforeEach/afterEach cleanup +import { $, shell, disableVirtualCommands } from '../src/$.mjs'; + +// Reset shell settings before each test to prevent interference +beforeEach(() => { + shell.errexit(false); + shell.verbose(false); + shell.xtrace(false); + shell.pipefail(false); + shell.nounset(false); + // Disable virtual commands for these tests to ensure system command behavior + disableVirtualCommands(); +}); + +// Reset shell settings after each test to prevent interference with other test files +afterEach(() => { + shell.errexit(false); + shell.verbose(false); + shell.xtrace(false); + shell.pipefail(false); + shell.nounset(false); +}); + +describe('Parallel Sleep Commands Execution', () => { + test('should execute 2 sleep commands in parallel', async () => { + const startTime = Date.now(); + + // Start 2 parallel sleep commands with 0.5 second delay each + const promises = [ + $`sleep 0.5`, + $`sleep 0.5` + ]; + + const results = await Promise.all(promises); + const endTime = Date.now(); + const duration = endTime - startTime; + + // Both commands should complete successfully + expect(results[0].code).toBe(0); + expect(results[1].code).toBe(0); + + // Total duration should be closer to 0.5s (parallel) than 1.0s (sequential) + // Allow some tolerance for system overhead + expect(duration).toBeLessThan(800); // Should be much less than 800ms if truly parallel + expect(duration).toBeGreaterThan(400); // Should be at least 400ms since sleep is 0.5s + }); + + test('should execute 3 sleep commands in parallel', async () => { + const startTime = Date.now(); + + // Start 3 parallel sleep commands with 0.3 second delay each + const promises = [ + $`sleep 0.3`, + $`sleep 0.3`, + $`sleep 0.3` + ]; + + const results = await Promise.all(promises); + const endTime = Date.now(); + const duration = endTime - startTime; + + // All commands should complete successfully + expect(results[0].code).toBe(0); + expect(results[1].code).toBe(0); + expect(results[2].code).toBe(0); + + // Total duration should be closer to 0.3s (parallel) than 0.9s (sequential) + // Allow some tolerance for system overhead + expect(duration).toBeLessThan(600); // Should be much less than 600ms if truly parallel + expect(duration).toBeGreaterThan(250); // Should be at least 250ms since sleep is 0.3s + }); + + test('should execute mixed duration sleep commands in parallel', async () => { + const startTime = Date.now(); + + // Start 3 parallel sleep commands with different durations + const promises = [ + $`sleep 0.2`, + $`sleep 0.4`, + $`sleep 0.3` + ]; + + const results = await Promise.all(promises); + const endTime = Date.now(); + const duration = endTime - startTime; + + // All commands should complete successfully + expect(results[0].code).toBe(0); + expect(results[1].code).toBe(0); + expect(results[2].code).toBe(0); + + // Total duration should be determined by the longest sleep (0.4s), not the sum (0.9s) + // Allow some tolerance for system overhead + expect(duration).toBeLessThan(650); // Should be much less than 650ms if truly parallel + expect(duration).toBeGreaterThan(350); // Should be at least 350ms since longest sleep is 0.4s + }); + + test('should handle parallel sleep commands with output verification', async () => { + const startTime = Date.now(); + + // Start 2 parallel sleep commands that also produce output + const promises = [ + $`sh -c 'echo "command1 start"; sleep 0.2; echo "command1 end"'`, + $`sh -c 'echo "command2 start"; sleep 0.3; echo "command2 end"'` + ]; + + const results = await Promise.all(promises); + const endTime = Date.now(); + const duration = endTime - startTime; + + // Both commands should complete successfully + expect(results[0].code).toBe(0); + expect(results[1].code).toBe(0); + + // Verify output content + expect(results[0].stdout.trim()).toContain('command1 start'); + expect(results[0].stdout.trim()).toContain('command1 end'); + expect(results[1].stdout.trim()).toContain('command2 start'); + expect(results[1].stdout.trim()).toContain('command2 end'); + + // Duration should be closer to max(0.2s, 0.3s) = 0.3s, not sum = 0.5s + expect(duration).toBeLessThan(550); // Should be much less than 550ms if truly parallel + expect(duration).toBeGreaterThan(250); // Should be at least 250ms since longest sleep is 0.3s + }); + + test('should maintain individual command execution context in parallel', async () => { + // Test that each parallel command maintains its own execution environment + const commands = [ + $`sh -c 'VAR="value1"; sleep 0.1; echo "Command 1: $VAR"'`, + $`sh -c 'VAR="value2"; sleep 0.1; echo "Command 2: $VAR"'`, + $`sh -c 'VAR="value3"; sleep 0.1; echo "Command 3: $VAR"'` + ]; + + const results = await Promise.all(commands); + + // All commands should succeed + results.forEach(result => { + expect(result.code).toBe(0); + }); + + // Each should have its own variable value + expect(results[0].stdout.trim()).toBe('Command 1: value1'); + expect(results[1].stdout.trim()).toBe('Command 2: value2'); + expect(results[2].stdout.trim()).toBe('Command 3: value3'); + }); +}); \ No newline at end of file From 35b7589dd6723a70ec38edacd328eaf296334526 Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 16 Sep 2026 09:14:41 +0000 Subject: [PATCH 4/4] Test parallel sleeping commands in JavaScript and Rust Issue #22 asks for a test that 2-3 commands with a sleep inside can be started in parallel and all of them finish. The JavaScript test moved to js/tests/ with the monorepo layout, and the same coverage now exists for the Rust crate so both implementations stay in parity. Wall-clock thresholds alone are a weak signal on a loaded CI machine, so each test also records the window every command occupied and asserts the windows overlap - that is the property 'parallel' actually describes. Coverage spans the built-in sleep, real sleep processes, and sh -c scripts that sleep between writes while keeping their own output and environment. --- .../issue-22-parallel-sleep-tests.md | 9 + js/tests/parallel-sleep.test.mjs | 317 ++++++++++-------- .../20260916_091500_parallel_sleep_tests.md | 11 + rust/tests/parallel_sleep.rs | 213 ++++++++++++ 4 files changed, 413 insertions(+), 137 deletions(-) create mode 100644 js/.changeset/issue-22-parallel-sleep-tests.md create mode 100644 rust/changelog.d/20260916_091500_parallel_sleep_tests.md create mode 100644 rust/tests/parallel_sleep.rs diff --git a/js/.changeset/issue-22-parallel-sleep-tests.md b/js/.changeset/issue-22-parallel-sleep-tests.md new file mode 100644 index 00000000..e111943f --- /dev/null +++ b/js/.changeset/issue-22-parallel-sleep-tests.md @@ -0,0 +1,9 @@ +--- +'command-stream': patch +--- + +Cover parallel execution of sleeping commands with tests (issue #22). Two and +three commands started together — through the built-in `sleep`, through real +`sleep` processes with virtual commands disabled, and through `sh -c` scripts +that sleep between writes — must all finish, keep their own output and +environment, and overlap in time instead of running one after another. diff --git a/js/tests/parallel-sleep.test.mjs b/js/tests/parallel-sleep.test.mjs index a02cac96..a8f22731 100644 --- a/js/tests/parallel-sleep.test.mjs +++ b/js/tests/parallel-sleep.test.mjs @@ -1,147 +1,190 @@ -import { test, expect, describe, beforeEach, afterEach } from 'bun:test'; -import './test-helper.mjs'; // Automatically sets up beforeEach/afterEach cleanup -import { $, shell, disableVirtualCommands } from '../src/$.mjs'; - -// Reset shell settings before each test to prevent interference -beforeEach(() => { - shell.errexit(false); - shell.verbose(false); - shell.xtrace(false); - shell.pipefail(false); - shell.nounset(false); - // Disable virtual commands for these tests to ensure system command behavior - disableVirtualCommands(); -}); +import { describe, expect, test } from 'bun:test'; +import { isWindows } from './test-helper.mjs'; // Installs per-test state cleanup +import { $, disableVirtualCommands } from '../src/$.mjs'; -// Reset shell settings after each test to prevent interference with other test files -afterEach(() => { - shell.errexit(false); - shell.verbose(false); - shell.xtrace(false); - shell.pipefail(false); - shell.nounset(false); -}); +// Issue #22: starting 2-3 commands that sleep inside must run them at the same +// time and let every one of them finish. +// +// Timing alone is a weak signal on a loaded CI machine, so each test asserts +// three independent things: +// 1. every command completed successfully (and produced its output), +// 2. the execution windows of the commands overlap, which is what "parallel" +// actually means, +// 3. the wall clock stayed well below the sequential total. -describe('Parallel Sleep Commands Execution', () => { - test('should execute 2 sleep commands in parallel', async () => { - const startTime = Date.now(); - - // Start 2 parallel sleep commands with 0.5 second delay each - const promises = [ - $`sleep 0.5`, - $`sleep 0.5` - ]; - - const results = await Promise.all(promises); - const endTime = Date.now(); - const duration = endTime - startTime; - - // Both commands should complete successfully - expect(results[0].code).toBe(0); - expect(results[1].code).toBe(0); - - // Total duration should be closer to 0.5s (parallel) than 1.0s (sequential) - // Allow some tolerance for system overhead - expect(duration).toBeLessThan(800); // Should be much less than 800ms if truly parallel - expect(duration).toBeGreaterThan(400); // Should be at least 400ms since sleep is 0.5s - }); +// A sleep long enough that process startup noise cannot hide it, short enough +// to keep the suite fast. +const SLEEP_SECONDS = 0.5; +const SLEEP_MS = SLEEP_SECONDS * 1000; - test('should execute 3 sleep commands in parallel', async () => { - const startTime = Date.now(); - - // Start 3 parallel sleep commands with 0.3 second delay each - const promises = [ - $`sleep 0.3`, - $`sleep 0.3`, - $`sleep 0.3` - ]; - - const results = await Promise.all(promises); - const endTime = Date.now(); - const duration = endTime - startTime; - - // All commands should complete successfully - expect(results[0].code).toBe(0); - expect(results[1].code).toBe(0); - expect(results[2].code).toBe(0); - - // Total duration should be closer to 0.3s (parallel) than 0.9s (sequential) - // Allow some tolerance for system overhead - expect(duration).toBeLessThan(600); // Should be much less than 600ms if truly parallel - expect(duration).toBeGreaterThan(250); // Should be at least 250ms since sleep is 0.3s - }); +// Timers are allowed to fire slightly early (timer resolution, rounding in the +// sleep implementation), so the per-command lower bound gets a small slack. +const TIMER_SLACK_MS = 50; + +// The wall clock of a parallel run is compared against the sequential total. +// 75% leaves room for startup overhead while still failing loudly if the +// commands were serialized. +const SEQUENTIAL_FRACTION = 0.75; - test('should execute mixed duration sleep commands in parallel', async () => { - const startTime = Date.now(); - - // Start 3 parallel sleep commands with different durations - const promises = [ - $`sleep 0.2`, - $`sleep 0.4`, - $`sleep 0.3` - ]; - - const results = await Promise.all(promises); - const endTime = Date.now(); - const duration = endTime - startTime; - - // All commands should complete successfully - expect(results[0].code).toBe(0); - expect(results[1].code).toBe(0); - expect(results[2].code).toBe(0); - - // Total duration should be determined by the longest sleep (0.4s), not the sum (0.9s) - // Allow some tolerance for system overhead - expect(duration).toBeLessThan(650); // Should be much less than 650ms if truly parallel - expect(duration).toBeGreaterThan(350); // Should be at least 350ms since longest sleep is 0.4s +/** + * Start a command and record when it started and finished. + * @param {object} command Thenable command returned by `$` + * @returns {Promise<{result: object, startedAt: number, finishedAt: number}>} + */ +function timed(command) { + const startedAt = Date.now(); + return Promise.resolve(command).then((result) => ({ + result, + startedAt, + finishedAt: Date.now(), + })); +} + +/** + * Assert that every pair of runs was in flight at the same moment. + * @param {Array<{startedAt: number, finishedAt: number}>} runs Completed runs + */ +function expectOverlappingExecution(runs) { + for (let i = 0; i < runs.length; i++) { + for (let j = i + 1; j < runs.length; j++) { + // Half-open windows overlap when each one starts before the other ends. + expect(runs[i].startedAt).toBeLessThan(runs[j].finishedAt); + expect(runs[j].startedAt).toBeLessThan(runs[i].finishedAt); + } + } +} + +/** + * Assert that a run slept at least as long as it was asked to. + * @param {{startedAt: number, finishedAt: number}} run Completed run + * @param {number} expectedMs Requested sleep in milliseconds + */ +function expectSlept(run, expectedMs) { + expect(run.finishedAt - run.startedAt).toBeGreaterThanOrEqual( + expectedMs - TIMER_SLACK_MS + ); +} + +describe('parallel sleep commands', () => { + test('runs 2 sleeping commands at the same time', async () => { + const startedAt = Date.now(); + const runs = await Promise.all([ + timed($`sleep ${SLEEP_SECONDS}`), + timed($`sleep ${SLEEP_SECONDS}`), + ]); + const elapsed = Date.now() - startedAt; + + for (const run of runs) { + expect(run.result.code).toBe(0); + expectSlept(run, SLEEP_MS); + } + expectOverlappingExecution(runs); + expect(elapsed).toBeLessThan(2 * SLEEP_MS * SEQUENTIAL_FRACTION); }); - test('should handle parallel sleep commands with output verification', async () => { - const startTime = Date.now(); - - // Start 2 parallel sleep commands that also produce output - const promises = [ - $`sh -c 'echo "command1 start"; sleep 0.2; echo "command1 end"'`, - $`sh -c 'echo "command2 start"; sleep 0.3; echo "command2 end"'` - ]; - - const results = await Promise.all(promises); - const endTime = Date.now(); - const duration = endTime - startTime; - - // Both commands should complete successfully - expect(results[0].code).toBe(0); - expect(results[1].code).toBe(0); - - // Verify output content - expect(results[0].stdout.trim()).toContain('command1 start'); - expect(results[0].stdout.trim()).toContain('command1 end'); - expect(results[1].stdout.trim()).toContain('command2 start'); - expect(results[1].stdout.trim()).toContain('command2 end'); - - // Duration should be closer to max(0.2s, 0.3s) = 0.3s, not sum = 0.5s - expect(duration).toBeLessThan(550); // Should be much less than 550ms if truly parallel - expect(duration).toBeGreaterThan(250); // Should be at least 250ms since longest sleep is 0.3s + test('runs 3 sleeping commands at the same time', async () => { + const startedAt = Date.now(); + const runs = await Promise.all([ + timed($`sleep ${SLEEP_SECONDS}`), + timed($`sleep ${SLEEP_SECONDS}`), + timed($`sleep ${SLEEP_SECONDS}`), + ]); + const elapsed = Date.now() - startedAt; + + for (const run of runs) { + expect(run.result.code).toBe(0); + expectSlept(run, SLEEP_MS); + } + expectOverlappingExecution(runs); + expect(elapsed).toBeLessThan(3 * SLEEP_MS * SEQUENTIAL_FRACTION); }); - test('should maintain individual command execution context in parallel', async () => { - // Test that each parallel command maintains its own execution environment - const commands = [ - $`sh -c 'VAR="value1"; sleep 0.1; echo "Command 1: $VAR"'`, - $`sh -c 'VAR="value2"; sleep 0.1; echo "Command 2: $VAR"'`, - $`sh -c 'VAR="value3"; sleep 0.1; echo "Command 3: $VAR"'` - ]; - - const results = await Promise.all(commands); - - // All commands should succeed - results.forEach(result => { - expect(result.code).toBe(0); + test('finishes mixed durations in the time of the longest one', async () => { + const durations = [0.2, 0.5, 0.3]; + const startedAt = Date.now(); + const runs = await Promise.all( + durations.map((seconds) => timed($`sleep ${seconds}`)) + ); + const elapsed = Date.now() - startedAt; + + runs.forEach((run, index) => { + expect(run.result.code).toBe(0); + expectSlept(run, durations[index] * 1000); }); - - // Each should have its own variable value - expect(results[0].stdout.trim()).toBe('Command 1: value1'); - expect(results[1].stdout.trim()).toBe('Command 2: value2'); - expect(results[2].stdout.trim()).toBe('Command 3: value3'); + expectOverlappingExecution(runs); + + const sequentialMs = durations.reduce((total, s) => total + s, 0) * 1000; + expect(elapsed).toBeLessThan(sequentialMs * SEQUENTIAL_FRACTION); }); -}); \ No newline at end of file + + test.skipIf(isWindows)( + 'runs real sleep processes in parallel when virtual commands are off', + async () => { + // Without the built-in sleep the commands become real child processes, + // so this covers process spawning rather than the virtual command path. + // test-helper.mjs re-enables virtual commands after the test. + disableVirtualCommands(); + + const startedAt = Date.now(); + const runs = await Promise.all([ + timed($`sleep ${SLEEP_SECONDS}`), + timed($`sleep ${SLEEP_SECONDS}`), + timed($`sleep ${SLEEP_SECONDS}`), + ]); + const elapsed = Date.now() - startedAt; + + for (const run of runs) { + expect(run.result.code).toBe(0); + expectSlept(run, SLEEP_MS); + } + expectOverlappingExecution(runs); + expect(elapsed).toBeLessThan(3 * SLEEP_MS * SEQUENTIAL_FRACTION); + } + ); + + test.skipIf(isWindows)( + 'keeps the output of commands that sleep between writes', + async () => { + const startedAt = Date.now(); + const runs = await Promise.all( + [1, 2, 3].map((id) => + timed( + $`sh -c ${`echo "start ${id}"; sleep ${SLEEP_SECONDS}; echo "end ${id}"`}` + ) + ) + ); + const elapsed = Date.now() - startedAt; + + runs.forEach((run, index) => { + const id = index + 1; + expect(run.result.code).toBe(0); + expect(run.result.stdout.trim().split('\n')).toEqual([ + `start ${id}`, + `end ${id}`, + ]); + expectSlept(run, SLEEP_MS); + }); + expectOverlappingExecution(runs); + expect(elapsed).toBeLessThan(3 * SLEEP_MS * SEQUENTIAL_FRACTION); + } + ); + + test.skipIf(isWindows)( + 'gives each parallel command its own environment', + async () => { + const runs = await Promise.all( + [1, 2, 3].map((id) => + timed( + $`sh -c ${`VALUE="value ${id}"; sleep ${SLEEP_SECONDS}; echo "$VALUE"`}` + ) + ) + ); + + runs.forEach((run, index) => { + expect(run.result.code).toBe(0); + expect(run.result.stdout.trim()).toBe(`value ${index + 1}`); + }); + expectOverlappingExecution(runs); + } + ); +}); diff --git a/rust/changelog.d/20260916_091500_parallel_sleep_tests.md b/rust/changelog.d/20260916_091500_parallel_sleep_tests.md new file mode 100644 index 00000000..877dd836 --- /dev/null +++ b/rust/changelog.d/20260916_091500_parallel_sleep_tests.md @@ -0,0 +1,11 @@ +--- +bump: patch +--- + +### Added + +- Tests covering parallel execution of sleeping commands (issue #22). Two and + three commands started together — through the built-in `sleep`, through real + `/bin/sleep` processes, and through `sh -c` scripts that sleep between writes + — must all finish, keep their own output and environment, and overlap in time + instead of running one after another. diff --git a/rust/tests/parallel_sleep.rs b/rust/tests/parallel_sleep.rs new file mode 100644 index 00000000..a37fd1bc --- /dev/null +++ b/rust/tests/parallel_sleep.rs @@ -0,0 +1,213 @@ +//! Parallel execution of commands that sleep (issue #22). +//! +//! Timing alone is a weak signal on a loaded CI machine, so each test asserts +//! three independent things: +//! 1. every command completed successfully (and produced its output), +//! 2. the execution windows of the commands overlap, which is what "parallel" +//! actually means, +//! 3. the wall clock stayed well below the sequential total. + +use command_stream::{run, CommandResult}; +use std::time::{Duration, Instant}; + +/// A sleep long enough that process startup noise cannot hide it, short enough +/// to keep the suite fast. +const SLEEP: Duration = Duration::from_millis(500); + +/// Timers are allowed to fire slightly early (timer resolution, rounding in the +/// sleep implementation), so the per-command lower bound gets a small slack. +const TIMER_SLACK: Duration = Duration::from_millis(50); + +/// The wall clock of a parallel run is compared against the sequential total. +/// 75% leaves room for startup overhead while still failing loudly if the +/// commands were serialized. +const SEQUENTIAL_FRACTION: f64 = 0.75; + +/// One completed command plus the window it occupied. +struct TimedRun { + result: CommandResult, + started: Instant, + finished: Instant, +} + +/// Run a command, recording when it started and when it finished. +async fn timed(command: String) -> TimedRun { + let started = Instant::now(); + let result = run(command).await.expect("command failed to run"); + + TimedRun { + result, + started, + finished: Instant::now(), + } +} + +/// Format a sleep duration the way `sleep(1)` expects it. +fn seconds(duration: Duration) -> String { + format!("{:.3}", duration.as_secs_f64()) +} + +/// Assert that every pair of runs was in flight at the same moment. +fn assert_overlapping(runs: &[&TimedRun]) { + for (first_index, first) in runs.iter().enumerate() { + for (offset, second) in runs[first_index + 1..].iter().enumerate() { + // Half-open windows overlap when each one starts before the other + // ends. + assert!( + first.started < second.finished && second.started < first.finished, + "commands {} and {} did not overlap", + first_index, + first_index + 1 + offset + ); + } + } +} + +/// Assert that a run slept at least as long as it was asked to. +fn assert_slept(run: &TimedRun, expected: Duration) { + let elapsed = run.finished - run.started; + assert!( + elapsed + TIMER_SLACK >= expected, + "command finished after {:?}, expected at least {:?}", + elapsed, + expected + ); +} + +/// Assert that the whole batch finished well below the sequential total. +fn assert_faster_than_sequential(elapsed: Duration, sequential: Duration) { + let budget = sequential.mul_f64(SEQUENTIAL_FRACTION); + assert!( + elapsed < budget, + "batch took {:?}, which is not below the {:?} parallel budget (sequential total {:?})", + elapsed, + budget, + sequential + ); +} + +#[tokio::test] +async fn two_sleeping_commands_run_at_the_same_time() { + let batch_started = Instant::now(); + let (first, second) = tokio::join!( + timed(format!("sleep {}", seconds(SLEEP))), + timed(format!("sleep {}", seconds(SLEEP))), + ); + let elapsed = batch_started.elapsed(); + + for run in [&first, &second] { + assert!(run.result.is_success()); + assert_slept(run, SLEEP); + } + assert_overlapping(&[&first, &second]); + assert_faster_than_sequential(elapsed, 2 * SLEEP); +} + +#[tokio::test] +async fn three_sleeping_commands_run_at_the_same_time() { + let batch_started = Instant::now(); + let (first, second, third) = tokio::join!( + timed(format!("sleep {}", seconds(SLEEP))), + timed(format!("sleep {}", seconds(SLEEP))), + timed(format!("sleep {}", seconds(SLEEP))), + ); + let elapsed = batch_started.elapsed(); + + for run in [&first, &second, &third] { + assert!(run.result.is_success()); + assert_slept(run, SLEEP); + } + assert_overlapping(&[&first, &second, &third]); + assert_faster_than_sequential(elapsed, 3 * SLEEP); +} + +#[tokio::test] +async fn mixed_durations_finish_in_the_time_of_the_longest_one() { + let durations = [ + Duration::from_millis(200), + Duration::from_millis(500), + Duration::from_millis(300), + ]; + + let batch_started = Instant::now(); + let (first, second, third) = tokio::join!( + timed(format!("sleep {}", seconds(durations[0]))), + timed(format!("sleep {}", seconds(durations[1]))), + timed(format!("sleep {}", seconds(durations[2]))), + ); + let elapsed = batch_started.elapsed(); + + for (run, duration) in [&first, &second, &third].iter().zip(durations) { + assert!(run.result.is_success()); + assert_slept(run, duration); + } + assert_overlapping(&[&first, &second, &third]); + assert_faster_than_sequential(elapsed, durations.iter().sum()); +} + +/// Absolute path so the built-in `sleep` is bypassed and real child processes +/// are spawned instead, covering the process path rather than the virtual one. +#[cfg(unix)] +#[tokio::test] +async fn real_sleep_processes_run_in_parallel() { + let batch_started = Instant::now(); + let (first, second, third) = tokio::join!( + timed(format!("/bin/sleep {}", seconds(SLEEP))), + timed(format!("/bin/sleep {}", seconds(SLEEP))), + timed(format!("/bin/sleep {}", seconds(SLEEP))), + ); + let elapsed = batch_started.elapsed(); + + for run in [&first, &second, &third] { + assert!(run.result.is_success()); + assert_slept(run, SLEEP); + } + assert_overlapping(&[&first, &second, &third]); + assert_faster_than_sequential(elapsed, 3 * SLEEP); +} + +#[cfg(unix)] +#[tokio::test] +async fn output_of_commands_that_sleep_between_writes_is_kept() { + let script = |id: u32| { + format!( + "sh -c 'echo \"start {id}\"; sleep {}; echo \"end {id}\"'", + seconds(SLEEP) + ) + }; + + let batch_started = Instant::now(); + let (first, second, third) = tokio::join!(timed(script(1)), timed(script(2)), timed(script(3))); + let elapsed = batch_started.elapsed(); + + for (index, run) in [&first, &second, &third].iter().enumerate() { + let id = index + 1; + assert!(run.result.is_success()); + assert_eq!( + run.result.stdout.trim().lines().collect::>(), + vec![format!("start {id}"), format!("end {id}")] + ); + assert_slept(run, SLEEP); + } + assert_overlapping(&[&first, &second, &third]); + assert_faster_than_sequential(elapsed, 3 * SLEEP); +} + +#[cfg(unix)] +#[tokio::test] +async fn each_parallel_command_keeps_its_own_environment() { + let script = |id: u32| { + format!( + "sh -c 'VALUE=\"value {id}\"; sleep {}; echo \"$VALUE\"'", + seconds(SLEEP) + ) + }; + + let (first, second, third) = tokio::join!(timed(script(1)), timed(script(2)), timed(script(3))); + + for (index, run) in [&first, &second, &third].iter().enumerate() { + assert!(run.result.is_success()); + assert_eq!(run.result.stdout.trim(), format!("value {}", index + 1)); + } + assert_overlapping(&[&first, &second, &third]); +}