Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Install linters
run: |
sudo apt-get update
Expand Down Expand Up @@ -46,7 +46,7 @@ jobs:
package: busybox-static
path: /usr/local/bin/ash
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Install ${{ matrix.shell }}
run: |
sudo apt-get update
Expand All @@ -68,15 +68,15 @@ jobs:
matrix:
shell: [/bin/sh, /bin/bash]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- run: make install-dev
- run: make test SHELLSPEC_FLAGS="--shell ${{ matrix.shell }}"

freebsd:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: vmactions/freebsd-vm@v1
with:
usesh: true
Expand Down
3 changes: 3 additions & 0 deletions .shellcheckrc
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@ shell=sh
# Follow `. parallel.sh` into the library when a source= directive says where
# it is, so the example build script is checked as a whole.
external-sources=true

# Resolve `source=` directives relative to the script that carries them.
source-path=SCRIPTDIR
3 changes: 2 additions & 1 deletion examples/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
set -u

_cwd=$(dirname "$0")
# shellcheck source=../parallel.sh
. "$_cwd/../parallel.sh"

chain "composer dependencies" \
Expand All @@ -11,4 +12,4 @@ chain "build assets" \
"npm ci --audit false" \
"npm run build"

run
run
18 changes: 18 additions & 0 deletions parallel.sh
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,28 @@ _finish() {
0) ;;
*) _ec=$_signal ;;
esac
_stop_chains
_cleanup
exit "$_ec"
}

# Take the chains down on the way out. Nothing else does: a chain is its own
# process, so an interrupted build that only cleaned up after itself would
# leave the compiler it started still running, and a script that gave up
# before it reached `run` would leave the lot. Every chain that reported a
# status is finished already, and killing a process that has gone is not an
# error worth reporting, so this cannot fail and cannot abandon the trap
# that called it.
_stop_chains() {
i=1
while [ "$i" -le "$_count" ]; do
if [ ! -f "$_work/$i.code" ] && [ -f "$_work/$i.pid" ]; then
kill "$(cat "$_work/$i.pid")" 2>/dev/null || :
fi
i=$((i + 1))
done
}

trap '_finish $?' EXIT
trap '_signal=130; _finish 130' INT
trap '_signal=143; _finish 143' TERM
Expand Down
29 changes: 27 additions & 2 deletions spec/parallel_spec.sh
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ Describe 'parallel.sh'
#|. "$LIB"
#|chain "slow" \
#| "touch '$WORK/slow-started'" \
#| "until [ -f '$WORK/done' ]; do sleep 1; done" \
#| "until [ -f '$WORK/done' ] || [ ! -d '$WORK' ]; do sleep 1; done" \
#| "touch '$WORK/slow-finished'"
#|chain "quick" \
#| "until [ -f '$WORK/slow-started' ]; do sleep 1; done" \
Expand Down Expand Up @@ -249,13 +249,38 @@ Describe 'parallel.sh'
#|printf '%s\n' "$$" >"$WORK/pid"
#|chain "slow" \
#| "touch '$WORK/started'" \
#| "until [ -f '$WORK/done' ]; do sleep 1; done"
#| "until [ -f '$WORK/done' ] || [ ! -d '$WORK' ]; do sleep 1; done"
#|run
End

When call driver_interrupted
The status should equal 130
The directory "$(reported_workdir)" should not be exist
End

# A signal is the one way out that the cancel path never sees, so
# unless the library stops the chains itself an interrupted build
# leaves whatever it started still running. That is its own bug —
# the point of Ctrl-C is that the compiler stops too — and it is
# also what stalls CI: a macOS runner does not finish a step while
# a process the step started is alive, so a chain nobody killed
# holds the job open until it times out, minutes after the suite
# has passed.
It 'stops the chain it started when it is interrupted'
Data
#|set -eu
#|. "$LIB"
#|printf '%s\n' "$$" >"$WORK/pid"
#|chain "slow" \
#| "touch '$WORK/started'" \
#| "until [ -f '$WORK/done' ] || [ ! -d '$WORK' ]; do sleep 1; done"
#|printf '%s\n' "$!" >"$WORK/chainpid"
#|run
End

When call driver_interrupted
The status should equal 130
The value "$(chain_state)" should equal "stopped"
End
End
End
73 changes: 64 additions & 9 deletions spec/spec_helper.sh
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,18 @@ start_driver() {
"$SHELLSPEC_SHELL" "$WORK/driver.sh" 3>&- 4>&- 5>&- 6>&- 7>&- 8>&- 9>&-
}

# The same driver, but for the background, and `exec` is the whole point of
# the second copy. `start_driver &` forks a subshell that then waits on the
# driver, so $! names the subshell and not the driver: killing it reaps the
# wrapper and leaves the driver running, holding the pipes shellspec reads
# the example's output from. The watchdog below then has nothing to kill,
# and one driver that hangs hangs the whole suite instead of failing its own
# example. Replacing the subshell with the driver keeps the pid the same one
# the watchdog was given.
spawn_driver() {
exec "$SHELLSPEC_SHELL" "$WORK/driver.sh" 3>&- 4>&- 5>&- 6>&- 7>&- 8>&- 9>&-
}

detach() {
"$@" >/dev/null 2>&1 3>&- 4>&- 5>&- 6>&- 7>&- 8>&- 9>&- &
}
Expand All @@ -56,19 +68,25 @@ detach() {
# being tested. $LIB and $WORK are in its environment.
#
# When the driver is finished, $WORK/done appears. A fake command that has to
# stay busy for a while waits on that file, so a stray goes away on its own
# shortly after the example that made it.
# stay busy for a while waits for that file to arrive or for $WORK itself to
# go, so a stray goes away on its own shortly after the example that made it.
# Waiting on the file alone is not enough: teardown takes $WORK away moments
# after the marker lands, and a fake that was mid-nap then waits forever for
# a file that can no longer appear. The macOS runner does not finish a step
# while a process it started is still alive, so one immortal stray is a
# fifteen-minute job timeout there, long after the suite itself has passed.
driver() {
cat >"$WORK/driver.sh"
rm -f "$WORK/done"
start_driver &
rm -f "$WORK/done" "$WORK/timed-out"
spawn_driver &
_child=$!
detach watchdog "$_child"
_watchdog=$!
wait "$_child"
_status=$?
: >"$WORK/done"
stop "$_watchdog"
report_timeout
return "$_status"
}

Expand All @@ -81,16 +99,25 @@ driver() {
# driver publishes its own pid for the helper to aim at.
driver_interrupted() {
cat >"$WORK/driver.sh"
rm -f "$WORK/done"
rm -f "$WORK/done" "$WORK/timed-out"
detach interrupter
_interrupter=$!
start_driver
_status=$?
: >"$WORK/done"
stop "$_interrupter"
report_timeout
return "$_status"
}

# Say so when a driver had to be killed. Without this the example fails on a
# status it never chose, which reads like the library returning the wrong
# code rather than the driver never returning at all.
report_timeout() {
[ -e "$WORK/timed-out" ] || return 0
printf 'driver did not finish within %ss and was killed\n' "$DRIVER_TIMEOUT" >&2
}

# Shut a helper down and reap it. Left running, it would still be a child of
# the shell shellspec waits on at the end of the example, and every example
# would pay the full timeout.
Expand All @@ -100,15 +127,25 @@ stop() {
return 0
}

# The driver here runs in the foreground, so this helper is the only thing
# that can end it: a driver that never reports it started, or that sits
# through the interrupt, has to be killed anyway rather than left to run.
interrupter() {
await_file "$WORK/started" || return 0
kill -INT "$(cat "$WORK/pid")" 2>/dev/null
countdown && kill -9 "$(cat "$WORK/pid" 2>/dev/null)" 2>/dev/null
if await_file "$WORK/started"; then
kill -INT "$(cat "$WORK/pid")" 2>/dev/null
elif [ -e "$WORK/done" ]; then
return 0 # it finished on its own; there is nothing to interrupt
fi
countdown || return 0
: >"$WORK/timed-out"
kill -9 "$(cat "$WORK/pid" 2>/dev/null)" 2>/dev/null
}

# Kill a driver that outstays its welcome.
watchdog() {
countdown && kill -9 "$1" 2>/dev/null
countdown || return 0
: >"$WORK/timed-out"
kill -9 "$1" 2>/dev/null
}

# Sleep out the timeout a second at a time, stopping early once the driver is
Expand Down Expand Up @@ -140,6 +177,24 @@ reported_workdir() {
cat "$WORK/workdir"
}

# Whether the chain a finished driver reported through $WORK/chainpid is
# still running. A process that has just been killed can sit as a zombie
# until the shell that started it goes and init reaps it, so this waits a
# few seconds for an answer rather than believing the first one.
chain_state() {
_pid=$(cat "$WORK/chainpid")
_waited=0
while kill -0 "$_pid" 2>/dev/null; do
if [ "$_waited" -ge 5 ]; then
printf 'running'
return 0
fi
_waited=$((_waited + 1))
sleep 1
done
printf 'stopped'
}

lines_in() {
wc -l <"$1" | tr -d ' '
}
Expand Down