feat(run): add --parallel flag to run tests across CPU cores - #17
Open
vladdoster wants to merge 39 commits into
Open
feat(run): add --parallel flag to run tests across CPU cores#17vladdoster wants to merge 39 commits into
vladdoster wants to merge 39 commits into
Conversation
Add a -p/--parallel option (and 'parallel: true' config key) to 'zunit run' that distributes work across CPU cores: - multiple test files run concurrently, one worker per file - a single test file is split into evenly sized contiguous groups, one worker per core Workers record result events and the parent replays them through the existing event handlers in serial order, so output, numbering, TAP, text/HTML reports, fail-fast behaviour and exit codes are identical to a serial run. Crashed workers are reported as errors and fail the run. Also fix two latent issues surfaced by worker isolation: - testdir in _zunit_run_testfile was expanded from the caller's variable of the same name; compute it after testfile is assigned - extract the verbose-output echo into _zunit_verbose_output so it can be recorded as an ordered event
Correctness: - wait only for tracked worker PIDs, so background processes left by a bootstrap script no longer hang the run - make --fail-fast stop remaining parallel work via an abort sentinel checked by the spawn loop and by workers between tests - fail the run when file-level errors were reported (e.g. an unparseable @setup), in serial mode as well - detect truncated or empty worker state files with an end-of-file marker and report them as crashed workers - report setup/teardown parse errors once instead of once per core in single-file mode - count a crashed worker as an errored test so TAP numbering, the plan line and the summary stay consistent - quote recorded event fields so payloads containing the 0x1f separator byte survive the replay round-trip Robustness and efficiency: - abort with a clear error when mktemp fails instead of emitting a bogus crash report per core - exit through the EXIT trap on INT/TERM/HUP so the temporary directory is not leaked when the run is interrupted - track worker liveness via the shell's job table instead of kill -0, avoiding PID-reuse races, and poll with the zselect builtin instead of forking sleep - bound single-file fan-out by the number of tests in the file - detect CPU cores by running the commands directly: the first access to zsh's $commands hash scans every $PATH directory, which costs hundreds of milliseconds on hosts with slow filesystem mounts Tests: - assert known content markers before the checksum parity comparison so the tests cannot pass vacuously - prove config-enabled parallel mode actually spawns workers using the crash fixture - cover fail-fast side-effect prevention with a marker-file fixture - deduplicate the file header into _zunit_testfile_header
…underscore-prefixed tests
Parallel runs buffer each worker's output and replay it once every worker has finished, so a long run gave no feedback at all until the very end. Workers now append a single character per completed test to a shared file, and the parent redraws a bar from those ticks while it waits. Single byte appends are atomic, so the workers need no locking between them. The bar is a terminal affordance only: it is skipped when stderr is not a terminal, so piped output and report files stay byte for byte identical to a serial run, and it is skipped under --tap, which is a machine readable format. It can also be turned off explicitly with --no-progress, or with 'progress: false' in .zunit.yml. Signed-off-by: Vladislav Doster <mvdoster@gmail.com>
The bar is only drawn when stderr is a terminal, so these cases cannot be exercised by capturing output the usual way. A zpty helper runs the binary on a pseudo terminal instead; zpty is used rather than script(1), whose options differ between platforms. Covers the bar being drawn with the expected test count, --no-progress suppressing it, and piped output staying free of it. Signed-off-by: Vladislav Doster <mvdoster@gmail.com>
Signed-off-by: Vladislav Doster <mvdoster@gmail.com>
./configure writes Makefile from Makefile.in with the local prefix baked in, and 'make clean' deletes it again, so it is a build artifact rather than something to track. Makefile.in stays checked in. Signed-off-by: Vladislav Doster <mvdoster@gmail.com>
The bar sized itself from $COLUMNS, which a non interactive shell never refreshes during a run, so a window resized mid run left it drawing a line wider than the terminal. Such a line wraps, a wrapped line cannot be erased with a single escape sequence, and every redraw after it stranded another copy on the screen. The width is now measured from the terminal itself, before anything is put on it, and the line is never allowed to reach the final column. Terminal echo is turned off while the bar is up, so that a newline typed during a run no longer scrolls the bar away and leaves a stale copy behind, and the bar is only redrawn when it has something new to say. The terminal mode and the cursor are restored on every exit path, including an interrupted run. Signed-off-by: Vladislav Doster <mvdoster@gmail.com>
The fixture ended in `assert 1 equals 1`, which could never fail and never reported anything either: the test body runs in a fork of the worker, so after it SIGKILLs its parent the orphaned process writes its verdict into a dead pipe, and the worker never gets to record it. Assert the two preconditions before the kill instead, while the worker is still alive to report a failure - the fixture is only meaningful in a parallel worker with no time limit, since a serial run would kill the runner itself and a time limit puts the async wrapper in between. The trailing `fail` is only reachable when the kill missed. Signed-off-by: Vladislav Doster <mvdoster@gmail.com>
Verbose output was printed raw and flush left, and before the result line it belonged to. A test which runs zunit itself therefore emitted a whole results table of its own, indistinguishable from the outer run's, sitting above the name of the test which produced it. The result is now printed first, and each line of captured output is indented by two spaces, matching the indent already used for error and failure detail. Under --tap the lines are marked as diagnostics instead, so a verbose run still produces a parseable stream. print -r replaces echo, which interpreted backslash escapes in the captured output. Signed-off-by: Vladislav Doster <mvdoster@gmail.com>
The four templates were zsh string literals whose continuation lines carried the indentation of the source file, and that indentation was written into the generated files verbatim. For .zunit.yml this was fatal: _zunit_parse_yaml derives the nesting of a key from its indent, so every key landed two levels below the first one, yielding names such as zunit_config_tap__tests where the runner reads zunit_config_directories_tests. A generated config set none of the variables it was meant to. Dedent all four templates, and add the keys which have been added to the runner since the config template was last touched - parallel, progress, verbose and revolver. Signed-off-by: Vladislav Doster <mvdoster@gmail.com>
The completion had drifted from the runner. --revolver was missing entirely; --time-limit was declared as a plain flag, so no argument was offered after it; and -v/--version was absent from both subcommands although the top level zparseopts strips it before dispatch. Three calls also passed -A without the pattern it takes, which quietly consumed the following -h/--help spec as that pattern. -A is wrong here in any case, since options may be given either side of the test paths, so it has been dropped rather than corrected. The specs now live in two arrays shared by the top level and per command calls, -C is passed so that curcontext is honoured, -s allows single letter options to be stacked, and test paths complete *.zunit rather than every file. The README documented no CLI surface at all, deferring to zunit.xyz, which predates this fork and covers neither --parallel nor --no-progress. It now lists both commands, every option, the three forms a test argument can take, the .zunit.yml keys and the exit codes. tests/completion.zunit derives the option set from the usage text and compares it against the completion in both directions, so the two cannot drift apart again unnoticed. Signed-off-by: Vladislav Doster <mvdoster@gmail.com>
Signed-off-by: Vladislav Doster <mvdoster@gmail.com>
The bootstrap script used to be sourced once, into the parent shell, before the workers forked. Every worker therefore inherited one shared copy of whatever the script built - for a bootstrap which prepares an on-disk environment, all workers raced each other inside the same directories, and test files which pass in isolation failed under --parallel. Each worker now sources the script itself, after its recorders are installed and before it runs its share of the tests, so every worker builds its own environment, exactly as a standalone run of its test file would. The script is sourced at the subshell's top level rather than inside a helper function, because source runs a script in the enclosing function scope - a bare typeset in the script would become a local of the helper and be gone before the tests run. A bootstrap which returns non-zero is recorded as an error, with the worker's log attached, and the worker runs no tests; one which exits outright is reported by the existing crashed-worker path. Serial runs still source the script once, into the runner, as before. The zunit_config_parallel fallback moves above the bootstrap handling because the parent-or-worker decision needs the resolved flag. Signed-off-by: Vladislav Doster <mvdoster@gmail.com>
The fixture bootstrap appends its shell's PID to a log file on every source - $sysparams[pid], because zsh keeps the parent's PID in $$ inside subshells - so counting distinct PIDs distinguishes one parent source from one source per worker. It also declares a variable with a bare typeset, which only survives into the tests when the script is sourced at the worker's top level rather than inside a helper. Covered: one source per worker across files, one source per slice of a single file, the unchanged single parent source in serial mode, and a failing bootstrap surfacing as an error which fails the run. Signed-off-by: Vladislav Doster <mvdoster@gmail.com>
Signed-off-by: Vladislav Doster <mvdoster@gmail.com>
Signed-off-by: Vladislav Doster <mvdoster@gmail.com>
A parallel run of a single test file split that file's tests into one contiguous slice per core. The tests in a file run in the order they are declared, and share whatever state they leave on disk, so pulling them apart changes what the file means - and since each worker sources the bootstrap script itself, every slice also builds its own copy of whatever environment that script prepares. A file whose later tests read what its earlier ones set up therefore failed under --parallel while passing in serial. A test file is now a single unit of work. Slicing is still available, behind --slice and the parallel_slice config key, for a file whose tests are genuinely independent of each other and of their order. It implies --parallel, having nothing to split a file across otherwise. Two existing pieces move with the behaviour rather than after it. The completion gains the flag alongside the usage text, because the suite asserts that every option the CLI accepts is offered by the completion. The test which covers slicing asks for it explicitly, so that this commit leaves the suite green on its own. Signed-off-by: Vladislav Doster <mvdoster@gmail.com>
The bootstrap fixture logs the PID of every shell it is sourced into, which makes the number of workers a run used directly observable: one for a single file by default, one per slice under --slice. The ordering fixture is the regression itself, in miniature. Its second test reads a marker the first writes, so it passes serially and under --parallel, and fails when --slice splits the two apart. The first test sleeps before writing, so the sliced run fails on the ordering rather than on a race the scheduler might win. Signed-off-by: Vladislav Doster <mvdoster@gmail.com>
Say what the unit of work in a parallel run is, since that is what decides whether a test file which shares state between its tests is safe to run with -p, and record --slice and parallel_slice in the options and config tables. Signed-off-by: Vladislav Doster <mvdoster@gmail.com>
Signed-off-by: Vladislav Doster <mvdoster@gmail.com>
A rest argument declared as '*::arg:->args' hands the test paths to a second _arguments call, and that call is never reached once an option follows the command. `zunit run --verbose <TAB>` offered nothing at all, and neither did any other line with a flag after the command. The file parsed cleanly throughout, so nothing caught it. List the test arguments in the single _arguments call instead. `init` is dispatched ahead of it, since its options are the only ones that differ and it takes no arguments of its own. Three smaller places where the completion did not match the runner: a path whose name begins with an underscore is skipped when the tests are collected, so the output and support directories are no longer offered; the name in 'file.zunit@test name' now completes from the @test lines the file declares; and --time-limit guards its value, which is a count of seconds. Signed-off-by: Vladislav Doster <mvdoster@gmail.com>
The existing tests read the flag names out of the completion file and compare them against the usage text, which says nothing about what a tab actually offers. The completion offered nothing after a run option for as long as those tests were green. Type a line into a zsh running under a pseudo terminal, press tab, and read back what the completion system draws. That covers the regression, the paths the runner skips, and the test names a file declares. Test bodies run with ERR_EXIT set. A wait that times out and a read that finds nothing both report failure and neither is an error, so the helper turns the option off the way evl does. Signed-off-by: Vladislav Doster <mvdoster@gmail.com>
Print the group count and the worker cap once, before the first worker is forked. Neither number is knowable from the command line: the group count depends on what was queued and whether slicing applied, and the worker count comes from the machine. Left out of TAP output, which is machine readable. Signed-off-by: Vladislav Doster <mvdoster@gmail.com>
The four serial-versus-parallel parity checks compare output byte for byte, so they filter the banner out alongside the timing line - it is the one line a parallel run prints which a serial run has no equivalent for. The count itself is asserted directly instead: a literal count for file level groups, the shape only for slices, since that number belongs to the machine. Signed-off-by: Vladislav Doster <mvdoster@gmail.com>
Piped output is no longer byte for byte identical to a serial run, so the parity claim is narrowed to report files rather than left standing as written. Signed-off-by: Vladislav Doster <mvdoster@gmail.com>
Signed-off-by: Vladislav Doster <mvdoster@gmail.com>
The command was read from the first word of the line, before a single
option had been parsed, so `zunit --verbose run tests` left the word
`run` behind to be parsed as a test path and died with
Test file or directory 'run' could not be found
Every option the runner accepts was affected, and `--help` printed the
general usage rather than the usage of the command it was given to.
The command is now the first word which is neither an option nor the
value of one, and it is removed by position, so an option written
before it is still passed along with the rest of the line.
Signed-off-by: Vladislav Doster <mvdoster@gmail.com>
zparseopts stops at the first word it does not recognise unless -E is given, so now that an option may be written before the command, `zunit --verbose init --travis` bootstrapped a project without a .travis.yml and reported success. Signed-off-by: Vladislav Doster <mvdoster@gmail.com>
zparseopts leaves the '=' attached to the value in the `--time-limit=5` form and not in the `--time-limit 5` form, so the arithmetic which works out the kill time read '=5' as no number of seconds at all and the limit was never enforced. Signed-off-by: Vladislav Doster <mvdoster@gmail.com>
The command was read from the second word, so `zunit --verbose init` completed as a run and offered options `init` has no use for. The command word is now looked for the same way the runner looks for it, and it takes the place of the `zunit` word so that _arguments reads everything written before it as an option given to it. Signed-off-by: Vladislav Doster <mvdoster@gmail.com>
The build target writes bin/zunit with a plain redirect, which leaves a newly created file without its execute bit, and the tests target runs that file directly. `make build tests` on a clean checkout stopped at `Permission denied`. Signed-off-by: Vladislav Doster <mvdoster@gmail.com>
Every option now reads the same before the command as after it, and -t takes its meaning from the command it is given to rather than from where it was written. Signed-off-by: Vladislav Doster <mvdoster@gmail.com>
Nothing exercised the split between the runner's own options and the command - tests/run.zunit covers the `run` helper, not the subcommand. Every case in tests/cli.zunit fails against the runner as it was, apart from the two which guard behaviour that had to stay put. The completion tests which asserted that a run option was withheld before a command now assert that it is offered, and one more covers a command found behind an option. Signed-off-by: Vladislav Doster <mvdoster@gmail.com>
Signed-off-by: Vladislav Doster <mvdoster@gmail.com>
Signed-off-by: Vladislav Doster <mvdoster@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Parallel test execution — new
src/parallel.zsh.-p/--parallel(orparallel: truein.zunit.yml) spreads test files, or slices of a single file, across worker subshells. Workers record results rather than printing them, and the parent replays them in serial order, so output, TAP and report files stay byte for byte identical to a serial run.--fail-faststops the other workers, and a crashed worker is reported as an error instead of vanishing.Progress bar — drawn during parallel runs when stderr is a terminal and TAP was not requested. Follows a resize, never fills the last column, restores terminal echo and the cursor on exit, and is suppressed by
--no-progressorprogress: false.--verboseoutput framing — captured output was printed raw, flush left, and above the result line it belonged to, so a test which runs zunit emitted a results table indistinguishable from the outer run's. It is now indented beneath its result, and marked as a TAP diagnostic under--tap.zunit init— the generated.zunit.ymlcarried the source file's indentation, so every key parsed one level too deep and none of the names the runner reads were ever set. Templates dedented, and the keys added since they were last touched (parallel,progress,verbose,revolver) included.Completion and docs —
_zunitwas missing--revolver, declared--time-limitas a valueless flag, and passed-Awithout its pattern argument, silently swallowing the following-h/--helpspec. README now documents both commands, every option, the three test-argument forms and the.zunit.ymlkeys.