Zsh Mailing List Archive
Messages sorted by: Reverse Date, Date, Thread, Author

PoC: Allow parallel 'make test' (was: Re: PATCHv2: Allow parallel make)



Philippe Altherr wrote on Sat, 19 Sep 2026 15:45 +00:00:
> Unfortunately, as expected, the following stayed the same:
>
> make     check  39.63s user 51.56s system 52% cpu 2:53.97 total
> make -j8 check  40.37s user 52.18s system 51% cpu 2:57.99 total
>
> For me, being able to run tests in parallel would be a big improvement.
> Doing so is probably non-trivial.

It turns out that the ztst files hardcode various path-related assumptions:

The tests all use the same cwd, and ZTST_cleanup removes *.tmp... which
could remove the still-in-use *.tmp files of other *.ztst files being run in
parallel.  (B01cd.ztst documents the use of *.tmp filenames.)

To sidestep this, I've gone ahead and made each ztst file have its own
cwd, but that runs into tests assuming that ../config.modules and
../Src/zsh exist.

I didn't want to make the per-ztst-file workdirs immediate children of
the build tree root, so I went ahead and mocked up these two paths.  See
the second TODO in the attachment.

> Just avoiding garbled output might already be non-trivial.

flock(1) with a well-known filename seems to do the trick.

It does require buffering the data using tempfiles.  (The tempfiles are created
in the build tree.  Given the context, I assume the build tree will be on a ramdisk.)

> However, a version that runs in parallel with
> garbled output but a correct summary would already be a great improvement.

Proof of concept attached.

Known issues:

- No UI for opting in to / out of parallel mode (see the first TODO)

- Using per-ztst-file workdirs *only* in parallel mode looks weird.
  The «if (( MAX_CPUS > 1 ))» in run_one_file() should become «if true», right?

- One print hasn't been guarded by the mutex (see the fourth TODO)

- Occasional failures in V08zpty.ztst.  Not investigated.

- Only tested with out-of-tree builds, not with in-tree builds.

Observations:

- Inflates runtests.zsh from 66 lines to 215 lines.

Timings:

make -C zsh test  63.19s user 14.19s system 55% cpu 2:18.63 total
make -C zsh test  67.71s user 20.41s system 161% cpu 54.494 total

If someone wants to pick this up and finish it, please do.

Cheers,

Daniel


> Usually, when I run all tests, it's just to check whether all tests still
> pass and, if not, find out which ones have problems.
diff --git Test/runtests.zsh Test/runtests.zsh
index ea862d2b3..4a27ded2d 100644
--- Test/runtests.zsh
+++ Test/runtests.zsh
@@ -2,6 +2,82 @@
 
 emulate zsh
 
+
+### Define helpers and constants
+
+# Use a suffix other than ".tmp" in order to prevent premature removal by ztst.zsh:ZTST_cleanup
+ZTST_exe=${ZTST_exe:P}
+export ZTST_root_of_build_tree=${ZTST_exe:h:h}
+export ZTST_root_of_source_tree=${ZTST_srcdir:h}
+readonly RUNTESTS_WORKDIR=${ZTST_root_of_build_tree}/Test/workdir-for-h${HOST}p$$.parent-tmp/
+mkdir -- ${RUNTESTS_WORKDIR} || {
+  print -ru2 - "runtests: error: failed to create unique workdir"
+  exit 1
+}
+
+# How many test files to run in parallel.
+#
+# Must be a positive integer.
+#
+# When >2, flock(1) must be available.
+#
+# TODO: make this configurable (and document)
+integer MAX_CPUS=1
+MAX_CPUS=$(
+  DEFAULT=4
+  if type python3 > /dev/null; then
+    # Return os.process_cpu_count(), falling back to os.cpu_count() and $DEFAULT if os.process_cpu_count() is unavailable or returns None
+    python3 -c "import os; print(  getattr(os, 'process_cpu_count', os.cpu_count)() or $DEFAULT  )"
+  else
+    print $DEFAULT
+  fi
+)
+#MAX_CPUS=1  # quick toggle for testing
+
+if (( MAX_CPUS > 1 )); then
+  # Load zargs
+  () {
+    local fpath
+    fpath+=( ${ZTST_root_of_source_tree}/Functions/Misc/zargs(:h) )
+    autoload -U +X zargs
+  }
+
+  # Make it possible for run_one_file() to cd into per-ztst-file directories,
+  # by arranging for $ZTST_testdir/../foo to exist for those values of foo that
+  # tests currently use.
+  #
+  # TODO: In all ztst files, replace "$ZTST_testdir/.." [literal dot-dot,
+  #       not an ellipsis] with "$ZTST_root_of_build_tree", then remove the
+  #       following block.
+  if true; then
+    mkdir -- "${RUNTESTS_WORKDIR}"/Src
+    ln -s "${ZTST_exe}" "${RUNTESTS_WORKDIR}/Src/zsh"
+    ln -s "${ZTST_root_of_build_tree}/config.modules" "${RUNTESTS_WORKDIR}/config.modules"
+  fi
+fi
+
+# Define with_stdout_mutex() appropriately.
+#
+# with_stdout_mutex(): A precommand that runs its argv under the stdout mutex, if necessary.
+if (( MAX_CPUS > 1 )); then
+  type flock >/dev/null || {
+    print -ru2 - "$0: error: parallel mode requires flock(1)"
+    exit 1
+  }
+  : > ${STDOUT_SEQUENCER_FILE::="${RUNTESTS_WORKDIR}/.stdout-mutex"}
+  with_stdout_mutex() {
+    # TODO: use 'zsystem flock' (and fall back to the MAX_CPUS=1 codepath when 'zsystem flock' is unavailable)
+    flock "${STDOUT_SEQUENCER_FILE}" "$@"
+  }
+else
+  with_stdout_mutex() {
+    "$@"
+  }
+fi
+
+
+### Main logic
+
 # Run all specified tests, keeping count of which succeeded.
 # The reason for this extra layer above the test script is to
 # protect from catastrophic failure of an individual test.
@@ -19,14 +95,66 @@ typeset -a ffiles res
 [[ $TERM == *color* || "$( tput colors 2> /dev/null )" == <8-> ]] &&
 typeset -x ZTST_failcolour=red
 
-for file in "${(f)ZTST_testlist}"; do
-  file=${file#./}
-  $ZTST_exe +Z -f $ZTST_srcdir/ztst.zsh $file
-  retval=$?
+# Set $REPLY to a filename component that ---
+#
+# * depends on the basename of $1;
+# * can be appended to ${RUNTESTS_WORKDIR} without ending up in another directory
+#   (i.e., does not have slashes or dot-dot components).
+make_unique_suffix_for_file() {
+  REPLY=for-${1:t}.unique
+}
+
+# Run the ztst file $1.  When done, save its exit status in a file, and emit
+# its stdout and stderr with synchronization.
+run_one_file() {
+  local file=${1#./}
+
+  make_unique_suffix_for_file "${file}"
+  local unique_suffix=$REPLY
+
+  if (( MAX_CPUS > 1 )); then
+    local workdir_for_this_ztst=${RUNTESTS_WORKDIR}/workdir-${unique_suffix}
+    mkdir -- "${workdir_for_this_ztst}"
+  fi
+
+  (
+    if (( $+workdir_for_this_ztst )); then
+      cd -- "${workdir_for_this_ztst}";
+    fi || {
+      print -ru2 - "$0: failed cd to ${(qqqq)workdir_for_this_ztst}"
+      exit 1
+    }
+    $ZTST_exe +Z -f $ZTST_srcdir/ztst.zsh $file
+    print -r - > ${RUNTESTS_WORKDIR}/ztst-status-${unique_suffix} "$?"
+  ) \
+    1>${RUNTESTS_WORKDIR}/ztst-output-${unique_suffix} \
+    2>${RUNTESTS_WORKDIR}/ztst-errput-${unique_suffix}
+
+  # Print immediately, otherwise 'make test' would have no stdout at all until everything finished.
+  #
+  # Note: Can't add "--" before "cat", as on Linux at least that would interpret "--" as a command name to execvp().
+  with_stdout_mutex \
+    cat -- \
+      ${RUNTESTS_WORKDIR}/ztst-output-${unique_suffix} \
+      ${RUNTESTS_WORKDIR}/ztst-errput-${unique_suffix} \
+      =(print)
+}
+
+# Update counters for the ztst file $1.
+#
+# Precondition: The exit status file.
+tally_one_file() {
+  local file=${1#./}
+
+  make_unique_suffix_for_file "${file}"
+  local unique_suffix=$REPLY
+
+  local retval=$(< ${RUNTESTS_WORKDIR}/ztst-status-${unique_suffix})
   if (( $retval == 2 )); then
     (( skipped++ ))
   elif (( $retval )); then
     (( failure++ ))
+    # TODO: Move to run_one_file() and use with_stdout_mutex()
     (( $retval > 128 )) && {
       [[ -n $ZTST_failcolour ]] && print -rnP "%F{$ZTST_failcolour}"
       print -r - "$file: test failed: SIG$signals[$retval - 127]."
@@ -36,8 +164,28 @@ for file in "${(f)ZTST_testlist}"; do
   else
     (( success++ ))
   fi
-  print
-done
+}
+
+if (( MAX_CPUS > 1 )); then
+  print "${0:t}: Running tests (via zargs -P${MAX_CPUS})..."
+  zargs \
+    --no-run-if-empty \
+    -n1 \
+    --max-procs "${MAX_CPUS}" \
+    "${(f)ZTST_testlist}" \
+    -- run_one_file
+
+  for file in "${(f)ZTST_testlist}"; do
+    # This updates various counters, so it can't run in zargs (which uses subshells).
+    tally_one_file "$file"
+  done
+else
+  print "${0:t}: Running tests (sequentially)..."
+  for file in "${(f)ZTST_testlist}"; do
+    run_one_file "$file"
+    tally_one_file "$file"
+  done
+fi
 
 res=(
   "$success successful test script${${success:#1}:+s}"
@@ -63,4 +211,5 @@ print -rl - '' "failing test script${${#ffiles:#1}:+s}:" ${(@)ffiles/#/  }
 print -rl - $rule
 (( $#col )) && print -rnP - %f
 
+rm -rf -- ${RUNTESTS_WORKDIR}
 return $(( failure ? 1 : 0 ))


Messages sorted by: Reverse Date, Date, Thread, Author