Skip to content

Harden the library, and test it under more shells - #4

Merged
inxilpro merged 1 commit into
mainfrom
claude/script-testing-hardening-dntd5q
Aug 20, 2026
Merged

inxilpro merged 1 commit into
mainfrom
claude/script-testing-hardening-dntd5q

Conversation

@inxilpro

Copy link
Copy Markdown
Contributor

Nine ways a build could break, found by going after them one at a time. Each one has an example that fails without its fix, and the suite went from 24 to 42.

The two that hung a build for ever

A chain killed from outside left no status behind. run watches for a status file and nothing was ever going to write one, so a chain that took a SIGKILL — from the OOM killer, from a timeout -s KILL, from anything — left the build waiting until somebody noticed. run now sees the process is gone, looks for the status once more to settle the race, and reports the chain as killed.

A chain that ignores SIGTERM held cancellation open. The wait between asking a chain to go and taking its process group down was unbounded. A chain gets GRACE polls, ten seconds at the default, and then the signal nothing can ignore. The bound applies on every shell, not only the ones with process groups: without it the bare wait at the end of cancelling was the thing that hung.

The rest

  • set -C in the calling script lost the output. The library rewrites one file every poll and noclobber refused, so a build that set it saw the first line and then a complaint a poll on stderr instead of its output.
  • A chain took the build script's stdin. Under job control a background job keeps the script's stdin, so a chain could eat what the script was going to read — on bash, mksh, ksh93 and zsh, and not on dash or busybox ash, which made it inconsistent as well as wrong. Chains read /dev/null.
  • mktemp failing was silent. Every path became a bare filename and the library wrote 1.log, 1.code and the rest into whatever directory the build ran from. It says so on stderr and stops.
  • A chain command could move the library's own bookkeeping. The chain's status trap used $_work and $n, in the chain's own shell, so chain "x" "for n in 1 2 3; ..." wrote the status to the wrong place and hung the run, and a command assigning code made a chain that worked report a failure. Everything internal is underscore-prefixed now, which also stops the library clobbering i, n, label and the rest in the calling script — a caveat the README no longer has to carry.
  • Grouped output glued the closing marker onto an unfinished line, so a chain whose last line had no newline read no newline here[!] chain exited 5.
  • "$@" with nothing behind it stopped a chain that had a label and no commands under posh.
  • A box drawing character in the source made the file unreadable to a strict shell in a C locale — yash will not read a script carrying a byte sequence the locale cannot make a character of, and a build container with no locale set is exactly where that happens. The sources are ASCII, comments included, the bar is written as its bytes in the one place it is needed, and make lint keeps it that way.

Shells

posh, bash --posix and zsh --emulate sh on Linux, and zsh on macOS. posh is Debian's policy-compliant shell and is the one that objected to "$@"; the two POSIX modes are what /bin/sh actually is on a lot of machines and differ from their own defaults in ways this library can feel. The harness can drive a shell that carries options now, which is what those two needed.

yash is not in the matrix. It found the locale bug and the library does run under it, but shellspec cannot inspect it — yash resolves [ and echo through PATH, which shellspec's inspection does not survive.

Simpler along the way

  • The chain's status comes from the shell's own exit status rather than a variable kept alongside it, so code, the break and the [ -ne 0 ] || all go.
  • Both reports share one function for the line a chain ends on, instead of the same two printfs written out in each branch.
  • A label and a pid never cross a process boundary, so they are variables rather than files. That also takes a cat per chain per poll out of the wait loop.
  • && chains whose failure would abort a set -e caller are if statements.

Also

run returns 137 for a killed chain and the report gains a [!] label killed line. GRACE is a new setting. chain with no label says so and returns 2. The README's opening example called chain without the label it needs, and its sample output named a chain the example never declared.

Checks

make lint and make bench pass. The suite passes under dash, bash, bash --posix, ksh93, mksh, zsh, zsh --emulate sh, posh and busybox ash — 42 examples, no failures.


Generated by Claude Code

Nine ways a build could break, each with an example that fails without the
fix:

- A chain killed from outside left no status behind, and `run` waited for
  one for ever. It notices the process is gone and reports the chain as
  killed instead.
- A chain that ignores SIGTERM held cancellation open for ever. It gets
  GRACE polls and then SIGKILL.
- A caller that set -C lost every streamed line after the first poll, and
  got a complaint a poll instead of output.
- A chain took the build script's stdin, on every shell with job control
  and on none of the ones without. It reads /dev/null now.
- mktemp failing left the library writing `1.log` and `1.code` into the
  working directory. It says so and stops.
- A chain command assigning to `n`, `code` or `_work` moved the file the
  chain reports itself through, which lost the status or hung the run.
  Everything internal is prefixed now, which also stops the library
  clobbering names in the calling script.
- Grouped output printed the closing marker onto the end of a last line
  that never got its newline.
- `"$@"` with nothing behind it stopped a chain with a label and no
  commands under posh.
- A box drawing character in the source made the file unreadable to a
  strict shell in the C locale. The sources are ASCII, and `make lint`
  keeps them that way.

The report gained a `killed` line, `GRACE` is a new setting, and the README
example called `chain` without the label it needs.

Simpler along the way: the chain's status comes from the shell's own exit
status rather than a variable it kept alongside; the two reports share one
function for the line a chain ends on; a label and a pid are variables
rather than files, which also takes a fork per chain per poll out of the
wait loop; `&&` chains whose failure would abort a set -e caller are `if`
statements.

Added to the matrix: posh, bash --posix and zsh --emulate sh on Linux, zsh
on macOS. The harness can drive a shell with options now. yash is not
there, because shellspec cannot inspect it, though the library does run
under it.
@inxilpro
inxilpro merged commit 0499502 into main Aug 20, 2026
30 checks passed
@inxilpro
inxilpro deleted the claude/script-testing-hardening-dntd5q branch August 20, 2026 11:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant