Skip to content
Activity · 12m 32s
Pro thinking
Investigating zsh issue after NixOS upgrade

The user seems to have a zsh-related issue after upgrading NixOS. It looks like they uploaded a zsh.zip file, possibly source code, which needs inspection. I should check the changes in zsh with the NixOS upgrade to version 26.05-pre, potentially using the web to gather the latest info. I'll also consider inspecting the zip file using a container. This might take a bit of time, so I'll give the user an update soon.

Exploring possible zsh job table issue after upgrade

So, the behavior of the job table in zsh seems to have changed. I’m looking at this function while test $(jobs | wc -l) -ge "$1"; do sleep 0.1; done. In interactive zsh, jobs seems to count jobs differently in command substitution. The issue is that after an upgrade, the job count doesn’t decrease, causing the loop to sleep forever. I'll look into whether this is due to zsh 5.9 changes or a bug.

Browsing for Zsh job count and NixOS issues
Checking zsh release notes for job table behavior change

The zsh release notes mention something about parent shell jobs being in subshells, and possibly frozen snapshots — this might be the root cause of the issue. I’ll need to check the release notes from the uploaded zsh.zip file to confirm, as it might be related to zsh 5.9.

Browsing zsh 5.9 release notes for potential issues
Reviewing zsh 5.9 release notes

I need to check the first 100 lines of the zsh 5.9 release notes to better understand the potential causes of the issue. This will help determine whether the changes related to jobs and subshells are mentioned and if they align with the problem the user is experiencing.

Searching for top release lines for version 5.9
Inspecting uploaded zsh.zip file

I need to check the uploaded zsh.zip to see if it contains new zsh source files or a diff. To do that, I'll unzip and list the contents inside a container. This will help me understand if the zip file has anything relevant to the issues with zsh after the upgrade.

Inspecting contents of zsh.zip archive
Python
bash -lc ls -l /mnt/data && unzip -l /mnt/data/zsh.zip | sed -n '1,80p'
total 57459
-rw-r--r-- 1 root oai_shared 58837689 May 17 01:20 zsh.zip
Archive:  /mnt/data/zsh.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
        0  2026-05-17 01:18   zsh/
        0  2026-05-17 01:18   zsh/.git/
        0  2018-07-01 08:03   zsh/.git/branches/
        0  2018-07-01 08:03   zsh/.git/hooks/
      478  2018-07-01 08:03   zsh/.git/hooks/applypatch-msg.sample
      896  2018-07-01 08:03   zsh/.git/hooks/commit-msg.sample
      189  2018-07-01 08:03   zsh/.git/hooks/post-update.sample
      424  2018-07-01 08:03   zsh/.git/hooks/pre-applypatch.sample
     1642  2018-07-01 08:03   zsh/.git/hooks/pre-commit.sample
     1348  2018-07-01 08:03   zsh/.git/hooks/pre-push.sample
     4898  2018-07-01 08:03   zsh/.git/hooks/pre-rebase.sample
      544  2018-07-01 08:03   zsh/.git/hooks/pre-receive.sample
     1239  2018-07-01 08:03   zsh/.git/hooks/prepare-commit-msg.sample
     3610  2018-07-01 08:03   zsh/.git/hooks/update.sample
        0  2026-05-17 01:18   zsh/.git/info/
      240  2018-07-01 08:03   zsh/.git/info/exclude
    18651  2026-05-17 01:18   zsh/.git/info/refs
        0  2024-05-25 11:47   zsh/.git/logs/
        0  2018-07-01 08:03   zsh/.git/logs/refs/
        0  2024-05-25 11:47   zsh/.git/logs/refs/heads/
      159  2026-05-17 01:18   zsh/.git/logs/refs/heads/master
        0  2018-07-01 08:03   zsh/.git/logs/refs/remotes/
        0  2026-05-17 01:18   zsh/.git/logs/refs/remotes/origin/
        0  2024-05-25 11:47   zsh/.git/logs/refs/remotes/origin/5.9
        0  2024-05-25 11:47   zsh/.git/logs/refs/remotes/origin/HEAD
        0  2024-05-25 11:47   zsh/.git/logs/refs/remotes/origin/declarednull
        0  2024-05-25 11:47   zsh/.git/logs/refs/remotes/origin/job_control_debug
        0  2024-05-25 11:47   zsh/.git/logs/refs/remotes/origin/job_control_debug2
    10320  2026-05-17 01:18   zsh/.git/logs/refs/remotes/origin/master
        0  2024-05-25 11:47   zsh/.git/logs/refs/remotes/origin/zsh-5.8-patches
      156  2026-05-17 01:18   zsh/.git/logs/refs/remotes/origin/zsh-5.9-patches
      527  2026-05-17 01:18   zsh/.git/logs/HEAD
        0  2026-05-17 01:18   zsh/.git/objects/
        0  2026-05-17 01:18   zsh/.git/objects/info/
      107  2026-05-17 01:18   zsh/.git/objects/info/packs
        0  2026-05-17 01:18   zsh/.git/objects/info/commit-graphs/
   886352  2024-05-25 11:47   zsh/.git/objects/info/commit-graphs/graph-415d73a48d2ddaa4a23cdf49cf843ff462e65e91.graph
    45724  2026-05-17 01:18   zsh/.git/objects/info/commit-graphs/graph-0229a8e1ebc5c3b70b4024c8f5bf697640182b3e.graph
       82  2026-05-17 01:18   zsh/.git/objects/info/commit-graphs/commit-graph-chain
        0  2026-05-17 01:18   zsh/.git/objects/pack/
  2521128  2018-07-01 08:03   zsh/.git/objects/pack/pack-b00cf71d54c9e70cede8cf26b1083cce9c4419bd.idx
 28199497  2023-06-26 13:02   zsh/.git/objects/pack/pack-b00cf71d54c9e70cede8cf26b1083cce9c4419bd.pack
  8902115  2026-05-17 01:18   zsh/.git/objects/pack/pack-0e7d9080f52de2df61c32c9d24004931ae761267.pack
    70640  2026-05-17 01:18   zsh/.git/objects/pack/pack-0e7d9080f52de2df61c32c9d24004931ae761267.rev
   495188  2026-05-17 01:18   zsh/.git/objects/pack/pack-0e7d9080f52de2df61c32c9d24004931ae761267.idx
  3015388  2026-05-17 01:18   zsh/.git/objects/pack/multi-pack-index
        0  2018-07-01 08:03   zsh/.git/refs/
        0  2026-05-17 01:18   zsh/.git/refs/heads/
       41  2026-05-17 01:18   zsh/.git/refs/heads/master
        0  2018-07-01 08:03   zsh/.git/refs/remotes/
        0  2026-05-17 01:18   zsh/.git/refs/remotes/origin/
       32  2018-07-01 08:03   zsh/.git/refs/remotes/origin/HEAD
       41  2026-05-17 01:18   zsh/.git/refs/remotes/origin/master
       41  2026-05-17 01:18   zsh/.git/refs/remotes/origin/zsh-5.9-patches
        0  2025-04-21 08:32   zsh/.git/refs/tags/
       41  2025-04-21 08:32   zsh/.git/refs/tags/zsh-5.9.0.2-test
     6775  2022-09-25 20:55   zsh/.git/COMMIT_EDITMSG
        0  2026-05-17 01:18   zsh/.git/FETCH_HEAD
      272  2018-12-14 08:27   zsh/.git/config
       73  2018-07-01 08:03   zsh/.git/description
        0  2023-12-17 23:31   zsh/.git/rebase-merge/
        0  2023-12-17 23:31   zsh/.git/rebase-merge/interactive
       18  2023-12-17 23:31   zsh/.git/rebase-merge/head-name
       41  2023-12-17 23:31   zsh/.git/rebase-merge/onto
       41  2023-12-17 23:31   zsh/.git/rebase-merge/orig-head
        0  2023-12-17 23:31   zsh/.git/rebase-merge/drop_redundant_commits
        0  2023-12-17 23:31   zsh/.git/rebase-merge/no-reschedule-failed-exec
     1597  2023-12-17 23:31   zsh/.git/rebase-merge/git-rebase-todo.backup
        2  2023-12-17 23:31   zsh/.git/rebase-merge/end
        0  2023-12-17 23:31   zsh/.git/rebase-merge/git-rebase-todo
      111  2023-12-17 23:31   zsh/.git/rebase-merge/done
        2  2023-12-17 23:31   zsh/.git/rebase-merge/msgnum
      100  2023-12-17 23:31   zsh/.git/rebase-merge/author-script
       41  2023-12-17 23:31   zsh/.git/rebase-merge/stopped-sha
      371  2023-12-17 23:31   zsh/.git/rebase-merge/patch
      129  2023-12-17 23:31   zsh/.git/rebase-merge/message
       41  2023-12-17 23:31   zsh/.git/REBASE_HEAD
Checking zsh commit history

I need to extract and possibly compare commits from the zsh source to see what's changed. To do this, I'll check the currently checked-out state and use git log to gather information on the most recent changes. This might help identify where things went wrong after the upgrade.

Extracting and comparing recent git commits
Python
bash -lc rm -rf /mnt/data/zsh_work && mkdir /mnt/data/zsh_work && unzip -q /mnt/data/zsh.zip -d /mnt/data/zsh_work && cd /mnt/data/zsh_work/zsh && git status --short --branch && git --no-pager log --oneline -n 20 --decorate --date=short --pretty=format:'%h %ad %d %s'
## master...origin/master
 T Functions/VCS_Info/test-repo-git-rebase-merge
b2dc18b5b 2026-05-16  (HEAD -> master, origin/master, origin/HEAD) unposted: zsh-merge-changelog: find even more context
3e21cc694 2026-05-16  github #169: update change log
1502edd59 2026-05-16  github #169: completion: remove _darcs
99d875d65 2026-05-16  github #169: completion: update _darcs from up-stream
6cd02b9e2 2026-03-09  github #161 (tweaked): zkbd: allow zkbd to generate config in a custom directory
28c3808d7 2026-05-11  github #170 (tweaked): Clarify the documentation about -d flag in compadd
78c3e89df 2026-05-13  54525: interrupt during redirect to fifo doesn't run precmd
724548b4e 2026-05-13  54569: zle: various issues
f41f7bed8 2026-05-13  54568: zformat: fix some misparse and overrun issues
13dfc7b18 2026-05-13  54567: zselect: don't pass fd>=FD_SETSIZE to FD_SET
17185544f 2026-05-13  54566: zpty: fix some issues
2c43edc5c 2026-05-13  54565: watch: increase a quite tight bound on format length
458ce8950 2026-05-12  54564: termcap, terminfo: reset u, fix metafy confusion
e555255ee 2026-05-12  54563: stat, system: minor fixes
371394a52 2026-05-12  54562: random: Fix some bugs in the random module
438a0444f 2026-05-12  54561: regex: regfree on pattern where regcomp failed could be undefined behavior
3fc8de899 2026-05-12  54560: parameter: Fix some issues in the parameter module
75ebfe8cf 2026-05-12  54559: nearcolor was missing the third grayscale entry in 88 color mode
d8c0f0085 2026-05-12  54558: ksh93: .sh.match handling with KSHARRAYS set was totally broken
3607894ea 2026-05-12  54557: langinfo: fix some metafy confusion
Reproducing zsh behavior with compiled source

I might try reproducing the behavior by compiling the latest zsh master source. This step could take a while, but it might help narrow down the issue. If the package includes the source, I can analyze the code and commits around job control to see what has changed. Time to search!

Searching zsh commits related to job control
Python
bash -lc cd /mnt/data/zsh_work/zsh && git --no-pager log --oneline --decorate --grep='job' --all -n 100
d55bbb2c7 unposted: ghostscript completion does a better job of handling gsnd
0902f566d 52059 (+52070): _scons: fix for options --jobs and --question
217673dbe 51322 (+ minor tweak): Update gnu make completion for version 4.4
177949afb 50379/50380: fix off-by-one side-effect of workers/49906 that broke $(jobs -l)
31e8b0a14 unposted: ghostscript completion does a better job of handling gsnd
0ecc456fb 52365: record state of exited background jobs so as to be visible in TRAPCHLD
ddfc81a2b 52309: fix cases that hang with all signals blocked.
d36b9ae0d 52059 (+52070): _scons: fix for options --jobs and --question
03695f4b5 51977: PIPEFAIL interaction with ERREXIT / ERRRETURN
f54ed0b76 51322 (+ minor tweak): Update gnu make completion for version 4.4
61610ea4b 50922: fix additional cases of signals for current shell jobs on the right of a pipeline.
188c5cd51 50874: fix handling of tty signals for jobs in the current shell when waiting for the right side of a pipeline.
cb59dfb3a 50379/50380: fix off-by-one side-effect of workers/49906 that broke $(jobs -l)
52761c941 50335: simplify "wait" usage, fix signal handling
98e463408 49906 (Bart), 49911: Fixes to querying jobs in subshell.
67f932e7c 49897: Eliminate reliance on $jobstates parameter, fix -P exit status check.
2be2efc12 49787: test for jobs fix in 49783
6a8aa2aa5 49783: Consistently use old job table in parameter module
d165d408a 49266: fix segfault on metacharacters in long job texts
8bdbc6149 33465: use prctl() for "jobs -Z" where available
6fc8e8628 Merge remote-tracking branch 'origin/master' into 5.9
14ea665a9 users/24710: Fix job control problem with sudo.
5c55b3fb5 45453: builtins: kill: Do not signal current process group when pid is empty
e0d063a2a 44635: Don't apply STAT_NOPRINT to backgrounded jobs
b5519b372 44290: job number exceeding int range and wrapping to a negative number crashed the shell
623294a3e 43589: Further improved subjob reporting.
80ff65dc4 43570: Start documenting jobs.c, in particular superjobs.
c2d65a3a6 43564: improve job control report about stopped subjob
464065f42 43543: Further improvements to fg/bg of superjob/subjob.
0a20f4e5a 43535: Fixes for bg / fg handling of superjobs.
551ff8427 43464: Another attachtty() fix.
7c5241edf 43446: More entersubsh() / addproc() wiring.
d6859ce68 43426: disable job control tests involving suspension.
449f13a46 42234: Stephane: don't kill a process if not running.
2d3b3510a c.f. 42726: Back off fg and bg tests. This is too hard to make robust.  Problems include - Reading input in different places causes lines to appear and disappear - There is no easy way to sequence the asynchronous operations   following bg. - Response to job control of user commands on some OSes (MacOS,   for example) is unpredicatble.
3c74891fc 42630: Improve process group handling in pipelines.
7fd8e380b 42453: Fix race in look up of status for wait.
5f6a52c06 42156: new CHECK_RUNNING_JOBS option demanded by bash groupies
da4146bdf 41688: builtin wait for all jobs should ignore STAT_NOPRINT
a955065cd Delay processing "disown" for superjob.
ae10f88bf 41386: when backgrounding a STAT_CURSH job, remove the flag.
21b195562 41043: Close pipes in shell when disowning background job.
8b5102ccf 40933: Ensure we get job text in sourced files.
a2426747d 39978: more accurate doc for finding job by string
327f3dd3a 39359: Fix remaining race with orphaned subjob.
01ae64c0d 39331: Reparent subjob on fork with exited superjob.
b7bb60b47 38923: zwaitjob() continues waiting for children that may have ignored the interrupt signal, even if the current shell has been interrupted.
5d019f426 36180: avoid infinite job stop/continue loop on "wait PID" for a background job
93ca77f8f 36079: do not allow update_job() and its helpers to run the signal queue while we are processing a job exit.
e0980e91b 35937: wait for process IDs instead of job numbers in parallel mode
e6638bbd7 35032: Handle SIGCONT for process better.
df5115a74 34979: Preserve job text when doing shell job fix.
e0cdf39fd 34955: save and restore list_pipe_job with its friends
38dc59907 34120: compctl, jobs: Check contents instead of array
d067ebcac 33876: etc.: Separate errors and keyboards interrupts
2908fcea0 Ensure propagation of SIGINT.
b4f7ccecd 33531 with additions: retain status of exited background jobs.
cb468501c 33042: $? and $pipestatus report 128+signal number for stopped jobs
ed01d6e1e 31929: Src/jobs.c: fix DPUTS3() test condition from 31906.
c3114a773 31906: fix race-condition interaction of $pipestatus with job control
a8f736b4c Users/18033: Documentation for exit and ERR_EXIT. Notes about interaction with asynchronous jobs such as process substitution and multios.
1c9931cc7 users/17955: new "disown" style for MIME handlers. Makes handlers put into the background run without job control.
709dbbbda 31549: Fix third problem with file descriptor management. Replace ad-hoc subsh_close file descriptor for pipe management with the new job-based addfilelist() mechanism.
cda21a28e 31536 with additions: Fix hang in previous process substitution fix. Close applicable file descriptors when waiting for a job.
3c5732223 31528: use job table to record file descriptors associated with process subst
aa7467b0b Improve Perforce jobs completion to limit potentially huge output using jobview by default; fix quoting of arguments passed on to eval within _call_program.
cc7437bf2 31159: git: Pass prefix filter to ls-files even if it matches no files
daa9e1fd5 users/17042: don't stomp the environment in "jobs -Z" unless we've first copied it to new memory.
ad92cb320 30724: shell code optimisd to use execsimple() doesn't have a valid thisjob
93139f39f 30000 plus some comments: Better POSIXJOBs behaviour. Don't restore default SIGTTOU etc. behaviour if still doing job control. Only carry on doing job control in subshell if it's a real (...) subshell
748bd73d8 unposted: cross-reference zmodload from the modules intro doc. 29769: follow-up 29677 to handle the case where thisjob == -1.
fdcabaf50 Use preexec hook to replace "fg" et al. with the jobtext of the resumed job.
ecb696723 users/16251: use preexec hook in prompt_bart_setup to capture jobtext. 29694: Avoid overwriting current history word when not expanding an alias or history event.
92ee9324a 29654: "wait" should resume stopped jobs identified by process ID as well as by job number; temporary (?) workaround for pipelines getting lost if TSTP is delivered when a shell builtin is the tail of the pipe.
98b29d02c 29481: always return a matching job in findproc() but scan the whole list to prefer running jobs
21c39600e 29472: findproc() needs at least to also return	stopped jobs.
57f5b9768 29472: findproc() needs at least to also return stopped jobs.
0f1fd5b91 28475: test that starting background job resets status
c7ff1b79d 28179, users/15314, users/15310, users/15200: various job and process control fixes
5659426c2 always print job status if non-zero status and PRINTEXITSVALUE
dbf51cf3f 27708: remove distracting initial semicolons in job outpt
bda90c969 27159: restore pre-23067 behavior of SIGINT outside a "wait", so that foreground jobs that handle or ignore interrupts are not orphaned.
3257f6b8d 27134: Close SHTTY on exec. 27135: POSIX_JOBS leaves MONITOR on in subshell and doesn't save parent job table.
bd2ee44b7 27119: job output to stdout if non-interactive no automated jobs notifications
5df700fb5 27112: allow jobbing output if not interactive
14e709698 users/13937, users/13939: configure.ac, Src/jobs.c: check for and if available use setproctitle() for "jobs -Z".
f0bcd0ecd users/13910: spawnjob() should output to tty
f5a6b2a8c users/11818: allow non-numeric keys for job status parameters
e85760e05 users/11807: fix some job display bugs
6f99bd559 Merge of 21366: LONG_LIST_JOBS applies to more places where job listing are printed.
a78402247 Merge of 21313: make code for extracting mutt aliases do a better job of finding rc files.
9fb47cff9 23238: jobs didn't get deleted if they weren't printed
a1476c1ee 23141: don't zrfresh() after printjob() that didn't print
ebc76673a 22953: Mikael Magnusson: _most_recent_file fix. users/10928: send all "jobs" output to stdout
3292f9ec1 unposted: tweak job completion in _perforce
02446276e 21872: job accounting in subshells was screwy
4a2f8d92e 21872: job accounting in subshells was screwy
495f177f3 21366: LONG_LIST_JOBS applies to more places where job listings are printed
4714ce085 The PROMPT_SP output should be prior to the job notifications too.
f478a8b75 21313: make code for extracting mutt aliases do a better job of finding rc files
Checking latest commits and mailing list discussions

I need to understand the latest commits, especially those that might be newer than the web docs. It might be useful to search through the mailing list for any related discussions, like worker 49906, to gather relevant details. Time to dig deeper and see what’s been shared!

Searching for recent zsh worker commits and issues
Investigating the cause of function issue after upgrade

It looks like the issue with the function not working after the 25.11 to 26.05-pre upgrade might be related to NixOS switching from a patched zsh 5.9 to the upstream master 5.9.0.2-test. I need to confirm this by searching for the exact details in NixOS packages. Time to look into it!

Searching NixPKGS for Zsh 5.9.0.2-test details
Opening nixpkgs default.nix for inspection

I need to open the "default.nix" file from the nixpkgs repository to understand what's been configured there. This will help me dive deeper into the possible reasons behind the issue, especially in relation to the zsh package changes. Let’s see what’s in there.

Searching package path for zsh configuration

I think the relevant file might be at pkgs/by-name/zs/zsh/package.nix. I’m going to search there to see if I can find any references to how the zsh package is set up or if there’s something helpful regarding the changes. Let’s see what turns up.

Searching for Zsh package details in Nixpkgs
Investigating possible issues with zsh and container setup

I’m trying to get to the root cause. It’s possible the command substitution issue is related to a missing pipeline, or maybe the container doesn’t have zsh installed. I’ll check for zsh and consider if GitHub web pages provide sufficient details. We’ll test with the current setup.

Checking zsh installation and version
Python
bash -lc command -v zsh || true; zsh --version 2>/dev/null || true
Considering options for compiling zsh from source

It seems like zsh isn’t present. I might need to compile it from source. If I go that route, I should probably replicate the setup by compiling the latest master, or possibly tagging at zsh-5.9. I'll check dependencies like gcc, make, and autoconf before proceeding.

Checking source for Zsh compilation and dependencies
Python
bash -lc cd /mnt/data/zsh_work/zsh && ls | head && ls configure configure.ac Src/jobs.c Doc/Zsh/mod_parameter.yo >/dev/null && echo ok && grep -R "oldjobtab\|oldmaxjob\|list_pipe_job\|SUBLEVEL" -n Src/jobs.c Src/exec.c Src/Modules/parameter.c | sed -n '1,120p'
ChangeLog
Completion
Config
Doc
Etc
FEATURES
Functions
INSTALL
LICENCE
MACHINES
ls: cannot access 'configure': No such file or directory


Command 'bash -lc 'cd /mnt/data/zsh_work/zsh && ls | head && ls configure configure.ac Src/jobs.c Doc/Zsh/mod_parameter.yo >/dev/null && echo ok && grep -R "oldjobtab\|oldmaxjob\|list_pipe_job\|SUBLEVEL" -n Src/jobs.c Src/exec.c Src/Modules/parameter.c | sed -n '"'"'1,120p'"'"''' failed with status 2.
Inspecting missing configuration

It looks like something might be missing in the configuration process, possibly related to the Util/preconfig. I should inspect and verify if that’s the issue. Once I take a closer look, I'll know if I need to adjust anything or add any missing components.

Inspecting configuration and job settings
Python
bash -lc cd /mnt/data/zsh_work/zsh && ls | sed -n '1,120p' && grep -R "oldjobtab\|oldmaxjob\|jobs.*subshell\|parent shell jobs" -n Src Doc Test | sed -n '1,200p'
ChangeLog
Completion
Config
Doc
Etc
FEATURES
Functions
INSTALL
LICENCE
MACHINES
Makefile.in
Misc
NEWS
README
Scripts
Src
StartupFiles
Test
Util
aclocal.m4
aczsh.m4
config.guess
config.sub
configure.ac
install-sh
mkinstalldirs
Src/exec.c:1746:	clearoldjobtab();
Src/jobs.c:101:static struct job *oldjobtab;
Src/jobs.c:104:static int oldmaxjob;
Src/jobs.c:1146:    if (synch > 1 && oldjobtab != NULL)
Src/jobs.c:1147:	job = jn - oldjobtab;
Src/jobs.c:1150:    DPUTS3(job < 0 || job > (oldjobtab && synch > 1 ? oldmaxjob : maxjob),
Src/jobs.c:1151:	   "bogus job number, jn = %L, jobtab = %L, oldjobtab = %L",
Src/jobs.c:1152:	   (long)jn, (long)jobtab, (long)oldjobtab);
Src/jobs.c:1785:	oldmaxjob = 0;
Src/jobs.c:1794:	    oldmaxjob = i+1;
Src/jobs.c:1799:    if (monitor && oldmaxjob) {
Src/jobs.c:1800:	int sz = oldmaxjob * sizeof(struct job);
Src/jobs.c:1801:	if (oldjobtab)
Src/jobs.c:1802:	    free(oldjobtab);
Src/jobs.c:1803:	oldjobtab = (struct job *)zalloc(sz);
Src/jobs.c:1804:	memcpy(oldjobtab, jobtab, sz);
Src/jobs.c:1807:	if (thisjob != -1 && thisjob < oldmaxjob)
Src/jobs.c:1808:	    memset(oldjobtab+thisjob, 0, sizeof(struct job));
Src/jobs.c:1810:	/* oldmaxjob is now the size of the table, but outside
Src/jobs.c:1814:	--oldmaxjob;
Src/jobs.c:1835:clearoldjobtab(void)
Src/jobs.c:1837:    if (oldjobtab)
Src/jobs.c:1838:	free(oldjobtab);
Src/jobs.c:1839:    oldjobtab = NULL;
Src/jobs.c:1840:    oldmaxjob = 0;
Src/jobs.c:2044:    if (oldjobtab)
Src/jobs.c:2047:	*jtabp = oldjobtab;
Src/jobs.c:2048:	*jmaxp = oldmaxjob;
Src/jobs.c:2102:	     * If running jobs in a subshell, we are allowed to
Src/jobs.c:2107:	    (myjobtab == oldjobtab || jobnum != thisjob)) {
Src/jobs.c:2480:    if (func != BIN_JOBS || isset(MONITOR) || !oldmaxjob)
Src/jobs.c:2504:	    if (unset(MONITOR) && oldmaxjob) {
Src/jobs.c:2505:		jobptr = oldjobtab;
Src/jobs.c:2506:		curmaxjob = oldmaxjob;
Src/jobs.c:2572:	if (func != BIN_JOBS && oldjobtab != NULL) {
Src/jobs.c:2573:	    zwarnnam(name, "can't manipulate jobs in subshell");
Src/jobs.c:2584:	jstat = oldjobtab ? oldjobtab[job].stat : jobtab[job].stat;
Src/jobs.c:2605:		clearoldjobtab();
Src/jobs.c:2691:	    printjob(job + (oldjobtab ? oldjobtab : jobtab), lng, 2);
Doc/Zsh/builtins.yo:1121:This gives information about the state of jobs at the point the subshell
Doc/Zsh/mod_parameter.yo:173:See the tt(jobs) builtin for how job information is provided in a subshell.
Doc/Zsh/mod_parameter.yo:183:See the tt(jobs) builtin for how job information is provided in a subshell.
Doc/Zsh/mod_parameter.yo:201:See the tt(jobs) builtin for how job information is provided in a subshell.
Doc/Zsh/options.yo:1543:cindex(jobs, output in subshell)
Test/W03jobparameters.ztst:46:0:$jobstate for running job in main shell and subshell
Test/W03jobparameters.ztst:61:0:$jobstate shows one job started in main shell or one started in subshell
Test/W03jobparameters.ztst:75:0:$jobstate shows no job started in main shell but one started in subshell
Inspecting the documentation

I need to check the documentation to get a better understanding. It’s likely that the answer or solution I need is there. Once I review it thoroughly, I'll be able to proceed with more clarity and adjust things if necessary.

Inspecting documentation sections in Zsh
Python
bash -lc cd /mnt/data/zsh_work/zsh && sed -n '1100,1135p' Doc/Zsh/builtins.yo && sed -n '1530,1560p' Doc/Zsh/options.yo && sed -n '35,85p' Test/W03jobparameters.ztst && sed -n '1760,1825p' Src/jobs.c && sed -n '2470,2520p' Src/jobs.c
xitem(tt(jobs) [ tt(-dlprs) ] [ var(job) ... ])
item(tt(jobs -Z) var(string))(
Lists information about each given job, or all jobs
if var(job) is omitted.  The tt(-l) flag lists process
IDs, and the tt(-p) flag lists process groups.
If the tt(-r) flag is specified only running jobs will be listed
and if the tt(-s) flag is given only stopped jobs are shown.
If the tt(-d) flag is given, the directory from which the job was
started (which may not be the current directory of the job) will also
be shown.

The tt(-Z) option replaces the shell's argument and environment space with
the given string, truncated if necessary to fit.  This will normally be
visible in tt(ps) (manref(ps)(1)) listings.  This feature is typically
used by daemons, to indicate their state.

Full job control is only available in the top-level interactive shell,
not in commands run in the left hand side of pipelines or within
the tt(LPAR())var(...)tt(RPAR()) construct.  However, a snapshot
of the job state at that point is taken, so it is still possible
to use the tt(jobs) builtin, or any parameter providing job information.
This gives information about the state of jobs at the point the subshell
was created.  If background processes are created within the subshell,
then instead information about those processes is provided.

For example,

example(sleep 10 &    # Job in background
LPAR()             # Shell forks
jobs          # Shows information about "sleep 10 &"
sleep 5 &     # Process in background (no job control)
jobs          # Shows information about "sleep 5 &"
RPAR())
)
findex(kill)
cindex(killing jobs)
cindex(background jobs, notification)
cindex(notification of background jobs)
item(tt(NOTIFY) (tt(-5), ksh: tt(-b)) <Z>)(
Report the status of background jobs immediately, rather than
waiting until just before printing a prompt.
)
pindex(POSIX_JOBS)
pindex(POSIXJOBS)
pindex(NO_POSIX_JOBS)
pindex(NOPOSIXJOBS)
cindex(bg, output in POSIX format)
cindex(fg, output in POSIX format)
cindex(job control, in subshell)
cindex(jobs, output in subshell)
item(tt(POSIX_JOBS) <K> <S>)(
This option makes job control more compliant with the POSIX standard.

When the option is not set, the tt(MONITOR) option is unset on entry to
subshells, so that job control is no longer active.  When the option is
set, the tt(MONITOR) option and job control remain active in the
subshell, but note that the subshell has no access to jobs in the parent
shell.

When the option is not set, jobs put in the background or foreground
with tt(bg) or tt(fg) are displayed with the same information that would
be reported by tt(jobs).  When the option is set, only the text is
printed.  The output from tt(jobs) itself is not affected by the option.

When the option is not set, job information from the parent
shell is saved for output within a subshell (for example, within a
pipeline).  When the option is set, the output of tt(jobs) is empty
  fi

%test

  zpty_start
  zpty_input "MODULE_PATH=${(q)MODULE_PATH}"
  zpty_input 'sleep 3 &'
  zpty_input 'print $jobstates'
  zpty_input '(print $jobstates)'
  zpty_input 'jobs -s'
  zpty_stop
0:$jobstate for running job in main shell and subshell
*>\[1] [0-9]##
*>running:+:*=running
*>running:+:*=running
*>zsh:*SIGHUPed*

# $jobstates refers to a job started in the main shell unless
# one has been started in the subshell.  In the latter case,
# the subshell has no job control so the job is not marked as current.
  zpty_start
  zpty_input "MODULE_PATH=${(q)MODULE_PATH}"
  zpty_input 'sleep 3 &'
  zpty_input '(print main; print $jobstates; sleep 2& print sub; print $jobstates)'
  zpty_input 'jobs -s'
  zpty_stop
0:$jobstate shows one job started in main shell or one started in subshell
*>\[1] [0-9]##
>main
*>running:+:*=running
>sub
*>running::*=running
*>zsh:*SIGHUPed*

# output from zpty removes empty lines
  zpty_start
  zpty_input "MODULE_PATH=${(q)MODULE_PATH}"
  zpty_input '(print main; print $jobstates; sleep 2& print sub; print $jobstates)'
  zpty_input 'jobs -s'
  zpty_stop
0:$jobstate shows no job started in main shell but one started in subshell
>main
>sub
*>running::*=running

/**/
void
waitjobs(void)
{
    Job jn = jobtab + thisjob;
    DPUTS(thisjob == -1, "No valid job in waitjobs.");

    /* If there's a subjob, it should finish first. */
    if (jn->stat & STAT_SUPERJOB)
	waitonejob(jobtab + jn->other);
    waitonejob(jn);

    thisjob = -1;
}

/* clear job table when entering subshells */

/**/
mod_export void
clearjobtab(int monitor)
{
    int i;

    if (isset(POSIXJOBS))
	oldmaxjob = 0;
    for (i = 1; i <= maxjob; i++) {
	/*
	 * See if there is a jobtable worth saving.
	 * We never free the saved version; it only happens
	 * once for each subshell of a shell with job control,
	 * so doesn't create a leak.
	 */
	if (monitor && !isset(POSIXJOBS) && jobtab[i].stat)
	    oldmaxjob = i+1;
	else if (jobtab[i].stat & STAT_INUSE)
	    freejob(jobtab + i, 0);
    }

    if (monitor && oldmaxjob) {
	int sz = oldmaxjob * sizeof(struct job);
	if (oldjobtab)
	    free(oldjobtab);
	oldjobtab = (struct job *)zalloc(sz);
	memcpy(oldjobtab, jobtab, sz);

	/* Don't report any job we're part of */
	if (thisjob != -1 && thisjob < oldmaxjob)
	    memset(oldjobtab+thisjob, 0, sizeof(struct job));

	/* oldmaxjob is now the size of the table, but outside
	 * this function, it's used as a job number, which must
	 * be the largest index available in the table.
	 */
	--oldmaxjob;
    }


    memset(jobtab, 0, jobtabsize * sizeof(struct job)); /* zero out table */
    maxjob = 0;

    /*
     * Although we don't have job control in subshells, we
     * sometimes needs control structures for other purposes such
     * as multios.  Grab a job for this purpose; any will do
     * since we've freed them all up (so there's no question
     * This updates stopped processes (but we should have been
     * signalled about those, up to inevitable races), and also
     * continued processes if that feature is available.
     */
    wait_for_processes();

    /* If necessary, update job table. */
    if (unset(NOTIFY))
	scanjobs();

    if (func != BIN_JOBS || isset(MONITOR) || !oldmaxjob)
	setcurjob();

    if (func == BIN_JOBS)
        /* If you immediately type "exit" after "jobs", this      *
         * will prevent zexit from complaining about stopped jobs */
	stopmsg = 2;
    if (!*argv) {
	/* This block handles all of the default cases (no arguments).  bg,
	fg and disown act on the current job, and jobs and wait act on all the
	jobs. */
 	if (func == BIN_FG || func == BIN_BG || func == BIN_DISOWN) {
	    /* W.r.t. the above comment, we'd better have a current job at this
	    point or else. */
	    if (curjob == -1 || (jobtab[curjob].stat & STAT_NOPRINT)) {
		zwarnnam(name, "no current job");
		unqueue_signals();
		return 1;
	    }
	    firstjob = curjob;
	} else if (func == BIN_JOBS) {
	    /* List jobs. */
	    struct job *jobptr;
	    int curmaxjob, ignorejob;
	    if (unset(MONITOR) && oldmaxjob) {
		jobptr = oldjobtab;
		curmaxjob = oldmaxjob;
		ignorejob = 0;
	    } else {
		jobptr = jobtab;
		curmaxjob = maxjob;
		ignorejob = thisjob;
	    }
	    for (job = 0; job <= curmaxjob; job++, jobptr++)
		if (job != ignorejob && jobptr->stat) {
		    if ((!OPT_ISSET(ops,'r') && !OPT_ISSET(ops,'s')) ||
			(OPT_ISSET(ops,'r') && OPT_ISSET(ops,'s')) ||
			(OPT_ISSET(ops,'r') && 
			 !(jobptr->stat & STAT_STOPPED)) ||
			(OPT_ISSET(ops,'s') && jobptr->stat & STAT_STOPPED))
			printjob(jobptr, lng, 2);
Finishing options documentation

I need to finish reviewing the options documentation to make sure I have all the details. It seems like this is the last step before I’m ready to move on. Once I’ve completed it, I’ll have a clearer picture of the available choices and can make more informed decisions.

Extracting finish options from doc
Python
bash -lc cd /mnt/data/zsh_work/zsh && sed -n '1560,1585p' Doc/Zsh/options.yo
pipeline).  When the option is set, the output of tt(jobs) is empty
until a job is started within the subshell.

In previous versions of the shell, it was necessary to enable
tt(POSIX_JOBS) in order for the builtin command tt(wait) to return the
status of background jobs that had already exited.  This is no longer
the case.
)
enditem()

subsect(Prompting)
startitem()
pindex(PROMPT_BANG)
pindex(NO_PROMPT_BANG)
pindex(PROMPTBANG)
pindex(NOPROMPTBANG)
cindex(prompt, ! expansion)
item(tt(PROMPT_BANG) <K>)(
If set, `tt(!)' is treated specially in prompt expansion.
See sectref(Expansion of Prompt Sequences)(zshmisc).
)
pindex(PROMPT_CR)
pindex(NO_PROMPT_CR)
pindex(PROMPTCR)
pindex(NOPROMPTCR)
cindex(prompt, with CR)
Clarifying zsh job table and subshell behavior

Okay, I think the issue could be about command substitution in zsh triggering a subshell. When $(jobs | wc -l) runs in each loop iteration, it takes a snapshot. If the parent shell's job table isn't updated while it's sleeping, that could explain why the job count doesn't decrease as expected. I need to verify.

Investigating zsh job table and command substitution behavior

I think the issue lies in how zsh manages job tables. In each loop iteration, the subshell takes a snapshot of the parent shell’s job table, but this snapshot doesn’t update as jobs finish. I wonder if the parent shell’s job table isn’t updated because jobs is never run at the top level, only inside the subshell. I’ll need to understand the signal-handling better.

Investigating changes in zsh job table handling

It looks like zsh 5.9 introduced some changes to how command substitution handles job tables. The user’s zsh version might be a snapshot that includes an update affecting how the job table is updated. I’ll need to check zsh’s git log, specifically around the commit that altered background job handling in 2024, and compare it with the user’s NixOS versions.

Inspecting zsh git log changes
Python
bash -lc cd /mnt/data/zsh_work/zsh && git --no-pager show --stat --summary 0ecc456fb && git --no-pager show --stat --summary ddfc81a2b && git --no-pager show --stat --summary 177949afb && git --no-pager show --stat --summary 98e463408 && git --no-pager show --stat --summary 2be2efc12
commit 0ecc456fb2a4dda75e9910bb019d381406f040e8
Author: Bart Schaefer <schaefer@zsh.org>
Date:   Sat Dec 9 19:36:47 2023 -0800

    52365: record state of exited background jobs so as to be visible in TRAPCHLD

 ChangeLog     |  5 +++++
 Src/jobs.c    | 19 +++++++++++++++++++
 Src/signals.c | 16 +++-------------
 3 files changed, 27 insertions(+), 13 deletions(-)
commit ddfc81a2b81e19c13f60bfff30f28ed520df8f57
Author: Bart Schaefer <schaefer@zsh.org>
Date:   Wed Nov 15 20:29:50 2023 -0800

    52309: fix cases that hang with all signals blocked.
    
    * no job control inside <<(substition)
    * allow interrupt of multios reading from a terminal

 ChangeLog  | 4 ++++
 Src/exec.c | 4 ++--
 2 files changed, 6 insertions(+), 2 deletions(-)
commit 177949afb065303f055f1a0f9a8b57bb33eec6e0
Author: Bart Schaefer <schaefer@zsh.org>
Date:   Tue Jun 21 18:04:45 2022 -0700

    50379/50380: fix off-by-one side-effect of workers/49906 that broke $(jobs -l)
    
    (cherry picked from commit cb59dfb3a6f6cce414c5b852c138d5f6bea6d563)

 ChangeLog  | 5 +++++
 Src/jobs.c | 2 +-
 2 files changed, 6 insertions(+), 1 deletion(-)
commit 98e46340867028808e71e7f3373881cb7e5b6764
Author: Peter Stephenson <p.stephenson@samsung.com>
Date:   Wed Mar 30 09:28:43 2022 +0100

    49906 (Bart), 49911: Fixes to querying jobs in subshell.
    
    Don't attempt to query invalid job off end of table, resulting in
    crashes from $jobtstates.
    
    If background task started in subshell, look at tatsks within subshell
    instead of main shell.  Document and add test.

 ChangeLog                  |  8 ++++++++
 Doc/Zsh/builtins.yo        | 18 ++++++++++++++++++
 Doc/Zsh/mod_parameter.yo   |  6 ++++++
 Src/exec.c                 |  1 +
 Src/jobs.c                 | 20 ++++++++++++++++++++
 Test/W03jobparameters.ztst | 28 ++++++++++++++++++++++++++++
 6 files changed, 81 insertions(+)
commit 2be2efc122b70a84a9debeaf0a11b99e11df3758
Author: Peter Stephenson <p.w.stephenson@ntlworld.com>
Date:   Thu Mar 3 19:07:53 2022 +0000

    49787: test for jobs fix in 49783

 ChangeLog                  |  5 +++++
 Test/W02jobs.ztst          |  2 ++
 Test/W03jobparameters.ztst | 49 ++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 56 insertions(+)
 create mode 100644 Test/W03jobparameters.ztst
Inspecting zsh commits

I think I need to take a closer look at commits 49783 and 49906 in the zsh repo. These might hold more context about the changes in how zsh handles job tables during command substitution. I'll check them to better understand any updates or fixes that could explain what's going on.

Inspecting specific git commits
Python
bash -lc cd /mnt/data/zsh_work/zsh && git --no-pager show --stat --summary 6a8aa2aa5 98e463408 177949afb && git --no-pager show --format=fuller --no-ext-diff 6a8aa2aa5 -- Src/Modules/parameter.c Src/jobs.c Src/exec.c | sed -n '1,240p'
commit 6a8aa2aa5a07c46a6b6d5c481a18a12e7ab6047a
Author: Peter Stephenson <p.stephenson@samsung.com>
Date:   Tue Mar 1 17:27:42 2022 +0000

    49783: Consistently use old job table in parameter module

 ChangeLog               |  3 ++
 Src/Modules/parameter.c | 96 +++++++++++++++++++++++++++++--------------------
 Src/jobs.c              | 34 +++++++++++++-----
 3 files changed, 85 insertions(+), 48 deletions(-)

commit 98e46340867028808e71e7f3373881cb7e5b6764
Author: Peter Stephenson <p.stephenson@samsung.com>
Date:   Wed Mar 30 09:28:43 2022 +0100

    49906 (Bart), 49911: Fixes to querying jobs in subshell.
    
    Don't attempt to query invalid job off end of table, resulting in
    crashes from $jobtstates.
    
    If background task started in subshell, look at tatsks within subshell
    instead of main shell.  Document and add test.

 ChangeLog                  |  8 ++++++++
 Doc/Zsh/builtins.yo        | 18 ++++++++++++++++++
 Doc/Zsh/mod_parameter.yo   |  6 ++++++
 Src/exec.c                 |  1 +
 Src/jobs.c                 | 20 ++++++++++++++++++++
 Test/W03jobparameters.ztst | 28 ++++++++++++++++++++++++++++
 6 files changed, 81 insertions(+)

commit 177949afb065303f055f1a0f9a8b57bb33eec6e0
Author: Bart Schaefer <schaefer@zsh.org>
Date:   Tue Jun 21 18:04:45 2022 -0700

    50379/50380: fix off-by-one side-effect of workers/49906 that broke $(jobs -l)
    
    (cherry picked from commit cb59dfb3a6f6cce414c5b852c138d5f6bea6d563)

 ChangeLog  | 5 +++++
 Src/jobs.c | 2 +-
 2 files changed, 6 insertions(+), 1 deletion(-)
commit 6a8aa2aa5a07c46a6b6d5c481a18a12e7ab6047a
Author:     Peter Stephenson <p.stephenson@samsung.com>
AuthorDate: Tue Mar 1 17:27:42 2022 +0000
Commit:     Peter Stephenson <p.stephenson@samsung.com>
CommitDate: Tue Mar 1 17:27:42 2022 +0000

    49783: Consistently use old job table in parameter module

diff --git a/Src/Modules/parameter.c b/Src/Modules/parameter.c
index c5cb5ce62..dbb61e474 100644
--- a/Src/Modules/parameter.c
+++ b/Src/Modules/parameter.c
@@ -1244,19 +1244,19 @@ histwgetfn(UNUSED(Param pm))
 
 /**/
 static char *
-pmjobtext(int job)
+pmjobtext(Job jtab, int job)
 {
     Process pn;
     int len = 1;
     char *ret;
 
-    for (pn = jobtab[job].procs; pn; pn = pn->next)
+    for (pn = jtab[job].procs; pn; pn = pn->next)
 	len += strlen(pn->text) + 3;
 
     ret = (char *) zhalloc(len);
     ret[0] = '\0';
 
-    for (pn = jobtab[job].procs; pn; pn = pn->next) {
+    for (pn = jtab[job].procs; pn; pn = pn->next) {
 	strcat(ret, pn->text);
 	if (pn->next)
 	    strcat(ret, " | ");
@@ -1269,22 +1269,25 @@ static HashNode
 getpmjobtext(UNUSED(HashTable ht), const char *name)
 {
     Param pm = NULL;
-    int job;
+    int job, jmax;
     char *pend;
+    Job jtab;
 
     pm = (Param) hcalloc(sizeof(struct param));
     pm->node.nam = dupstring(name);
     pm->node.flags = PM_SCALAR | PM_READONLY;
     pm->gsu.s = &nullsetscalar_gsu;
 
+    selectjobtab(&jtab, &jmax);
+
     job = strtod(name, &pend);
     /* Non-numeric keys are looked up by job name */
     if (*pend)
 	job = getjob(name, NULL);
-    if (job >= 1 && job <= maxjob &&
-	jobtab[job].stat && jobtab[job].procs &&
-	!(jobtab[job].stat & STAT_NOPRINT))
-	pm->u.str = pmjobtext(job);
+    if (job >= 1 && job <= jmax &&
+	jtab[job].stat && jtab[job].procs &&
+	!(jtab[job].stat & STAT_NOPRINT))
+	pm->u.str = pmjobtext(jtab, job);
     else {
 	pm->u.str = dupstring("");
 	pm->node.flags |= (PM_UNSET|PM_SPECIAL);
@@ -1297,22 +1300,25 @@ static void
 scanpmjobtexts(UNUSED(HashTable ht), ScanFunc func, int flags)
 {
     struct param pm;
-    int job;
+    int job, jmax;
     char buf[40];
+    Job jtab;
 
     memset((void *)&pm, 0, sizeof(struct param));
     pm.node.flags = PM_SCALAR | PM_READONLY;
     pm.gsu.s = &nullsetscalar_gsu;
 
-    for (job = 1; job <= maxjob; job++) {
-	if (jobtab[job].stat && jobtab[job].procs &&
-	    !(jobtab[job].stat & STAT_NOPRINT)) {
+    selectjobtab(&jtab, &jmax);
+
+    for (job = 1; job <= jmax; job++) {
+	if (jtab[job].stat && jtab[job].procs &&
+	    !(jtab[job].stat & STAT_NOPRINT)) {
 	    if (func != scancountparams) {
 		sprintf(buf, "%d", job);
 		pm.node.nam = dupstring(buf);
 		if ((flags & (SCANPM_WANTVALS|SCANPM_MATCHVAL)) ||
 		    !(flags & SCANPM_WANTKEYS))
-		    pm.u.str = pmjobtext(job);
+		    pm.u.str = pmjobtext(jtab, job);
 	    }
 	    func(&pm.node, flags);
 	}
@@ -1323,7 +1329,7 @@ scanpmjobtexts(UNUSED(HashTable ht), ScanFunc func, int flags)
 
 /**/
 static char *
-pmjobstate(int job)
+pmjobstate(Job jtab, int job)
 {
     Process pn;
     char buf[256], buf2[128], *ret, *state, *cp;
@@ -1335,14 +1341,14 @@ pmjobstate(int job)
     else
 	cp = ":";
 
-    if (jobtab[job].stat & STAT_DONE)
+    if (jtab[job].stat & STAT_DONE)
 	ret = dyncat("done", cp);
-    else if (jobtab[job].stat & STAT_STOPPED)
+    else if (jtab[job].stat & STAT_STOPPED)
 	ret = dyncat("suspended", cp);
     else
 	ret = dyncat("running", cp);
 
-    for (pn = jobtab[job].procs; pn; pn = pn->next) {
+    for (pn = jtab[job].procs; pn; pn = pn->next) {
 
 	if (pn->status == SP_RUNNING)
 	    state = "running";
@@ -1371,21 +1377,24 @@ static HashNode
 getpmjobstate(UNUSED(HashTable ht), const char *name)
 {
     Param pm = NULL;
-    int job;
+    int job, jmax;
     char *pend;
+    Job jtab;
 
     pm = (Param) hcalloc(sizeof(struct param));
     pm->node.nam = dupstring(name);
     pm->node.flags = PM_SCALAR | PM_READONLY;
     pm->gsu.s = &nullsetscalar_gsu;
 
+    selectjobtab(&jtab, &jmax);
+
     job = strtod(name, &pend);
     if (*pend)
 	job = getjob(name, NULL);
-    if (job >= 1 && job <= maxjob &&
-	jobtab[job].stat && jobtab[job].procs &&
-	!(jobtab[job].stat & STAT_NOPRINT))
-	pm->u.str = pmjobstate(job);
+    if (job >= 1 && job <= jmax &&
+	jtab[job].stat && jtab[job].procs &&
+	!(jtab[job].stat & STAT_NOPRINT))
+	pm->u.str = pmjobstate(jtab, job);
     else {
 	pm->u.str = dupstring("");
 	pm->node.flags |= (PM_UNSET|PM_SPECIAL);
@@ -1398,22 +1407,25 @@ static void
 scanpmjobstates(UNUSED(HashTable ht), ScanFunc func, int flags)
 {
     struct param pm;
-    int job;
+    int job, jmax;
+    Job jtab;
     char buf[40];
 
+    selectjobtab(&jtab, &jmax);
+
     memset((void *)&pm, 0, sizeof(struct param));
     pm.node.flags = PM_SCALAR | PM_READONLY;
     pm.gsu.s = &nullsetscalar_gsu;
 
-    for (job = 1; job <= maxjob; job++) {
-	if (jobtab[job].stat && jobtab[job].procs &&
-	    !(jobtab[job].stat & STAT_NOPRINT)) {
+    for (job = 1; job <= jmax; job++) {
+	if (jtab[job].stat && jtab[job].procs &&
+	    !(jtab[job].stat & STAT_NOPRINT)) {
 	    if (func != scancountparams) {
 		sprintf(buf, "%d", job);
 		pm.node.nam = dupstring(buf);
 		if ((flags & (SCANPM_WANTVALS|SCANPM_MATCHVAL)) ||
 		    !(flags & SCANPM_WANTKEYS))
-		    pm.u.str = pmjobstate(job);
+		    pm.u.str = pmjobstate(jtab, job);
 	    }
 	    func(&pm.node, flags);
 	}
@@ -1424,11 +1436,11 @@ scanpmjobstates(UNUSED(HashTable ht), ScanFunc func, int flags)
 
 /**/
 static char *
-pmjobdir(int job)
+pmjobdir(Job jtab, int job)
 {
     char *ret;
 
-    ret = dupstring(jobtab[job].pwd ? jobtab[job].pwd : pwd);
+    ret = dupstring(jtab[job].pwd ? jtab[job].pwd : pwd);
     return ret;
 }
 
@@ -1437,21 +1449,24 @@ static HashNode
 getpmjobdir(UNUSED(HashTable ht), const char *name)
 {
     Param pm = NULL;
-    int job;
+    int job, jmax;
     char *pend;
+    Job jtab;
 
     pm = (Param) hcalloc(sizeof(struct param));
     pm->node.nam = dupstring(name);
     pm->node.flags = PM_SCALAR | PM_READONLY;
     pm->gsu.s = &nullsetscalar_gsu;
 
+    selectjobtab(&jtab, &jmax);
+
     job = strtod(name, &pend);
     if (*pend)
 	job = getjob(name, NULL);
-    if (job >= 1 && job <= maxjob &&
-	jobtab[job].stat && jobtab[job].procs &&
-	!(jobtab[job].stat & STAT_NOPRINT))
-	pm->u.str = pmjobdir(job);
+    if (job >= 1 && job <= jmax &&
+	jtab[job].stat && jtab[job].procs &&
+	!(jtab[job].stat & STAT_NOPRINT))
+	pm->u.str = pmjobdir(jtab, job);
     else {
 	pm->u.str = dupstring("");
 	pm->node.flags |= (PM_UNSET|PM_SPECIAL);
@@ -1464,22 +1479,25 @@ static void
 scanpmjobdirs(UNUSED(HashTable ht), ScanFunc func, int flags)
 {
     struct param pm;
-    int job;
+    int job, jmax;
     char buf[40];
+    Job jtab;
 
     memset((void *)&pm, 0, sizeof(struct param));
     pm.node.flags = PM_SCALAR | PM_READONLY;
Deciding next steps

I'm considering whether I need to build something, but I'm not entirely sure it's necessary to proceed with it right now. It might be better to hold off for a bit, but I'll continue evaluating the situation to make sure I’m not missing something critical.

Analyzing job control behavior in zsh

I need to figure out if $(jobs | wc -l) uses jobs on the left side of a pipeline, as that would run in a subshell and give a snapshot. The user's zsh upgrade may have caused jobs to work differently. A potential fix is using zsh job parameters instead of relying on jobs | wc -l. But I’m still unsure if $#jobstates updates in the parent shell or if it’s also captured as a snapshot.

Understanding `jobstates` behavior in zsh

Without command substitution or a pipeline, parameter expansion happens in the current shell, so it should likely trigger an immediate update of the job table. But I'm wondering if it counts completed jobs before a notification is processed. jobstates is a special parameter, and I need to verify if it uses the current job table and scans, or if completed jobs are still counted.

Investigating job control in zsh with functions and pipelines
Python
bash -lc cd /mnt/data/zsh_work/zsh && grep -R "void.*wait_for_processes\|wait_for_processes" -n Src | sed -n '1,120p' && sed -n '1100,1190p' Src/jobs.c && grep -R "deletejob\|freejob\|STAT_DONE" -n Src/jobs.c Src/signals.c | sed -n '1,200p'
Src/jobs.c:2474:    wait_for_processes();
Src/signals.c:249:wait_for_processes(void)
Src/signals.c:431:	wait_for_processes();
#endif

    return 0;
}

/**/
mod_export char *
sigmsg(int sig)
{
    static char *unknown = "unknown signal";
#if defined(SIGRTMIN) && defined(SIGRTMAX)
    static char rtmsg[] = "real-time event XXX";
    if (sig >= SIGRTMIN && sig <= SIGRTMAX) {
	sprintf(rtmsg + sizeof(rtmsg) - 4, "%u", sig - SIGRTMIN + 1);
	return rtmsg;
    }
#endif
    return sig <= SIGCOUNT ? sig_msg[sig] : unknown;
}

/* !(lng & 3) means jobs    *
 *  (lng & 1) means jobs -l *
 *  (lng & 2) means jobs -p
 *  (lng & 4) means jobs -d
 *
 * synch = 0 means asynchronous
 * synch = 1 means synchronous
 * synch = 2 means called synchronously from jobs
 * synch = 3 means called synchronously from bg or fg
 *
 * Returns 1 if some output was done.
 *
 * The function also deletes the job if it was done, even it
 * is not printed.
 */

/**/
int
printjob(Job jn, int lng, int synch)
{
    Process pn;
    int job, len = 9, sig, sflag = 0, llen;
    int conted = 0, lineleng = zterm_columns, skip = 0, doputnl = 0;
    int doneprint = 0, skip_print = 0;
    FILE *fout = (synch == 2 || !shout) ? stdout : shout;

    if (synch > 1 && oldjobtab != NULL)
	job = jn - oldjobtab;
    else
	job = jn - jobtab;
    DPUTS3(job < 0 || job > (oldjobtab && synch > 1 ? oldmaxjob : maxjob),
	   "bogus job number, jn = %L, jobtab = %L, oldjobtab = %L",
	   (long)jn, (long)jobtab, (long)oldjobtab);

    if (jn->stat & STAT_NOPRINT)
	skip_print = 1;

    if (lng < 0) {
	conted = 1;
	lng = !!isset(LONGLISTJOBS);
    }

    if (jn->stat & STAT_SUPERJOB &&
	jn->other)
    {
	Job sjn = &jobtab[jn->other];
	if (sjn->procs || sjn->auxprocs)
	{
	    /*
	     * A subjob still has process, which must finish before
	     * further execution of the superjob, which the user wants to
	     * know about.  So report the status of the subjob as if it
	     * were the user-visible superjob.
	     */
	    jn = sjn;
	}
    }

/* find length of longest signame, check to see */
/* if we really need to print this job          */

    for (pn = jn->procs; pn; pn = pn->next) {
	if (jn->stat & STAT_SUPERJOB &&
	    jn->procs->status == SP_RUNNING && !pn->next)
	    pn->status = SP_RUNNING;
	if (pn->status != SP_RUNNING) {
	    if (WIFSIGNALED(pn->status)) {
		sig = WTERMSIG(pn->status);
		llen = strlen(sigmsg(sig));
		if (WCOREDUMP(pn->status))
		    llen += 14;
Src/jobs.c:206:	if (jobtab[i].stat & STAT_DONE)
Src/jobs.c:217:	     * STAT_DONE) will not stop these pids from being candidates
Src/jobs.c:279:    if ((sj->stat & STAT_DONE) || (!sj->procs && !sj->auxprocs)) {
Src/jobs.c:314:	       deletejob(sj, 0); */
Src/jobs.c:324:		deletejob(jn, 1);
Src/jobs.c:404:	if ((jobtab[i].stat & (STAT_CURSH|STAT_DONE)) ==
Src/jobs.c:619:	STAT_CHANGED | STAT_DONE;
Src/jobs.c:620:    if (jn->stat & (STAT_DONE|STAT_STOPPED)) {
Src/jobs.c:629:	(jn->stat & (STAT_SUBJOB | STAT_DONE)) == (STAT_SUBJOB | STAT_DONE)) {
Src/jobs.c:635:    if ((jn->stat & (STAT_DONE | STAT_STOPPED)) == STAT_STOPPED) {
Src/jobs.c:1216:	if (jn->stat & STAT_DONE) {
Src/jobs.c:1222:	    deletejob(jn, 0);
Src/jobs.c:1350:    if (jn->stat & STAT_DONE) {
Src/jobs.c:1356:	deletejob(jn, 0);
Src/jobs.c:1457:freejob(Job jn, int deleting)
Src/jobs.c:1484:	    deletejob(jobtab + jn->other, 0);
Src/jobs.c:1486:	    freejob(jobtab + jn->other, 0);
Src/jobs.c:1512:deletejob(Job jn, int disowning)
Src/jobs.c:1525:    freejob(jn, 1);
Src/jobs.c:1591:     * added, maybe STAT_DONE got set incorrectly.  This can happen if *
Src/jobs.c:1596:    jobtab[thisjob].stat &= ~STAT_DONE;
Src/jobs.c:1699:	       !(jn->stat & STAT_DONE) &&
Src/jobs.c:1737:	deletejob(jn, 0);
Src/jobs.c:1753:	deletejob(jn, 0);
Src/jobs.c:1796:	    freejob(jobtab + i, 0);
Src/jobs.c:1916:	deletejob(jobtab + thisjob, 0);
Src/jobs.c:2688:	        deletejob(jobtab + job, 1);
Src/jobs.c:2728:	    deletejob(jobtab + job, 1);
Investigating zsh job state behavior
Python
bash -lc cd /mnt/data/zsh_work/zsh && sed -n '249,460p' Src/signals.c && sed -n '580,650p' Src/jobs.c && sed -n '1180,1240p' Src/jobs.c && sed -n '1320,1370p' Src/jobs.c && sed -n '1440,1530p' Src/jobs.c
wait_for_processes(void)
{
    /* keep WAITING until no more child processes to reap */
    for (;;) {
	/* save the errno, since WAIT may change it */
	int old_errno = errno;
	int status;
	Job jn;
	Process pn;
	pid_t pid;
	pid_t *procsubpid = &cmdoutpid;
	int *procsubval = &cmdoutval;
	int cont = 0;
	struct execstack *es = exstack;

	/*
	 * Reap the child process.
	 * If we want usage information, we need to use wait3.
	 */
#if defined(HAVE_WAIT3) || defined(HAVE_WAITPID)
# ifdef WCONTINUED
# define WAITFLAGS (WNOHANG|WUNTRACED|WCONTINUED)
# else
# define WAITFLAGS (WNOHANG|WUNTRACED)
# endif
#endif
#ifdef HAVE_WAIT3
# ifdef HAVE_GETRUSAGE
	struct rusage ru;

	pid = wait3((void *)&status, WAITFLAGS, &ru);
# else
	pid = wait3((void *)&status, WAITFLAGS, NULL);
# endif
#else
# ifdef HAVE_WAITPID
	pid = waitpid(-1, &status, WAITFLAGS);
# else
	pid = wait(&status);
# endif
#endif

	if (!pid)  /* no more children to reap */
	    break;

	/* check if child returned was from process substitution */
	for (;;) {
	    if (pid == *procsubpid) {
		*procsubpid = 0;
		if (WIFSIGNALED(status))
		    *procsubval = (0200 | WTERMSIG(status));
		else
		    *procsubval = WEXITSTATUS(status);
		use_cmdoutval = 1;
		get_usage();
		cont = 1;
		break;
	    }
	    if (!es)
		break;
	    procsubpid = &es->cmdoutpid;
	    procsubval = &es->cmdoutval;
	    es = es->next;
	}
	if (cont)
	    continue;

	/* check for WAIT error */
	if (pid == -1) {
	    if (errno != ECHILD)
		zerr("wait failed: %e", errno);
	    /* WAIT changed errno, so restore the original */
	    errno = old_errno;
	    break;
	}

	/* This is necessary to be sure queueing_enabled > 0 when
	 * we enter printjob() from update_job(), so that we don't
	 * decrement to zero in should_report_time() and improperly
	 * run other handlers in the middle of processing this one */
	queue_signals();

	/*
	 * Find the process and job containing this pid and
	 * update it.
	 */
	if (findproc(pid, &jn, &pn, 0)) {
	    if (((jn->stat & STAT_BUILTIN) ||
		 (list_pipe &&
		  (thisjob == -1 ||
		   (jobtab[thisjob].stat & STAT_BUILTIN)))) &&
		WIFSTOPPED(status) && WSTOPSIG(status) == SIGTSTP) {
		killjb(jn, SIGCONT);
		zwarn("job can't be suspended");
	    } else {
#if defined(HAVE_WAIT3) && defined(HAVE_GETRUSAGE)
		zgettime_monotonic_if_available(&pn->endtime);
#ifdef WIFCONTINUED
		if (WIFCONTINUED(status))
		    pn->status = SP_RUNNING;
		else
#endif
		pn->status = status;
		pn->ti = ru;
#else
		update_process(pn, status);
#endif
		if (WIFEXITED(status) &&
		    pn->pid == jn->gleader &&
		    killpg(pn->pid, 0) == -1  &&
		    errno == ESRCH) {
		    if (last_attached_pgrp == jn->gleader &&
			!(jn->stat & STAT_NOSTTY)) {
			/*
			 * This PID was in control of the terminal;
			 * reclaim terminal now it has exited.
			 * It's still possible some future forked
			 * process of this job will become group
			 * leader, however.
			 */
			attachtty(mypgrp);
			adjustwinsize(0);
		    }
		    jn->gleader = 0;
		}
	    }
	    update_bg_job(jn, pid, status);
	    update_job(jn);
	} else if (findproc(pid, &jn, &pn, 1)) {
	    pn->status = status;
	    update_bg_job(jn, pid, status);
	    update_job(jn);
	} else {
	    /* If not found, update the shell record of time spent by
	     * children in sub processes anyway:  otherwise, this
	     * will get added on to the next found process that
	     * terminates.
	     */
	    get_usage();
	    update_bg_job(jn, pid, status);
	}

	unqueue_signals();
    }
}

/* the signal handler */
 
/**/
mod_export void
zhandler(int sig)
{
    sigset_t newmask, oldmask;

    last_signal = sig;
 
    sigfillset(&newmask);
    /* Block all signals temporarily           */
    oldmask = signal_block(newmask);
 
    /* Are we queueing signals now?      */
    if (queueing_enabled) {
        int temp_rear = ++queue_rear % MAX_QUEUE_SIZE;

	DPUTS(temp_rear == queue_front, "BUG: signal queue full");
	/* Make sure it's not full (extremely unlikely) */
        if (temp_rear != queue_front) {
	    /* ok, not full, so add to queue   */
            queue_rear = temp_rear;
	    /* save signal caught              */
            signal_queue[queue_rear] = sig;
	    /* save current signal mask        */
            signal_mask_queue[queue_rear] = oldmask;
        }
        return;
    }
 
    /* Reset signal mask, signal traps ok now */
    signal_setmask(oldmask);
 
    switch (sig) {
    case SIGCHLD:
	wait_for_processes();
        break;
 
    case SIGPIPE:
	if (!handletrap(SIGPIPE)) {
	    if (!interact)
		_exit(SIGPIPE);
	    else if (!isatty(SHTTY)) {
		stopmsg = 1;
		zexit(SIGPIPE, ZEXIT_SIGNAL);
	    }
	}
	break;

    case SIGHUP:
        if (!handletrap(SIGHUP)) {
            stopmsg = 1;
            zexit(SIGHUP, ZEXIT_SIGNAL);
        }
        break;
 
    case SIGINT:
        if (!handletrap(SIGINT)) {
	    if (isset(PRIVILEGED) &&
		isset(INTERACTIVE) && (noerrexit & NOERREXIT_SIGNAL))
		zexit(SIGINT, ZEXIT_SIGNAL);
            errflag |= ERRFLAG_INT;
            if (list_pipe || chline || simple_pline) {
                breaks = loops;
		inerrflush();
		     * it's death to call attachtty() just yet.  Mark the
		     * fact in the job, so that the attachtty() will be called
		     * when the job is finally deleted.
		     */
		    jn->stat |= STAT_ATTACH;
		}
		/* If we have `foo|while true; (( x++ )); done', and hit
		 * ^C, we have to stop the loop, too. */
		if (signalled && inforeground == 1 &&
		    ((val & ~0200) == SIGINT || (val & ~0200) == SIGQUIT)) {
		    if (!errbrk_saved) {
			errbrk_saved = 1;
			prev_breaks = breaks;
			prev_errflag = errflag;
		    }
		    breaks = loops;
		    errflag |= ERRFLAG_INT;
		    inerrflush();
		}
	    } else {
		attachtty(mypgrp);
		/* check window size and adjust if necessary */
		adjustwinsize(0);
	    }
	}
    } else if (list_pipe && signalled && inforeground == 1 &&
	       ((val & ~0200) == SIGINT || (val & ~0200) == SIGQUIT)) {
	if (!errbrk_saved) {
	    errbrk_saved = 1;
	    prev_breaks = breaks;
	    prev_errflag = errflag;
	}
	breaks = loops;
	errflag |= ERRFLAG_INT;
	inerrflush();
    }
    if (somestopped && jn->stat & STAT_SUPERJOB)
	return;
    jn->stat |= (somestopped) ? STAT_CHANGED | STAT_STOPPED :
	STAT_CHANGED | STAT_DONE;
    if (jn->stat & (STAT_DONE|STAT_STOPPED)) {
	/* This may be redundant with printjob() but note that inforeground
	 * is true here for STAT_CURSH jobs even when job != thisjob, most
	 * likely because thisjob = -1 from exec.c:execsimple() trickery.
	 * However, if we reset lastval here we break it for printjob().
	 */
	storepipestats(jn, inforeground, 0);
    }
    if (!inforeground &&
	(jn->stat & (STAT_SUBJOB | STAT_DONE)) == (STAT_SUBJOB | STAT_DONE)) {
	int su;

	if ((su = super_job(jn - jobtab)))
	    handle_sub(su, 0);
    }
    if ((jn->stat & (STAT_DONE | STAT_STOPPED)) == STAT_STOPPED) {
	prevjob = curjob;
	curjob = job;
    }
    if ((isset(NOTIFY) || job == thisjob) && (jn->stat & STAT_LOCKED)) {
	if (printjob(jn, !!isset(LONGLISTJOBS), 0) &&
	    zleactive)
	    zleentry(ZLE_CMD_REFRESH);
    }
    if (sigtrapped[SIGCHLD] && job != thisjob)
	dotrap(SIGCHLD);

    /* When MONITOR is set, the foreground process runs in a different *
     * process group from the shell, so the shell will not receive     *
     * terminal signals, therefore we pretend that the shell got       *
     * the signal too.                                                 */

    for (pn = jn->procs; pn; pn = pn->next) {
	if (jn->stat & STAT_SUPERJOB &&
	    jn->procs->status == SP_RUNNING && !pn->next)
	    pn->status = SP_RUNNING;
	if (pn->status != SP_RUNNING) {
	    if (WIFSIGNALED(pn->status)) {
		sig = WTERMSIG(pn->status);
		llen = strlen(sigmsg(sig));
		if (WCOREDUMP(pn->status))
		    llen += 14;
		if (llen > len)
		    len = llen;
		if (sig != SIGINT && sig != SIGPIPE)
		    sflag = 1;
		if (job == thisjob && sig == SIGINT)
		    doputnl = 1;
		if (isset(PRINTEXITVALUE) && isset(SHINSTDIN)) {
		    sflag = 1;
		    skip_print = 0;
		}
	    } else if (WIFSTOPPED(pn->status)) {
		sig = WSTOPSIG(pn->status);
		if ((int)strlen(sigmsg(sig)) > len)
		    len = strlen(sigmsg(sig));
		if (job == thisjob && sig == SIGTSTP)
		    doputnl = 1;
	    } else if (isset(PRINTEXITVALUE) && isset(SHINSTDIN) &&
		       WEXITSTATUS(pn->status)) {
		sflag = 1;
		skip_print = 0;
	    }
	}
    }

    if (skip_print) {
	if (jn->stat & STAT_DONE) {
	    /* This looks silly, but see update_job() */
	    if (synch <= 1)
		storepipestats(jn, job == thisjob, job == thisjob);
	    if (should_report_time(jn))
		dumptime(jn);
	    deletejob(jn, 0);
	    if (job == curjob) {
		curjob = prevjob;
		prevjob = job;
	    }
	    if (job == prevjob)
		setprevjob();
	}
	return 0;
    }

    /*
     * - Always print if called from jobs
     * - Otherwise, require MONITOR option ("jobbing") and some
     *   change of state
     * - also either the shell is interactive or this is synchronous.
     */
    if (synch == 2 ||
	((interact || synch) && jobbing &&
		unmetafy(txt, &txtlen);
		fwrite(txt, sizeof(char), txtlen, fout);
		if (pn->next)
		    fputs(" | ", fout);
	    }
	    putc('\n', fout);
	    fline = 0;
	}
	fflush(fout);
    } else if (doputnl && interact && !synch) {
	doneprint = 1;
	putc('\n', fout);
	fflush(fout);
    }

    /* print "(pwd now: foo)" messages: with (lng & 4) we are printing
     * the directory where the job is running, otherwise the current directory
     */

    if ((lng & 4) || (interact && job == thisjob &&
		      jn->pwd && strcmp(jn->pwd, pwd))) {
	doneprint = 1;
	fprintf(fout, "(pwd %s: ", (lng & 4) ? "" : "now");
	fprintdir(((lng & 4) && jn->pwd) ? jn->pwd : pwd, fout);
	fprintf(fout, ")\n");
	fflush(fout);
    }

    /* delete job if done */

    if (jn->stat & STAT_DONE) {
	/* This looks silly, but see update_job() */
	if (synch <= 1)
	    storepipestats(jn, job == thisjob, job == thisjob);
	if (should_report_time(jn))
	    dumptime(jn);
	deletejob(jn, 0);
	if (job == curjob) {
	    curjob = prevjob;
	    prevjob = job;
	}
	if (job == prevjob)
	    setprevjob();
    } else
	jn->stat &= ~STAT_CHANGED;

    return doneprint;
}

/* Add a file to be deleted or fd to be closed to the current job */


/**/
void
cleanfilelists(void)
{
    int i;

    DPUTS(shell_exiting >= 0, "BUG: cleanfilelists() before exit");
 
    for (i = 1; i <= maxjob; i++) {
	deletefilelist(jobtab[i].filelist, 0);
	jobtab[i].filelist = 0;
    }
}

/**/
void
freejob(Job jn, int deleting)
{
    struct process *pn, *nx;

    pn = jn->procs;
    jn->procs = NULL;
    for (; pn; pn = nx) {
	nx = pn->next;
	zfree(pn, sizeof(struct process));
    }

    pn = jn->auxprocs;
    jn->auxprocs = NULL;
    for (; pn; pn = nx) {
	nx = pn->next;
	zfree(pn, sizeof(struct process));
    }

    if (jn->ty)
	zfree(jn->ty, sizeof(struct ttyinfo));
    if (jn->pwd)
	zsfree(jn->pwd);
    jn->pwd = NULL;
    if (jn->stat & STAT_WASSUPER) {
	/* careful in case we shrink and move the job table */
	int job = jn - jobtab;
	if (deleting)
	    deletejob(jobtab + jn->other, 0);
	else
	    freejob(jobtab + jn->other, 0);
	jn = jobtab + job;
    }
    jn->gleader = jn->other = 0;
    jn->stat = jn->stty_in_env = 0;
    jn->filelist = NULL;
    jn->ty = NULL;

    /* Find the new highest job number. */
    if (maxjob == jn - jobtab) {
	while (maxjob && !(jobtab[maxjob].stat & STAT_INUSE))
	    maxjob--;
    }
}

/*
 * We are actually finished with this job, rather
 * than freeing it to make space.
 *
 * If "disowning" is set, files associated with the job are not
 * actually deleted --- and won't be as there is nothing left
 * to clear up.
 */

/**/
void
deletejob(Job jn, int disowning)
{
    deletefilelist(jn->filelist, disowning);
    if (jn->stat & STAT_ATTACH) {
	attachtty(mypgrp);
	adjustwinsize(0);
    }
    if (jn->stat & STAT_SUPERJOB) {
	Job jno = jobtab + jn->other;
	if (jno->stat & STAT_SUBJOB)
	    jno->stat |= STAT_SUBJOB_ORPHANED;
    }

    freejob(jn, 1);
}

/*
 * Add a process to the current job.
 * The third argument is 1 if we are adding a process which is not
Rethinking job cleanup and counting in zsh

It seems like printjob deletes finished jobs only when they run, and STAT_DONE jobs won’t delete unless NOTIFY is immediately set. If NOTIFY is unset, jobs stay in the table until printed, meaning #jobstates still counts them. To avoid this in a loop, I'll need a workaround to ensure finished jobs are cleaned up promptly without relying on command substitution or pipelines for counting.

Testing job cleanup with redirection in zsh

To clean up finished jobs, I might try redirecting jobs output to a temp file. Redirection could happen in the current shell environment, updating the job table and removing done jobs. I’ll read the

Evaluating job counting and cleanup options

I’m thinking through whether to use jobstates for job tracking, though that's tied to the zsh/parameter module, which I may need to load. The jobs -p command doesn’t have a direct count option, so I could use a zsh array, but then it’d fall into the command substitution trap. Maybe reading with a top-level jobs -r would help count running jobs, but it's tricky whether it'll clean up finished ones as desired.

Inspecting zsh job management functions
Python
bash -lc cd /mnt/data/zsh_work/zsh && grep -n "scanjobs" -n Src/jobs.c Src/*.h | head -20 && sed -n '180,230p' Src/jobs.c && sed -n '2440,2495p' Src/jobs.c
Src/jobs.c:1993:scanjobs(void)
Src/jobs.c:2478:	scanjobs();
    }

    if (jn->stat & STAT_SUPERJOB)
	makerunning(jobtab + jn->other);
}

/* Find process and job associated with pid.         *
 * Return 1 if search was successful, else return 0. */

/**/
int
findproc(pid_t pid, Job *jptr, Process *pptr, int aux)
{
    Process pn;
    int i;

    *jptr = NULL;
    *pptr = NULL;
    for (i = 1; i <= maxjob; i++)
    {
	/*
	 * We are only interested in jobs with processes still
	 * marked as live.  Careful in case there's an identical
	 * process number in a job we haven't quite got around
	 * to deleting.
	 */
	if (jobtab[i].stat & STAT_DONE)
	    continue;

	for (pn = aux ? jobtab[i].auxprocs : jobtab[i].procs;
	     pn; pn = pn->next)
	{
	    /*
	     * Make sure we match a process that's still running.
	     *
	     * When a job contains two pids, one terminated pid and one
	     * running pid, then the condition (jobtab[i].stat &
	     * STAT_DONE) will not stop these pids from being candidates
	     * for the findproc result (which is supposed to be a
	     * RUNNING pid), and if the terminated pid is an identical
	     * process number for the pid identifying the running
	     * process we are trying to find (after pid number
	     * wrapping), then we need to avoid returning the terminated
	     * pid, otherwise the shell would block and wait forever for
	     * the termination of the process which pid we were supposed
	     * to return in a different job.
	     */
	    if (pn->pid == pid) {
		*pptr = pn;
		*jptr = jobtab + i;
		if (pn->status == SP_RUNNING) 
	memset(hackzero + len, 0, hackspace - len);
#endif

#ifdef HAVE_PRCTL
	/* try to change /proc/$$/comm which will *
	 * be used when checking with "ps -e"  */
#include <sys/prctl.h>
	prctl(PR_SET_NAME, *argv);
#endif
	unqueue_signals();
	return 0;
    }

    if (func == BIN_JOBS) {
	lng = (OPT_ISSET(ops,'l')) ? 1 : (OPT_ISSET(ops,'p')) ? 2 : 0;
	if (OPT_ISSET(ops,'d'))
	    lng |= 4;
    } else {
	lng = !!isset(LONGLISTJOBS);
    }

    if ((func == BIN_FG || func == BIN_BG) && !jobbing) {
	/* oops... maybe bg and fg should have been disabled? */
	zwarnnam(name, "no job control in this shell.");
	return 1;
    }

    queue_signals();
    /*
     * In case any processes changed state recently, wait for them.
     * This updates stopped processes (but we should have been
     * signalled about those, up to inevitable races), and also
     * continued processes if that feature is available.
     */
    wait_for_processes();

    /* If necessary, update job table. */
    if (unset(NOTIFY))
	scanjobs();

    if (func != BIN_JOBS || isset(MONITOR) || !oldmaxjob)
	setcurjob();

    if (func == BIN_JOBS)
        /* If you immediately type "exit" after "jobs", this      *
         * will prevent zexit from complaining about stopped jobs */
	stopmsg = 2;
    if (!*argv) {
	/* This block handles all of the default cases (no arguments).  bg,
	fg and disown act on the current job, and jobs and wait act on all the
	jobs. */
 	if (func == BIN_FG || func == BIN_BG || func == BIN_DISOWN) {
	    /* W.r.t. the above comment, we'd better have a current job at this
	    point or else. */
	    if (curjob == -1 || (jobtab[curjob].stat & STAT_NOPRINT)) {
		zwarnnam(name, "no current job");
Analyzing zsh job states and management
Python
bash -lc cd /mnt/data/zsh_work/zsh && sed -n '1990,2045p' Src/jobs.c
 
/**/
void
scanjobs(void)
{
    int i;
 
    for (i = 1; i <= maxjob; i++)
        if (jobtab[i].stat & STAT_CHANGED)
            printjob(jobtab + i, !!isset(LONGLISTJOBS), 1);
}

/**** job control builtins ****/

/* This simple function indicates whether or not s may represent      *
 * a number.  It returns true iff s consists purely of digits and     *
 * minuses.  Note that minus may appear more than once.               */

/**/
static int
isanum(char *s)
{
    if (*s == '\0')
	return 0;
    while (*s == '-' || idigit(*s))
	s++;
    return *s == '\0';
}

/* Make sure we have a suitable current and previous job set. */

/**/
static void
setcurjob(void)
{
    if (curjob == thisjob ||
	(curjob != -1 && !(jobtab[curjob].stat & STAT_INUSE))) {
	curjob = prevjob;
	setprevjob();
	if (curjob == thisjob ||
	    (curjob != -1 && !((jobtab[curjob].stat & STAT_INUSE) &&
			       curjob != thisjob))) {
	    curjob = prevjob;
	    setprevjob();
	}
    }
}

/* Find the job table for reporting jobs */

/**/
mod_export void
selectjobtab(Job *jtabp, int *jmaxp)
{
    if (oldjobtab)
    {
Investigating zsh job management alternatives
Python
bash -lc cd /mnt/data/zsh_work/zsh && sed -n '2045,2065p' Src/jobs.c
    {
	/* In subshell --- use saved job table to report */
	*jtabp = oldjobtab;
	*jmaxp = oldmaxjob;
    }
    else
    {
	/* Use main job table */
	*jtabp = jobtab;
	*jmaxp = maxjob;
    }
}

/* Convert a job specifier ("%%", "%1", "%foo", "%?bar?", etc.) *
 * to a job number.                                             */

/**/
mod_export int
getjob(const char *s, const char *prog)
{
    int jobnum, returnval, mymaxjob;
Considering job completion output handling

If I run jobs > /dev/null, it triggers scanjobs, which deletes completed jobs, but could they still print to redirected stdout? It looks like interactive notifications usually go to the terminal, but with redirection, they might go to shout. I’ll need to confirm if redirection affects this, as it might swallow output unexpectedly. Testing should help.

Deciding on job status output handling

I’m considering jobs -r >/dev/null to avoid extra output, but it still triggers scanjobs and could print job notifications. If I use jobs -p >/dev/null, the impact could be similar. I might need to disable notifications or redirect them to /dev/null or another file. Also, it’s worth checking if shout uses stderr.

Searching for shout output behavior
Python
bash -lc cd /mnt/data/zsh_work/zsh && grep -R "FILE \*shout\|shout =\|fdopen.*shout" -n Src | sed -n '1,100p'
Src/Zle/zle_main.c:1689:    FILE *oshout = NULL;
Src/Zle/zle_main.c:1815:	oshout = shout;
Src/Zle/zle_main.c:1850:	shout = oshout;
Src/builtin.c:6425:    FILE *oshout = NULL;
Src/builtin.c:6469:		    oshout = shout;
Src/builtin.c:6628:		    shout = oshout;
Src/builtin.c:6741:		shout = oshout;
Src/exec.c:1165:    shout = NULL;
Src/init.c:70:mod_export FILE *shout;
Src/init.c:616:	shout = 0;
Src/init.c:725:	shout = stderr;
Src/init.c:735:    shout = fdopen(SHTTY, "w");
Investigating job notifications and output redirection behavior
Python
bash -lc cd /mnt/data/zsh_work/zsh && sed -n '1125,1180p' Src/exec.c && sed -n '6400,6485p' Src/builtin.c | sed -n '1,100p'
	    }
	}
	else if (!jobtab[thisjob].gleader ||
		 setpgrp(0L, jobtab[thisjob].gleader) == -1) {
	    /*
	     * This is the standard point at which a newly started
	     * process gets put into the foreground by taking over
	     * the terminal.  Note that in normal circumstances we do
	     * this only from the process itself.  This only works if
	     * we are still ignoring SIGTTOU at this point; in this
	     * case ignoring the signal has the special effect that
	     * the operation is allowed to work (in addition to not
	     * causing the shell to be suspended).
	     */
	    jobtab[thisjob].gleader = getpid();
	    if (list_pipe_job != thisjob &&
		!jobtab[list_pipe_job].gleader)
		jobtab[list_pipe_job].gleader = jobtab[thisjob].gleader;
	    setpgrp(0L, jobtab[thisjob].gleader);
	    if (!(flags & ESUB_ASYNC)) {
		attachtty(jobtab[thisjob].gleader);
		if (retp) {
		    retp->gleader = jobtab[thisjob].gleader;
		    if (list_pipe_job != thisjob)
			retp->list_pipe_job = list_pipe_job;
		}
	    }
	}
    }
    if (!(flags & ESUB_FAKE))
	subsh = 1;
    /*
     * Increment the visible parameter ZSH_SUBSHELL even if this
     * is a fake subshell because we are exec'ing at the end.
     * Logically this should be equivalent to a real subshell so
     * we don't hang out the dirty washing.
     */
    zsh_subshell++;
    if ((flags & ESUB_REVERTPGRP) && getpid() == mypgrp)
	release_pgrp();
    shout = NULL;
    if (flags & ESUB_NOMONITOR) {
	/*
	 * Allowing any form of interactive signalling here is
	 * actively harmful as we are in a context where there is no
	 * control over the process.
	 */
	signal_ignore(SIGTTOU);
	signal_ignore(SIGTTIN);
	signal_ignore(SIGTSTP);
    } else if (!job_control_ok) {
	/*
	 * If this process is not going to be doing job control,
	 * we don't want to do special things with the corresponding
	 * signals.  If it is, we need to keep the special behaviour:
	 * see note about attachtty() above.
    return eval(argv);
}

static char *zbuf;
static int readfd;

/* Read a character from readfd, or from the buffer zbuf.  Return EOF on end of
file/buffer. */

/* read: get a line of input, or (for compctl functions) return some *
 * useful data about the state of the editing line.  The -E and -e   *
 * options mean that the result should be sent to stdout.  -e means, *
 * in addition, that the result should not actually be assigned to   *
 * the specified parameters.                                         */

/**/
int
bin_read(char *name, char **args, Options ops, UNUSED(int func))
{
    char *reply, *readpmpt;
    int bsiz, c = 0, gotnl = 0, al = 0, first, nchars = 1, bslash, keys = 0;
    int haso = 0;	/* true if /dev/tty has been opened specially */
    int isem = !strcmp(term, "emacs"), izle = zleactive;
    char *buf, *bptr, *firstarg, *zbuforig;
    LinkList readll = newlinklist();
    FILE *oshout = NULL;
    int readchar = -1, val, resettty = 0;
    struct ttyinfo saveti;
    char d;
    long izle_timeout = 0;
#ifdef MULTIBYTE_SUPPORT
    wchar_t delim = L'\n', wc;
    int rawbyte = 0;
    mbstate_t mbs;
    char *laststart;
    size_t ret;
#else
    int delim = '\n';
#endif

    if (OPT_HASARG(ops,c='k')) {
	char *eptr, *optarg = OPT_ARG(ops,c);
	nchars = (int)zstrtol(optarg, &eptr, 10);
	if (*eptr) {
	    zwarnnam(name, "number expected after -%c: %s", c, optarg);
	    return 1;
	}
    }
    /* This `*args++ : *args' looks a bit weird, but it works around a bug
     * in gcc-2.8.1 under DU 4.0. */
    firstarg = (*args && **args == '?' ? *args++ : *args);
    reply = *args ? *args++ : OPT_ISSET(ops,'A') ? "reply" : "REPLY";

    if (OPT_ISSET(ops,'A') && *args) {
	zwarnnam(name, "only one array argument allowed");
	return 1;
    }

    /* handle compctl case */
    if(OPT_ISSET(ops,'l') || OPT_ISSET(ops,'c'))
	return compctlreadptr(name, args, ops, reply);

    if ((OPT_ISSET(ops,'k') || OPT_ISSET(ops,'q')) &&
	!OPT_ISSET(ops,'u') && !OPT_ISSET(ops,'p')) {
	if (!zleactive) {
	    if (SHTTY == -1) {
		/* need to open /dev/tty specially */
		if ((SHTTY = open("/dev/tty", O_RDWR|O_NOCTTY)) != -1) {
		    haso = 1;
		    oshout = shout;
		    init_shout();
		}
	    } else if (!shout) {
		/* We need an output FILE* on the tty */
		init_shout();
	    }
	    /* We should have a SHTTY opened by now. */
	    if (SHTTY == -1) {
		/* Unfortunately, we didn't. */
		fprintf(stderr, "not interactive and can't open terminal\n");
		fflush(stderr);
		return 1;
	    }
	    if (unset(INTERACTIVE))
		gettyinfo(&shttyinfo);
	    /* attach to the tty */
Testing zsh redirection behavior

I’m considering compiling zsh to test how redirection works and how different function variants behave. First, I should check the dependencies, starting with Util/preconfig. This will help me understand whether I need to tweak anything or prepare before jumping into the test.

Listing dependencies for zsh compilation
Python
bash -lc cd /mnt/data/zsh_work/zsh && ls Util | sed -n '1,80p' && command -v autoconf automake yodl gcc make sed awk
check-tmux-state
check_exports
difflog.pl
helpfiles
mkdisttree.sh
preconfig
printdefines
reporter
zsh-merge-changelog
ztst-ftplugin.vim
ztst-syntax.vim
zyodl.vim
/usr/bin/autoconf
/usr/bin/automake
/usr/bin/gcc
/usr/bin/make
/usr/bin/sed
/usr/bin/awk
Testing zsh behavior with automation

I need to decide whether I should run Util/preconfig and configure it, but it might not be essential. To test the behavior, I’ll compile both the current and older zsh versions. For interactive job control, I can use zpty or expect, or maybe run zsh -fic '...'. But would that support job control? Using script or script -q -c could help allocate a pseudo-tty, if it’s available—I'll check if the container includes it.

Checking for script and expect utilities
Python
bash -lc command -v script || true; command -v expect || true
/usr/bin/script
Citing sources for the zsh documentation

I can compile for testing, but I think I have time. I need to make sure my explanation cites the right sources. Since this involves facts from the web, I'll use the official zsh release notes, manuals, and probably even StackOverflow. It's better to stick to the official docs unless I really need other sources, like the NixOS manual. I should also make sure to provide accurate references about job control and pipeline behavior—perhaps even the SourceForge docs. I'll need to open the docs page.

Ensuring citation of official zsh and NixOS docs
Checking official documentation for job control

The official zsh documentation online for version 5.9 might not fully cover the job control features related to builtins in subshells. I’ll need to check the builtins documentation to confirm if that’s included, and make sure it aligns with what I’m testing.

Opening builtins documentation

I need to open the built-ins documentation to check for the details I'm looking for regarding job control and subshells. This will help clarify the information I need to complete my task and ensure that I'm working with up-to-date and accurate sources.

Searching for open built-in resources
Searching for open builtins resources
Citing release notes and referencing job state docs

I need to cite a specific line from the Zsh release notes (line 63). I'll make sure to open the official release notes above that line and then include the relevant citation. Additionally, I may need to check the module parameter documentation to clarify job states for accuracy.

Citing zsh release notes and module parameter docs
Finding job states documentation

I need to locate the lines related to job states in the documentation. This will help clarify how job control is handled in a particular context. I'll focus on pulling those specific parts for reference to ensure I'm getting the correct information.

Searching for jobstates lines
Working with test variants and the uploaded source

I need to test some variants, and compiling could help. I’m thinking I might use the uploaded source to check the current docs and decide if I need citations. It’s tricky without citations for the local source, but I can say "the uploaded Zsh tree’s docs match this." For trust, I’ll cite official sources too.

Compiling Zsh from source
Python
bash -lc cd /mnt/data/zsh_work/zsh && ./Util/preconfig > /tmp/zsh_preconfig.log 2>&1 && echo preconfig_done && ./configure --prefix=/mnt/data/zsh_work/install > /tmp/zsh_configure.log 2>&1 && echo configure_done && make -j2 > /tmp/zsh_make.log 2>&1 && echo make_done
preconfig_done


Command 'bash -lc 'cd /mnt/data/zsh_work/zsh && ./Util/preconfig > /tmp/zsh_preconfig.log 2>&1 && echo preconfig_done && ./configure --prefix=/mnt/data/zsh_work/install > /tmp/zsh_configure.log 2>&1 && echo configure_done && make -j2 > /tmp/zsh_make.log 2>&1 && echo make_done'' failed with status 255.
Inspecting configure log

I need to check the configure log. This might give me more insight into the current state of the configuration, especially if things aren't behaving as expected. I'll check for any unusual entries or errors. It seems crucial to resolve any issues from this step next.

Inspecting zsh configuration logs
Python
bash -lc tail -n 80 /tmp/zsh_configure.log || true; tail -n 80 /tmp/zsh_preconfig.log || true
checking for sys/param.h... yes
checking for size_t... yes
checking for working alloca.h... yes
checking for alloca... yes
checking if the compiler supports union initialisation... yes
checking if the compiler supports variable-length arrays... yes
checking whether make sets $(MAKE)... yes
checking for a BSD-compatible install... /usr/bin/install -c
checking for gawk... no
checking for mawk... mawk
checking whether ln works... yes
checking whether ln -s works... yes
checking for grep that handles long lines and -e... /usr/bin/grep
checking for egrep... /usr/bin/grep -E
checking for yodl... no
checking for texi2dvi... texi2dvi
checking for texi2pdf... texi2pdf
checking for texi2any... texi2any
checking for dirent.h that defines DIR... yes
checking for library containing opendir... none required
checking whether stat file-mode macros are broken... no
checking for sys/wait.h that is POSIX.1 compatible... yes
checking for sys/time.h... yes
checking for sys/times.h... yes
checking for sys/select.h... yes
checking for termcap.h... no
checking for termio.h... yes
checking for termios.h... yes
checking for sys/param.h... (cached) yes
checking for sys/filio.h... no
checking for limits.h... yes
checking for fcntl.h... yes
checking for libc.h... no
checking for sys/utsname.h... yes
checking for sys/resource.h... yes
checking for sys/sysctl.h... no
checking for sys/random.h... yes
checking for locale.h... yes
checking for errno.h... yes
checking for stdio.h... (cached) yes
checking for stdarg.h... yes
checking for varargs.h... no
checking for stdlib.h... (cached) yes
checking for unistd.h... (cached) yes
checking for sys/capability.h... no
checking for utmp.h... yes
checking for utmpx.h... yes
checking for sys/types.h... (cached) yes
checking for pwd.h... yes
checking for grp.h... yes
checking for poll.h... yes
checking for sys/mman.h... yes
checking for netinet/in_systm.h... yes
checking for langinfo.h... yes
checking for wchar.h... yes
checking for stddef.h... yes
checking for sys/stropts.h... no
checking for iconv.h... yes
checking for ncurses.h... no
checking for ncursesw/ncurses.h... no
checking for ncurses/ncurses.h... no
checking for dlfcn.h... yes
checking for dl.h... no
checking for conflicts in sys/time.h and sys/select.h... no
checking TIOCGWINSZ in termios.h... no
checking TIOCGWINSZ in sys/ioctl.h... yes
checking for streams headers including struct winsize... no
checking for printf in -lc... yes
checking for pow in -lm... yes
checking for clock_gettime in -lrt... yes
checking if _XOPEN_SOURCE_EXTENDED should not be defined... no
checking for library containing tigetstr... no
checking for library containing tigetflag... no
checking for library containing tgetent... no
configure: error: in '/mnt/data/zsh_work/zsh':
configure: error: "No terminal handling library was found on your system.
This is probably a library called 'curses' or 'ncurses'.  You may
need to install a package called 'curses-devel' or 'ncurses-devel' on your
system."
See 'config.log' for more details
cd . && ./.preconfig
Checking ncurses and tinfo libraries
Python
bash -lc ldconfig -p 2>/dev/null | grep -E 'ncurses|tinfo|termcap' | head -50 || true; ls /usr/lib/x86_64-linux-gnu/libtinfo* /usr/lib/x86_64-linux-gnu/libncurses* 2>/dev/null | sed -n '1,30p'
	libtinfo.so.6 (libc6,x86-64) => /lib/x86_64-linux-gnu/libtinfo.so.6
	libncursesw.so.6 (libc6,x86-64) => /lib/x86_64-linux-gnu/libncursesw.so.6
	libncurses.so.6 (libc6,x86-64) => /lib/x86_64-linux-gnu/libncurses.so.6
/usr/lib/x86_64-linux-gnu/libncurses.so.6
/usr/lib/x86_64-linux-gnu/libncurses.so.6.5
/usr/lib/x86_64-linux-gnu/libncursesw.so.6
/usr/lib/x86_64-linux-gnu/libncursesw.so.6.5
/usr/lib/x86_64-linux-gnu/libtinfo.so.6
/usr/lib/x86_64-linux-gnu/libtinfo.so.6.5
Checking configuration for termcap

It seems I'm facing a problem with missing .so symlink headers, and I'm wondering if using apt could help—though the network’s disabled. Maybe I could compile, but I'll need to disable some flags like --disable-dynamic to bypass certain problems. I'll explore options for symlinks and flags, like ac_cv and LDFLAGS.

Checking configure options for term dependencies
Python
bash -lc cd /mnt/data/zsh_work/zsh && ./configure --help | grep -i term -C2 | sed -n '1,120p'
  --with-PACKAGE[=ARG]    use PACKAGE [ARG=yes]
  --without-PACKAGE       do not use PACKAGE (same as --with-PACKAGE=no)
  --with-term-lib=LIBS    search space-separated LIBS for terminal handling

Some influential environment variables:
Configuring and compiling with custom terminfo library
Python
bash -lc mkdir -p /mnt/data/zsh_work/lib && ln -sf /lib/x86_64-linux-gnu/libtinfo.so.6 /mnt/data/zsh_work/lib/libtinfo.so && cd /mnt/data/zsh_work/zsh && ./configure --prefix=/mnt/data/zsh_work/install --with-term-lib='tinfo' LDFLAGS='-L/mnt/data/zsh_work/lib' > /tmp/zsh_configure.log 2>&1 && echo configure_done && make -j2 > /tmp/zsh_make.log 2>&1 && echo make_done
Command failed because it timed out.
Investigating build timeout

It looks like the command timed out after 300 seconds. This might be due to the build process taking too long or getting stuck on something, possibly related to the documentation or its format (yodl?). I’ll need to inspect the build log and see what’s happening to identify the issue.

Inspecting logs and process actions
Python
bash -lc tail -n 60 /tmp/zsh_configure.log; echo '---make---'; tail -n 80 /tmp/zsh_make.log; ps -ef | grep -E 'make|gcc|zsh' | grep -v grep | sed -n '1,80p'
checking for nice... yes
checking for gethostname... yes
checking for gethostbyname2... yes
checking for getipnodebyname... no
checking for inet_aton... yes
checking for inet_pton... yes
checking for inet_ntop... yes
checking for getlogin... yes
checking for getpwent... yes
checking for getpwnam... yes
checking for getpwuid... yes
checking for getgrgid... yes
checking for getgrnam... yes
checking for initgroups... yes
checking for setuid... yes
checking for seteuid... yes
checking for setreuid... yes
checking for setresuid... yes
checking for setsid... yes
checking for setgid... yes
checking for setegid... yes
checking for setregid... yes
checking for setresgid... yes
checking for memccpy... yes
checking for memcpy... yes
checking for memmove... yes
checking for strstr... yes
checking for strerror... yes
checking for strtoul... yes
checking for getrlimit... yes
checking for getrusage... yes
checking for setlocale... yes
checking for isblank... yes
checking for iswblank... yes
checking for uname... yes
checking for signgam... yes
checking for tgamma... yes
checking for log2... yes
checking for scalbn... yes
checking for putenv... yes
checking for getenv... yes
checking for setenv... yes
checking for unsetenv... yes
checking for brk... yes
checking for sbrk... yes
checking for pathconf... yes
checking for sysconf... yes
checking for tgetent... yes
checking for tigetflag... yes
checking for tigetnum... yes
checking for tigetstr... yes
checking for setupterm... yes
checking for initscr... no
checking for resize_term... no
checking for getcchar... no
checking for setcchar... no
checking for waddwstr... no
checking for wget_wch... no
checking for win_wch... no
checking for use_default_colors... Terminated
---make---
tail: cannot open '/tmp/zsh_make.log' for reading: No such file or directory
Addressing configure timeout and options

It seems the configure process timed out, possibly due to the term tests or issues with the use_default_colors test on the linked library. We could increase the timeout to 600 seconds or skip some tests using cache flags. Alternatively, we might disable curses or use a package manager, though compiling is optional. Let’s test with adjustments and see if it helps.

Exploring configuration options

I’m considering running configure with --with-term-lib=tinfo and caching some function values. Maybe skipping zle could simplify things, or using a static build might help. I’ll also try setting CONFIG_SHELL=/bin/bash and disable dynamic linking or other options if needed. Let’s test with shorter configurations to see if this works.

Configuring Zsh with custom settings
Python
bash -lc cd /mnt/data/zsh_work/zsh && rm -f config.cache && env ac_cv_func_initscr=no ac_cv_func_resize_term=no ac_cv_func_getcchar=no ac_cv_func_setcchar=no ac_cv_func_waddwstr=no ac_cv_func_wget_wch=no ac_cv_func_win_wch=no ac_cv_func_use_default_colors=no ./configure --prefix=/mnt/data/zsh_work/install --with-term-lib='tinfo' LDFLAGS='-L/mnt/data/zsh_work/lib' > /tmp/zsh_configure2.log 2>&1 && echo configure_done && make -j4 > /tmp/zsh_make2.log 2>&1 && echo make_done
Command failed because it timed out.
Checking for timeout in make

It looks like the timeout might be happening again during the make process. I need to check the details of the timeout and see if it’s related to specific steps or settings. I’ll try adjusting settings or running it again to isolate the issue.

Checking make process and logs
Python
bash -lc tail -n 60 /tmp/zsh_configure2.log; echo '---make---'; tail -n 100 /tmp/zsh_make2.log 2>/dev/null || true; ps -ef | grep -E 'make|gcc|zsh|texi|config' | grep -v grep | sed -n '1,80p'
checking for getgrgid... yes
checking for getgrnam... yes
checking for initgroups... yes
checking for setuid... yes
checking for seteuid... yes
checking for setreuid... yes
checking for setresuid... yes
checking for setsid... yes
checking for setgid... yes
checking for setegid... yes
checking for setregid... yes
checking for setresgid... yes
checking for memccpy... yes
checking for memcpy... yes
checking for memmove... yes
checking for strstr... yes
checking for strerror... yes
checking for strtoul... yes
checking for getrlimit... yes
checking for getrusage... yes
checking for setlocale... yes
checking for isblank... yes
checking for iswblank... yes
checking for uname... yes
checking for signgam... yes
checking for tgamma... yes
checking for log2... yes
checking for scalbn... yes
checking for putenv... yes
checking for getenv... yes
checking for setenv... yes
checking for unsetenv... yes
checking for brk... yes
checking for sbrk... yes
checking for pathconf... yes
checking for sysconf... yes
checking for tgetent... yes
checking for tigetflag... yes
checking for tigetnum... yes
checking for tigetstr... yes
checking for setupterm... yes
checking for initscr... (cached) no
checking for resize_term... (cached) no
checking for getcchar... (cached) no
checking for setcchar... (cached) no
checking for waddwstr... (cached) no
checking for wget_wch... (cached) no
checking for win_wch... (cached) no
checking for use_default_colors... (cached) no
checking for nl_langinfo... yes
checking for erand48... yes
checking for open_memstream... yes
checking for posix_openpt... yes
checking for wctomb... yes
checking for iconv... (cached) yes
checking for grantpt... yes
checking for unlockpt... yes
checking for ptsname... yes
checking for htons... yes
checking for ntohs... ---make---
oai       1705  1629  0 01:19 ?        00:00:00 tini -- /opt/pyvenv-python-tool/bin/python -m uvicorn --host 0.0.0.0 --port 8080 --log-config /opt/python-tool/uvicorn_logging.config jupyter_server.app:app
oai       1843  1705  1 01:19 ?        00:00:03 /opt/pyvenv-python-tool/bin/python -m uvicorn --host 0.0.0.0 --port 8080 --log-config /opt/python-tool/uvicorn_logging.config jupyter_server.app:app
Investigating timeout during configure process

The configure process is timing out, possibly because it runs many checks slowly. The timeout might happen due to command limits or the container’s constraints. I'll try extending the timeout to 1000 seconds, though it may not be necessary. The issue could relate to unquoted command substitutions in a specific function, causing unexpected behavior, particularly in zsh. Let’s dig into those settings to ensure a more stable fix.

Exploring issues with command substitution and job control

Okay, so when I use $(echo ' 3'), the result might be a single word with spaces. It seems that test would handle " 3" correctly, but in zsh, unquoted command substitution might split on newlines, not spaces. Also, jobs may count the current job in a subshell, which could cause an off-by-one error. I'll keep digging into it.

Analyzing job handling issue with subshells and snapshots

It looks like the background jobs in the parent shell stay there even though waitpid(-1) is called in the subshell. The parent shell receives SIGCHLD and marks jobs as done but doesn't remove them immediately if NOTIFY is unset. This could explain why the job count keeps increasing—every new subshell snapshot includes those jobs. Maybe this has been a bug for a while?

Investigating behavior of jobs in a pipeline and Zsh changes

Before the change, jobs in a subshell or pipeline might not have shown parent jobs, but in a pipeline's left-hand side, it could still run in the "current shell." In Zsh 5.8, jobs | wc -l might have returned 0. I’ll check if NixOS 25.11 executed jobs in the parent shell. Time to research!

Searching for zsh background job behavior issues
Verifying jobstates behavior in interactive functions

Seems like the same user asked this question, with an answer suggesting $#jobstates is correct. But I'm wondering if $#jobstates decreases in an interactive function loop without calling jobs. I’ll need to verify this. The StackOverflow script counts active jobs without testing completion, so some jobstates might remain until updated. Time to search for more details!

Searching jobstate behavior for zsh job handling
Testing zsh installation via package manager

The search didn’t quite clarify things. Maybe I need to build or install something to test this. I’m wondering if I can use a package manager for this—maybe apt already has zsh installed. I’ll check with apt-cache policy zsh and see what comes up.

Checking zsh installation status
Python
bash -lc apt-cache policy zsh 2>/dev/null | sed -n '1,80p' || true; dpkg -l | grep '^ii.*zsh' || true
ii  python3-argcomplete                   3.6.2-1                              all          bash/zsh tab completion for argparse (for Python 3)
Checking zsh installation with apt

It seems like there’s no output when I use apt-cache, possibly because there’s no apt list available. I’m thinking of trying apt-get install zsh to see if I can install it, but I don’t have internet access right now. It’s worth trying the apt list command to check.

Installing Zsh with apt-get
Python
bash -lc apt-get update >/tmp/apt_update.log 2>&1 && apt-get install -y zsh >/tmp/apt_install_zsh.log 2>&1 && echo installed || (tail -n 80 /tmp/apt_update.log; tail -n 80 /tmp/apt_install_zsh.log 2>/dev/null; false)
Command failed because it timed out.
Considering network issue for apt

It looks like the apt command might not have worked due to no internet connection. I could further inspect the issue to see what’s going on, but without network access, it might be hard to make progress. Let me see if I can check or work around the problem.

Inspecting recent apt activity
Python
bash -lc tail -n 80 /tmp/apt_update.log 2>/dev/null || true; ps -ef | grep apt | grep -v grep || true
Ign:1 http://deb.debian.org/debian trixie InRelease
Ign:2 http://deb.debian.org/debian trixie-updates InRelease
Ign:3 http://deb.debian.org/debian-security trixie-security InRelease
Ign:1 http://deb.debian.org/debian trixie InRelease
Ign:3 http://deb.debian.org/debian-security trixie-security InRelease
Ign:2 http://deb.debian.org/debian trixie-updates InRelease
Ign:1 http://deb.debian.org/debian trixie InRelease
Ign:2 http://deb.debian.org/debian trixie-updates InRelease
Ign:3 http://deb.debian.org/debian-security trixie-security InRelease
Checking nix for possible solution

I’m wondering if I should try using nix as an alternative since apt isn’t working. I can check whether nix is installed or whether it can help with the task. I’ll quickly verify and see if it might be a viable option for my current task.

Checking availability of nix and nix-shell
Python
bash -lc command -v nix || true; command -v nix-shell || true
Exploring manual Zsh compilation options

It seems like manually compiling Zsh with minimal config could help speed things up, avoiding the slow tests. I’m wondering if I can bypass the configure step using a dist tarball instead. Could also download a binary, but since there's no internet, I’d need to work with the available source. I'll check the config log to diagnose any network-related issues, especially around the ntohs function testing that seems to be causing delays.

Inspecting zsh config.log for issues
Python
bash -lc cd /mnt/data/zsh_work/zsh && tail -n 120 config.log | sed -n '1,160p'
#define HAVE_STDDEF_H 1
#define HAVE_ICONV_H 1
#define HAVE_DLFCN_H 1
#define GWINSZ_IN_SYS_IOCTL 1
#define HAVE_LIBM 1
#define HAVE_LIBRT 1
#define HAVE_LIBDL 1
#define HAVE_ICONV 1
#define ICONV_CONST 
#define HAVE_OSPEED 1
#define MUST_DEFINE_OSPEED 1
#define HAVE_SYS_XATTR_H 1
#define LONG_IS_64_BIT 1
#define PRINTF_HAS_LLD 1
#define HAVE_STRUCT_STAT_ST_ATIM_TV_NSEC 1
#define HAVE_STRUCT_STAT_ST_MTIM_TV_NSEC 1
#define HAVE_STRUCT_STAT_ST_CTIM_TV_NSEC 1
#define HAVE_STRUCT_TIMEZONE 1
#define HAVE_STRUCT_TIMESPEC 1
#define HAVE_STRUCT_UTMP 1
#define HAVE_STRUCT_UTMPX 1
#define HAVE_STRUCT_UTMP_UT_HOST 1
#define HAVE_STRUCT_UTMPX_UT_HOST 1
#define HAVE_STRUCT_UTMPX_UT_TV 1
#define HAVE_STRUCT_DIRENT_D_INO 1
#define HAVE_STRUCT_SOCKADDR_IN6_SIN6_SCOPE_ID 1
#define USE_LOCAL_H_ERRNO 1
#define HAVE_STRFTIME 1
#define HAVE_STRPTIME 1
#define HAVE_MKTIME 1
#define HAVE_TIMELOCAL 1
#define HAVE_DIFFTIME 1
#define HAVE_GETTIMEOFDAY 1
#define HAVE_CLOCK_GETTIME 1
#define HAVE_SELECT 1
#define HAVE_POLL 1
#define HAVE_READLINK 1
#define HAVE_FCHDIR 1
#define HAVE_FTRUNCATE 1
#define HAVE_FSTAT 1
#define HAVE_LSTAT 1
#define HAVE_LCHOWN 1
#define HAVE_FCHOWN 1
#define HAVE_FCHMOD 1
#define HAVE_FSEEKO 1
#define HAVE_FTELLO 1
#define HAVE_MKFIFO 1
#define HAVE_MKSTEMP 1
#define HAVE_WAITPID 1
#define HAVE_WAIT3 1
#define HAVE_SIGQUEUE 1
#define HAVE_KILLPG 1
#define HAVE_SETPGID 1
#define HAVE_SETPGRP 1
#define HAVE_TCSETPGRP 1
#define HAVE_TCGETATTR 1
#define HAVE_NICE 1
#define HAVE_GETHOSTNAME 1
#define HAVE_GETHOSTBYNAME2 1
#define HAVE_INET_ATON 1
#define HAVE_INET_PTON 1
#define HAVE_INET_NTOP 1
#define HAVE_GETLOGIN 1
#define HAVE_GETPWENT 1
#define HAVE_GETPWNAM 1
#define HAVE_GETPWUID 1
#define HAVE_GETGRGID 1
#define HAVE_GETGRNAM 1
#define HAVE_INITGROUPS 1
#define HAVE_SETUID 1
#define HAVE_SETEUID 1
#define HAVE_SETREUID 1
#define HAVE_SETRESUID 1
#define HAVE_SETSID 1
#define HAVE_SETGID 1
#define HAVE_SETEGID 1
#define HAVE_SETREGID 1
#define HAVE_SETRESGID 1
#define HAVE_MEMCCPY 1
#define HAVE_MEMCPY 1
#define HAVE_MEMMOVE 1
#define HAVE_STRSTR 1
#define HAVE_STRERROR 1
#define HAVE_STRTOUL 1
#define HAVE_GETRLIMIT 1
#define HAVE_GETRUSAGE 1
#define HAVE_SETLOCALE 1
#define HAVE_ISBLANK 1
#define HAVE_ISWBLANK 1
#define HAVE_UNAME 1
#define HAVE_SIGNGAM 1
#define HAVE_TGAMMA 1
#define HAVE_LOG2 1
#define HAVE_SCALBN 1
#define HAVE_PUTENV 1
#define HAVE_GETENV 1
#define HAVE_SETENV 1
#define HAVE_UNSETENV 1
#define HAVE_BRK 1
#define HAVE_SBRK 1
#define HAVE_PATHCONF 1
#define HAVE_SYSCONF 1
#define HAVE_TGETENT 1
#define HAVE_TIGETFLAG 1
#define HAVE_TIGETNUM 1
#define HAVE_TIGETSTR 1
#define HAVE_SETUPTERM 1
#define HAVE_NL_LANGINFO 1
#define HAVE_ERAND48 1
#define HAVE_OPEN_MEMSTREAM 1
#define HAVE_POSIX_OPENPT 1
#define HAVE_WCTOMB 1
#define HAVE_ICONV 1
#define HAVE_GRANTPT 1
#define HAVE_UNLOCKPT 1
#define HAVE_PTSNAME 1
#define HAVE_HTONS 1

configure: caught signal 15
configure: exit 1
Exploring job states and possible solutions

The configuration ended due to a timeout. I could leverage cache for the remainder, but it's not absolutely necessary. Since we're looking at potential cleanup, handling running jobs with job states might be the way to go. I’ll consider using wait -n if Zsh supports it, though I need to double-check that.

Searching for `wait -n` support in zsh
Clarifying jobstates and filtering completed jobs

I found that jobstates might include completed jobs, but if statuses are updated, filtering helps focus on active (running or suspended) jobs. Some functions, like wait_for_processes, automatically handle the update when jobs finish. If the signal is queued during command substitution, it can cause a delay, but after a pause, the process should be handled. So I'll focus on non-done jobs when counting active ones.

Refining job count logic

The original command jobs | wc -l included done jobs because the subshell snapshot might not update correctly with parent status changes. It seems jobs should only count running or suspended jobs, excluding completed ones. A fix could be either cleaning up done jobs at the top level or filtering them out properly using jobstates.

Refining job state counting in Zsh

I need to make sure the syntax for handling the jobstates associative array is correct. The goal is to count the running or suspended jobs, excluding done values. For that, parameter expansion like (${(M)#${${(v)jobstates}%%:*}:#running|suspended}) should work, but I might need to enable EXTENDED_GLOB using setopt for pattern matching. Alternatively, I could simplify by filtering out the done states directly with something like:

job_state_names=( ${${(v)jobstates}%%:*} )
active_jobs=${#${job_state_names:#done}}
Refining job state handling and terminal notifications

In Zsh, using nested parameter expansion like:

states=( ${${(@v)jobstates}%%:*} )

works well to extract values. Then filtering out "done" jobs with:

states=( ${states:#done} )

removes them, leaving only running or suspended jobs. Regarding terminal notifications, running jobs >/dev/null might not suppress "Done" notifications since they're sent directly to the terminal, not via standard output or error, unless NOTIFY is unset before the prompt.

Handling job notifications and state updates

Using jobs > /dev/null may not silence "Done" notifications, as notifications are sent directly to the terminal, not via stdout or stderr. A better approach would be to avoid running jobs altogether, relying on SIGCHLD to mark jobs as

Handling job states while throttling background work

The script checks job states, filters out "done" jobs, and counts only active (running or suspended) jobs. This avoids using jobs directly, which might cause issues in subshell snapshots. If a job is suspended, it still occupies a slot, so it should count towards active jobs to prevent new work from starting indefinitely.

Considering job throttling options

I'm thinking of excluding "done" jobs and counting only "running" or "suspended" ones, but for throttling, including suspended jobs might be important. I could also track PIDs directly or use zselect to handle the job control more efficiently. Zsh doesn't officially support wait -n, so I’ll need to explore syswait.

Managing job states for background work throttling
Evaluating job status reporting in zsh

I’m considering using wait with no arguments, but that would wait for all jobs, which isn’t ideal. Zsh doesn’t support wait -n. I could use setopt notify to manage job reporting, but it's not recommended since it forces notifications. The most reliable fix might be filtering job states directly instead.

Explaining NixOS upgrade and zsh behavior change

It seems like the main change comes from zsh’s updated behavior in NixOS 26.05-pre, as opposed to NixOS 25.11. The new version lists jobs in subshells and pipelines differently. The job table snapshot behavior is now clearer, and it's impacting how job counts and reaping work. Maybe I should confirm the user’s specific zsh version to clarify further.

Searching NixOS release notes for zsh updates
Thought for 12m 32s

Done

Sources · 142