Case study

Case study: a SIGPIPE in Nix's gc-closure test

Nix's functional test gc-closure.sh can fail with exit status 141. Rewind VM failed it on the first run; the host passed it 200 times. We reported it as NixOS/nix#16546, and the fix below was merged as NixOS/nix#16547.

The test

Lines 14 to 16 of gc-closure.sh split a derivation's two outputs:

    input2=$(nix build -f dependencies2.nix input2_drv --no-link --print-out-paths)
    input2_out=$(printf "%s" "$input2" | head -n1)
    input2_out2=$(printf "%s" "$input2" | tail -n1)

Meson runs each functional test as bash -x -e -u -o pipefail, so if printf dies the pipeline fails and the test ends.

Run it

The derivation is the functional tests of Nix master 203f85b2, narrowed to this one test (flake). It fails on schedule 0:

$ rewind nix 'github:fzakaria/rewindvm?dir=examples/case-studies#nix-git-gc-closure'
rewind: packing 217 store paths for nix-functional-gc-closure-2.36pre20260912_203f85b2
...
+(gc-closure.sh:45) nix_gc_closure false
...
++(gc-closure.sh:14) nix build -f dependencies2.nix input2_drv --no-link --print-out-paths
...
+(gc-closure.sh:14) input2=$'/build/nix-test/main/gc-closure/store/ij8qwx0m6dvpmggqmq8zmhbaiw6ym8pg-dependencies-input-2\n/build/nix-test/main/gc-closure/store/hw6dm6...
++(gc-closure.sh:15) printf %s $'/build/nix-test/main/gc-closure/store/ij8qwx0m6dvpmggqmq8zmhbaiw6ym8pg-dependencies-input-2\n/build/nix-test/main/gc-closure/store/hw...
++(gc-closure.sh:15) head -n1
+(gc-closure.sh:15) input2_out=/build/nix-test/main/gc-closure/store/ij8qwx0m6dvpmggqmq8zmhbaiw6ym8pg-dependencies-input-2
...
1/1 main - nix-functional-tests:gc-closure FAIL             0.26s   (exit status 141 or signal 13 SIGPIPE)
...
rewind: run d6deb54e63ee7a3b exited:1 after 37507 steps, 1.436s virtual, 6.449s wall (poweroff)

$ rewind replay d6deb54e
identical: 9809 events over 37507 steps

input2_out got the right value, and the test still died of signal 13.

What died

The events around the failure, and the processes just before it:

$ rewind events d6deb54e --from 37240 --to 37340
     37250   359/359   fork() = 416
     37253   416/416   fork() = 417
     37256   416/416   fork() = 418
     37286   418/418   execve("/nix/store/2gfxiwls9hbgwdwcy43mprchwsq36mg6-coreutils-9.11/bin/head", ["head", "-n1"])
     37309   418/418   exit_group(head) exited:0
     37315   417/417   SIGPIPE code=0 addr=0x0
     37316   417/417   exit_group(bash) killed:SIGPIPE
     37318   416/416   SIGCHLD code=1 addr=0x0
     37319   416/416   exit_group(bash) exited:141
     37322   359/359   SIGCHLD code=1 addr=0x0
     37335   359/359   exit_group(bash) exited:141
     37339   358/358   close("anon_inode:[pidfd]")

$ rewind ps d6deb54e 37292
...
   359       /nix/store/10dxp0qxqxxsyiljrh2kp0xqhz6arhcx-bash-5.3p15/bin/bash -x -e -u -o pipefail gc-closure.sh
   416         /nix/store/10dxp0qxqxxsyiljrh2kp0xqhz6arhcx-bash-5.3p15/bin/bash -x -e -u -o pipefail gc-closure.sh (fork)
   417           /nix/store/10dxp0qxqxxsyiljrh2kp0xqhz6arhcx-bash-5.3p15/bin/bash -x -e -u -o pipefail gc-closure.sh (fork)
   418           head -n1

416 is the command substitution, 417 runs printf and 418 runs head. head exits 0, then 417 dies of SIGPIPE and the test exits 141.

Count the writes

rewind cat reads a file in a fork of the run at any step. /proc/<pid>/io counts each process's writes:

$ for step in 37305 37309 37315; do
>   echo "step $step"
>   rewind cat d6deb54e $step /proc/417/io | grep -E '^(wchar|syscw)' | sed 's/^/  417 /'
>   rewind cat d6deb54e $step /proc/418/io | grep -E '^(wchar|syscw)' | sed 's/^/  418 /'
> done
step 37305
  417 wchar: 224
  417 syscw: 1
  418 wchar: 30
  418 syscw: 1
step 37309
  417 wchar: 316
  417 syscw: 2
  418 wchar: 122
  418 syscw: 2
step 37315
  417 wchar: 316
  417 syscw: 3
rewind: /proc/418/io: no such file at this step

The first write of each is its bash -x trace line. By 37309 printf has written the first store path (92 bytes) and head has echoed it and exited. At 37315 printf's third write adds no bytes: the pipe has no reader left.

Root cause

bash line-buffers its own standard output, so printf writes one line per write call. On the host, with the same bash and the two paths from the run:

$ export X=$'/build/nix-test/main/gc-closure/store/ij8qwx0m6dvpmggqmq8zmhbaiw6ym8pg-dependencies-input-2\n/build/nix-test/main/gc-closure/store/hw6dm6rpynd7hmhl1mnp0z4m28lfbx4b-dependencies-input-2-out2'
$ strace -f -e trace=write -o /dev/stdout bash -c 'set -o pipefail; y=$(printf "%s" "$X" | cat)' | grep write
1281845 write(1, "/build/nix-test/main/gc-closure/"..., 92) = 92
1281845 write(1, "/build/nix-test/main/gc-closure/"..., 96) = 96

The buffering is set in shell_initialize in bash 5.3's shell.c. head -n1 exits after the first line. If it does so between the two writes, the second write raises SIGPIPE, pipefail makes the pipeline's status 141, and -e ends the test.

Who had the CPU

rewind threads prints which process held the CPU through each stretch of steps:

$ rewind threads d6deb54e --from 37284 --to 37320
     37284      37284       358/358  meson
     37285      37285       418/418  bash
     37286      37301       418/418  head
     37302      37306       417/417  bash
     37307      37307     kernel 11  ksoftirqd/0
     37308      37309       418/418  head
     37310      37310     kernel 11  ksoftirqd/0
     37311      37312       359/359  bash
     37313      37313       416/416  bash
     37314      37314     kernel 11  ksoftirqd/0
     37315      37316       417/417  bash
     37317      37317     kernel 11  ksoftirqd/0
     37318      37319       416/416  bash
     37320      37320     kernel 11  ksoftirqd/0

head blocks in read by 37301. printf makes its first write at 37302, which wakes head. head runs at 37308 and exits, and printf only gets the CPU back at 37315.

How often it fails

$ rewind check --all --schedules 256 'github:fzakaria/rewindvm?dir=examples/case-studies#nix-git-gc-closure'
schedule   0: exited:1      37507 steps    run d6deb54e63ee7a3b
schedule   1: exited:0      94453 steps  a50a5ab6d992  run 801b25958028b8e7
...
schedule  41: exited:1      43328 steps    run f3a81773fc0c51e5
...
schedule 214: exited:1      84959 steps    run efad69daaa21e8b5
...
schedule 0 failed; 254 of 256 perturbed schedules ended differently

schedule 1 passes where schedule 0 fails; narrowing the steps it perturbs
perturbing only steps 37303..37872 still ends differently
step 37871 decides it: a timer 49.9 µs late there makes the run pass

passing: run 9ab082205f1b42d3, schedule 1 over steps 37303..37872
failing: run 2b1a9ab1f5e313b8, schedule 1 over steps 37303..37871
the two are the same run until step 37871

where /nix/store/10dxp0qxqxxsyiljrh2kp0xqhz6arhcx-bash-5.3p15/bin/bash -x -e -u -o pipefail gc-closure.sh first behaves differently:
  both          74565   359/359   fork() = 534
  both          74568   534/534   fork() = 535
  both          74571   534/534   fork() = 536
  failing       74630   535/535   SIGPIPE code=0 addr=0x0
  failing       74631   535/535   exit_group(bash) killed:SIGPIPE
  failing       74633   534/534   SIGCHLD code=1 addr=0x0
  failing       74634   534/534   exit_group(bash) exited:141
  passing       74578   535/535   exit_group(bash) exited:0
  passing       74609   534/534   SIGCHLD code=1 addr=0x0
  passing       74610   534/534   exit_group(bash) exited:0
  passing       74612   359/359   SIGCHLD code=1 addr=0x0

open both in the desktop app: rewind open 2b1a9ab1f5e313b8 74630 --compare 9ab082205f1b42d3

Schedules 0, 41 and 214 fail; the other 254 pass. Here the unperturbed schedule happens to be the failing order. The test runs the pipeline three times, so the narrowed pair parts at a later call, step 74630.

Perturbing only from step 37253, where the pipeline forks printf, moves 62 of 64 schedules off the failing order:

$ rewind check --run d6deb54e --schedule-from 37253 --schedules 64 --all --no-narrow
schedule   0: exited:1      37507 steps    run d6deb54e63ee7a3b
schedule   1: exited:0      88436 steps  a50a5ab6d992  run 6ab3f5362d555a76
schedule   2: exited:0      88329 steps  a50a5ab6d992  run 682fe8225b49e35b
...
schedule  30: exited:1      37529 steps    run e416ba8b839efea7
...
schedule  36: exited:1      37536 steps    run 0326b5b03deeac5a
...
schedule  64: exited:0      88360 steps  a50a5ab6d992  run 47af11d7e52a9cc2
run d6deb54e63ee7a3b failed; 62 of 64 perturbed schedules ended differently

Schedules 30 and 36 fail the same way, in the same pipeline: head exits between printf's two writes, and printf dies of SIGPIPE at step 37330 and 37333.

Nix 2.35.2 from nixpkgs has the same line. It fails schedule 0 too, and 5 of its 1025 runs fail, all with SIGPIPE:

$ rewind check --all --schedules 1024 'github:fzakaria/rewindvm?dir=examples/case-studies#nix-gc-closure'
...
schedule   0: exited:1      36645 steps    run 923e38ffd8c63851
...
schedule 380: exited:1      42327 steps    run ecd1ae341b746262
...
schedule 598: exited:1      64405 steps    run ec597c4d82c5cf3f
...
schedule 843: exited:1      82981 steps    run e164c79070f367cd
...
schedule 894: exited:1      82957 steps    run 836489d5467dfbec
...
schedule 0 failed; 1020 of 1024 perturbed schedules ended differently

On the host it fails only when the writer is made the low-priority task on its CPU:

What ran on the host Failed
gc-closure from Nix master, 200 times in one build 0 of 200
line 15 in a loop, 16 CPUs 0 of 5000
line 15 in a loop, pinned to one CPU 0 of 5000
the same, with a busy loop sharing that CPU 0 of 20000
the same, with the printf subshell reniced to 19, pinned 1802 of 2000
the same, reniced, not pinned 1 of 2000

The fix

Give head its input without a pipe, so there is no writer to kill:

-    input2_out=$(printf "%s" "$input2" | head -n1)
+    input2_out=$(head -n1 <<< "$input2")

Line 16 needs no change: tail reads to the end. With the fix, all 65 schedules pass:

$ rewind check --all 'github:fzakaria/rewindvm?dir=examples/case-studies#nix-git-gc-closure-fixed'
schedule   0: exited:0      83722 steps  a50a5ab6d992  run e6deae8939c0328e
schedule   1: exited:0      94498 steps  a50a5ab6d992  run ea7dfcb0b2d8cdfe
...
0 of 64 perturbed schedules ended differently
same result under all 65 schedules

The recording

Both files are in the case-studies release:

$ rewind import https://github.com/fzakaria/rewindvm/releases/download/case-studies/nix-gc-closure-sigpipe-replayable.rwd
$ rewind replay d6deb54e
$ rewind shell d6deb54e <step>
$ rewind-app https://github.com/fzakaria/rewindvm/releases/download/case-studies/nix-gc-closure-sigpipe.rwd

The run ids above come from the derivations in the example flake, on nixpkgs b4fd65b1. Any change to a derivation gives a different set of runs.