You said:
for i in {1..100}; do echo $i; sleep $i &; wait-until-jobs-below 3; done
The function:
# Wait until the number of shell jobs drops below a limit.
# This can be used as an alternative to parallel using
# for i in ...; do something $i &; wait-until-jobs-below 8; done
wait-until-jobs-below() {
while test $(jobs | wc -l) -ge "$1"; do
sleep 0.1
done
}ChatGPT said:
I’ll verify the zsh/NixOS regression angle against current shell behavior, especially how jobs behaves inside pipelines or command substitutions, then separate the root cause from a safer replacement.
The specific failure point is jobs running outside the top-level interactive shell, where zsh sees only a snapshot of job state rather than refreshing the parent’s table.
The upgrade-specific failure is very likely a zsh build regression in affected NixOS 26.05-pre nixpkgs revisions, triggered by a fragile job-counting idiom.
In the affected zsh 5.9 builds, an Autoconf 2.73 / C23 interaction made zsh’s configure probe think POSIX sigsuspend() was broken. That caused zsh to compile a pause() fallback with a SIGCHLD race: if the child exits before zsh actually enters pause(), zsh can sleep forever waiting for a signal that already happened. Nixpkgs PR #513971 describes this exact failure mode for $() hangs, and was merged on April 27, 2026. GitHub Nixpkgs now carries a fix-sigsuspend-probe-c23.patch for zsh, specifically noting that the bad probe could cause $(...) hangs. GitHub+1
Your function hits that path constantly:
Zsh$(jobs | wc -l)
That creates a command substitution and a pipeline every 0.1 seconds, so it repeatedly asks zsh to wait for short-lived children. On a bad build, one missed SIGCHLD is enough to make the shell appear to sleep forever.
There is also a zsh-specific logic problem: jobs | wc -l is not a reliable “current number of running jobs” test. Zsh documents that full job control is only available in the top-level interactive shell; in pipelines, subshells, and similar contexts, jobs sees a snapshot of the job state. The same docs also note that jobs -r means “running jobs only.” Zsh Plain jobs can include jobs that are already done but not yet reported/removed from the parent shell’s job table.
To confirm whether you have the bad zsh build:
Zshzsh_bin=$(readlink -f "$(command -v zsh)")
drv=$(nix-store -q --deriver "$zsh_bin")
nix log "$drv" | grep -F 'POSIX sigsuspend'
Bad:
checking if POSIX sigsuspend() works... no
Good:
checking if POSIX sigsuspend() works... yes
On Linux you can also check whether the resulting binary refers to pause instead of sigsuspend:
Zshnm -D --undefined-only "$(readlink -f "$(command -v zsh)")" \
| grep -E '\b(pause|sigsuspend)@'
Update nixpkgs to a revision containing NixOS/nixpkgs PR #513971, then rebuild zsh. If your nixpkgs revision already contains that PR and the configure log says yes, then the remaining issue is the jobs | wc -l idiom itself.
A better zsh-native version avoids command substitution, avoids wc, avoids a pipeline, and counts only running jobs:
Zsh# Wait until fewer than LIMIT zsh jobs are running.
wait-until-jobs-below() {
emulate -L zsh
zmodload -F zsh/parameter p:jobstates || return
zmodload zsh/zselect || return
local -i limit=${1:?usage: wait-until-jobs-below LIMIT}
local -i running
local state
while true; do
running=0
for state in "${(@v)jobstates}"; do
[[ $state == running:* ]] && (( ++running ))
done
(( running < limit )) && return 0
# Avoid spawning an external sleep process just to pause briefly.
zselect -t 10 >/dev/null || true
done
}
jobstates is the zsh-provided associative array for job state, with values beginning with states such as running, suspended, or done, so this counts the thing you actually meant to limit. Zsh
After the zsh package is fixed, this minimal version is also less wrong than the original, though it still uses $() and a pipeline:
Zshwait-until-jobs-below() {
while (( $(jobs -r | wc -l) >= $1 )); do
sleep 0.1
done
}
The robust fix is: update/rebuild zsh for the sigsuspend patch, then stop using jobs | wc -l as the live job counter.
You said:
zsh
# Wait until the number of shell jobs drops below a limit.
# This can be used as an alternative to parallel using
# for i in ...; do something $i &; wait-until-jobs-below 8; done
#
# This function was reimplemented with the help of
# Slop-provider: ChatGPT-5.5-Pro
# after upgrading from NixOS 25.11 to 26.05-pre (still zsh 5.9)
# caused the simpler version:
# while test $(jobs | wc -l) -ge "$1"; do sleep 0.1; done
# to sleep forever.
wait-until-jobs-below() {
emulate -L zsh
zmodload -F zsh/parameter p:jobstates
zmodload zsh/zselect
local -i limit=${1:?usage: wait-until-jobs-below LIMIT}
local -i running
local state
while true; do
running=0
for state in "${(@v)jobstates}"; do
[[ $state == running:* ]] && (( ++running ))
done
(( running < limit )) && return 0
# Like `sleep 0.1`
zselect -t 10
done
}ChatGPT said:
That result narrows it: the forever-sleep is not just the sigsuspend() misbuild. The key remaining difference is that the working version reads zsh’s parent-shell job table directly, while the minimal versions force jobs through a pipeline/command substitution.
Yep — that rules out my first diagnosis. The “minimal version” was still wrong because it kept the real bug:
Zsh$(jobs -r | wc -l)
That still runs jobs in a context where zsh job control is not live. Zsh’s docs say full job control is only available in the top-level interactive shell; in the left side of pipelines or subshell constructs, zsh exposes only a snapshot of job state. jobs -r | wc -l has both problems: jobs is on the left side of a pipeline, and the whole thing is inside $(). Zsh
So jobs -r did not save it. It avoids stopped jobs, but it is still querying/printing from a child-side snapshot, and any cleanup that jobs causes happens in that child context, not in the parent shell function that is looping. This matches old zsh-workers guidance too: using $(jobs) is specifically called out as running in a subshell where the job-state information is not the same as the current shell’s. Zsh
Your longer version works because it avoids jobs output entirely and reads zsh’s own jobstates parameter in the current shell. That parameter’s values are explicitly structured as:
job-state:mark:pid=state...
where the whole-job state is one of running, suspended, or done, so matching running:* is the right idea. Zsh
I would make only two small robustness tweaks:
Zshwait-until-jobs-below() {
emulate -L zsh
zmodload -F zsh/parameter p:jobstates || return
zmodload zsh/zselect || return
local -i limit=${1:?usage: wait-until-jobs-below LIMIT}
local -i running
local state
(( limit > 0 )) || {
print -ru2 -- 'wait-until-jobs-below: LIMIT must be greater than 0'
return 2
}
while true; do
running=0
for state in "${(@v)jobstates}"; do
[[ $state == running:* ]] && (( ++running ))
done
(( running < limit )) && return 0
# Like `sleep 0.1`; zselect returns 1 on timeout by design.
zselect -t 10 >/dev/null || true
done
}
The zselect comment matters because zselect -t with no ready file descriptors returns status 1 on timeout, even though that is the expected “slept successfully” case. Zsh
So the short correction is: the regression exposed that jobs | wc -l was never a safe live job counter in interactive zsh; jobstates in the parent shell is the right primitive.
You said:
zsh
# Wait until the number of shell jobs drops below a limit.
# This can be used as an alternative to parallel using
# for i in ...; do something $i &; wait-until-jobs-below 8; done
#
# This function was reimplemented with the help of
# Slop-provider: ChatGPT-5.5-Pro
# Slop-provider: ChatGPT-5.5-Thinking
# after upgrading from NixOS 25.11 to 26.05-pre (still zsh 5.9)
# caused the simpler version:
# while test $(jobs | wc -l) -ge "$1"; do sleep 0.1; done
# to sleep forever.
wait-until-jobs-below() {
emulate -L zsh
zmodload -F zsh/parameter p:jobstates || { print -ru2 -- 'wait-until-jobs-below: failed to load zsh/parameter'; return 2; }
zmodload zsh/zselect || { print -ru2 -- 'wait-until-jobs-below: failed to load zsh/zselect'; return 2; }
local -i limit=${1:?usage: wait-until-jobs-below LIMIT}
local -i running
local state
(( limit > 0 )) || {
print -ru2 -- 'wait-until-jobs-below: LIMIT must be greater than 0'
return 2
}
while true; do
running=0
for state in "${(@v)jobstates}"; do
[[ $state == running:* ]] && (( ++running ))
done
(( running < limit )) && return 0
# Like `sleep 0.1`
zselect -t 10 > /dev/null || true
done
}ChatGPT said:
Looks good. The important part is fixed: no jobs, no pipeline, no $() polling loop.
Only small nits I’d consider:
Zshzmodload -F zsh/parameter p:jobstates || { print -ru2 -- 'wait-until-jobs-below: failed to load zsh/parameter'; return 2; }
zmodload zsh/zselect || { print -ru2 -- 'wait-until-jobs-below: failed to load zsh/zselect'; return 2; }
The trailing spaces before } are harmless but worth removing.
The one semantic tweak I’d make is avoiding ${1:?...} if you want cleaner error handling for missing or non-integer arguments. Your current version is fine for personal shell code, but this is a little more predictable:
Zshwait-until-jobs-below() {
emulate -L zsh
zmodload -F zsh/parameter p:jobstates || { print -ru2 -- 'wait-until-jobs-below: failed to load zsh/parameter'; return 2; }
zmodload zsh/zselect || { print -ru2 -- 'wait-until-jobs-below: failed to load zsh/zselect'; return 2; }
(( $# == 1 )) || {
print -ru2 -- 'usage: wait-until-jobs-below LIMIT'
return 2
}
[[ $1 == <-> ]] || {
print -ru2 -- 'wait-until-jobs-below: LIMIT must be a positive integer'
return 2
}
local -i limit=$1
local -i running
local state
(( limit > 0 )) || {
print -ru2 -- 'wait-until-jobs-below: LIMIT must be greater than 0'
return 2
}
while true; do
running=0
for state in "${(@v)jobstates}"; do
[[ $state == running:* ]] && (( ++running ))
done
(( running < limit )) && return 0
# Like `sleep 0.1`; timeout is the expected case.
zselect -t 10 >/dev/null || true
done
}
Your version should work as-is; this just makes bad calls like these fail more deliberately:
Zshwait-until-jobs-below
wait-until-jobs-below foo
wait-until-jobs-below 0
You said:
ChatGPT said:
In zsh patterns, <-> means a decimal integer: one or more digits.
So this check:
Zsh[[ $1 == <-> ]]
means “does $1 consist only of digits?”
Examples:
Zsh[[ 123 == <-> ]] # true
[[ 0 == <-> ]] # true
[[ foo == <-> ]] # false
[[ 12x == <-> ]] # false
[[ -3 == <-> ]] # false
[[ '' == <-> ]] # false
It is a zsh glob-pattern feature, not a regex. More generally:
Zsh<10-20>
matches integers from 10 through 20.
And:
Zsh<->
is the open-ended form, matching any non-negative integer.
In that function, it catches non-integer input before this line:
Zshlocal -i limit=$1
because local -i will coerce invalid arithmetic-ish strings in ways that may be surprising. Since <-> allows 0, the later check handles that:
Zsh(( limit > 0 ))