Skip to content
VLSI Mentor

Perl Part II · Module 25 · Regression Automation

Collecting Results When Some Jobs Never Return

Every large regression is incomplete in some way. A job hangs and is killed, the farm refuses one, another never starts before the deadline, and one leaves a log that stops halfway through a line. The collect stage must still produce a report in which every planned run has a row, a verdict and a reason. This chapter builds that stage, and measures how far the quick versions are from the truth.

Advanced27 min readPerlPart IIRegression

Part II · Module 25 · Chapter 25.7 · Collecting Results When Some Jobs Never Return

1. The Problem

Chapters 25.4 to 25.6 launched runs, submitted them to a farm and waited for them. Some finished. Some did not, and each of those stopped in its own way. Now the runner has to say what happened to all of them.

The tempting approach is to look at the logs that exist and classify each one. That answers a different question — "what do the logs I can find say?" — and it gets the regression's real question wrong in two directions: runs that left no log disappear from the report, and logs that are not from this regression appear in it.

So the rule for this chapter is: the report has exactly one row for every planned run. Not for every log, not for every job, not for every directory. The plan is the list of questions; the collect stage must answer each one, and "this run has no verdict, and here is why" is a valid answer.

2. What the Runner Kept

The collector can only use evidence the earlier stages wrote down. Here is everything this module's runner keeps, and where each piece comes from:

FileWritten bySays
runs/plan.tsvexpansion (25.3), before anything runsevery run that should happen: id, test, seed
runs/jobs.tsvsubmission (25.5)every run the farm accepted, with its job id
runs/wait.tsvthe waiter (25.6)why the runner stopped waiting for each job
runs/<id>/sim.logthe job (25.4, 25.5)what the simulator printed
runs/<id>/statusthe job scriptthe simulator's exit status, and which regression wrote it
runs/<id>/donethe job script, lastthe job script reached its end
runs/REGRESSIONthe runnerthis regression's id

Two of these are worth noticing. wait.tsv exists because Chapter 25.6 insisted on recording a reason every time a job left the wait loop — "TIMEOUT", "ended without done file", "never started". That record is now the strongest evidence the collector has for the runs that did not finish, because it is the runner's account of its own actions. And status carries the regression id, so that a file from a previous regression can be recognised as such. Section 5 shows why that matters.

3. A Realistic Run Area

To test a collector, you need a run area where every kind of ending has happened at least once — and you need to know the right answer for each run before you start. This script builds one for a regression called r2, using the real simulator for every log:

Azvya Education Pvt. Ltd.VLSI Mentor
stage.pl
#!/usr/bin/perl
# stage.pl -- build the run area that Chapters 25.4 to 25.6 leave behind for
# regression r2, with every log produced by the real simulator. Each run ends
# in one of the ways those chapters measured.
use strict;
use warnings;
use File::Path qw(make_path remove_tree);
use Time::HiRes qw(sleep);

my $REGR = "r2";

# id, test, seed, extra args, how it ended
my @plan = (
    [qw(smoke_s1  smoke  1), "",          "finished"],
    [qw(corner_s1 corner 1), "",          "finished"],
    [qw(rand_s2   rand   2), "",          "finished"],
    [qw(rand_s3   rand   3), "",          "finished"],
    [qw(bogus_s1  bogus  1), "",          "finished"],
    [qw(stall_s1  stall  1), "",          "timeout"],        # the waiter killed it (TERM)
    [qw(stall_s2  stall  2), "",          "farm_kill"],      # the farm killed it (KILL)
    [qw(rand_s5   rand   5), "+n=400000", "farm_term"],      # the farm stopped it (TERM)
    [qw(smoke_s9  smoke  9), "",          "never_started"],
    [qw(rand_s4   rand   4), "",          "not_submitted"],
);

sub sim {                                  # run the bench into a log; maybe stop it
    my ($log, $sig, @args) = @_;
    my $pid = fork // die "fork: $!\n";
    if ($pid == 0) {
        open STDOUT, ">", $log or exit 126;
        open STDERR, ">&", \*STDOUT or exit 126;
        exec "bin/simv", @args;
        exit 127;
    }
    if ($sig) { sleep 0.8; kill $sig, $pid }
    waitpid($pid, 0);
    return $? >> 8;
}
sub put {
    my ($path, $text) = @_;
    open my $f, ">", $path or die "$path: $!\n";
    print {$f} $text;
    close $f;
}

system("bin/build") == 0 or die "build failed\n";
system("bin/simv +test=smoke > /dev/null");   # warm-up: the first launch is the slowest
remove_tree("runs");
make_path("runs");

# Yesterday (regression r1) used the same fixed seeds, so two of its run
# directories have today's names -- and one directory today's plan no longer has.
for my $old (["smoke_s9", 9], ["old_s7", 7]) {
    my ($id, $seed) = @$old;
    make_path("runs/$id");
    my $exit = sim("runs/$id/sim.log", undef, "+test=smoke", "+seed=$seed");
    put("runs/$id/status", "exit=$exit\nregr=r1\n");
    put("runs/$id/done", "");
}

my ($plan, $jobs, $wait) = ("", "", "");
my $job = 4100;
for my $p (@plan) {
    my ($id, $test, $seed, $extra, $how) = @$p;
    my @args = ("+test=$test", "+seed=$seed", $extra ? $extra : ());
    my $dir  = "runs/$id";
    make_path($dir);
    put("$dir/cmd.sh", "#!/bin/sh\ncd \"\$(dirname \"\$0\")/../..\" || exit 2\nexec bin/simv @args\n");
    $plan .= "$id\t$test\t$seed\n";
    next if $how eq "not_submitted";              # refused: no job, no log
    $jobs .= ++$job . "\t$id\n";
    if ($how eq "never_started") { $wait .= "$id\tREGRESSION DEADLINE: never started\n"; next }
    if ($how eq "finished") {
        my $exit = sim("$dir/sim.log", undef, @args);
        put("$dir/status", "exit=$exit\nregr=$REGR\n");
        put("$dir/done", "");
        $wait .= "$id\tfinished (done file)\n";
    } elsif ($how eq "timeout") {
        sim("$dir/sim.log", "TERM", @args);       # job.sh died too: no status, no done
        $wait .= "$id\tTIMEOUT: ran over 2s\n";
    } elsif ($how eq "farm_kill") {
        sim("$dir/sim.log", "KILL", @args);
        $wait .= "$id\tended without done file\n";
    } elsif ($how eq "farm_term") {
        sim("$dir/sim.log", "TERM", @args);
        $wait .= "$id\tended without done file\n";
    }
}
put("runs/plan.tsv", "run_id\ttest\tseed\n$plan");
put("runs/jobs.tsv", "job_id\trun_id\n$jobs");
put("runs/wait.tsv", "run_id\twhy\n$wait");
put("runs/REGRESSION", "$REGR\n");
print "staged regression $REGR: ", scalar(@plan), " planned runs\n";

Each run ends in a way an earlier chapter measured:

RunHow it endedThe right answer
smoke_s1, rand_s3finished, TEST PASSEDpass
corner_s1, rand_s2finished, TEST FAILEDfail
bogus_s1finished; the bench called $fatalfail (the bench refused the test)
stall_s1hung; the waiter killed it with TERMno verdict: timeout
stall_s2hung; the farm killed it with KILLno verdict
rand_s5long run; the farm stopped it with TERMno verdict — but its log already shows errors
smoke_s9never started before the regression deadlineno verdict — and its directory holds yesterday's files
rand_s4the farm refused the submissionno verdict

smoke_s9 is the interesting one. Chapter 25.3 recommended a fixed base seed for merge-gating regressions, so that the same seeds run every time. A consequence is that run ids repeat: yesterday's regression also had a smoke_s9, and if its directory was not cleared, its files are still there — including a done file and a log that says TEST PASSED. There is also old_s7, a directory from yesterday for a run that today's plan does not contain at all.

4. What a Stopped Simulator Leaves in Its Log

Before classifying anything, it is worth seeing what the logs of stopped runs actually contain. The same simulation, stopped after 0.8 seconds by TERM and by KILL; stopped by TERM during its start-up; and the hung test stopped by KILL:

Azvya Education Pvt. Ltd.VLSI Mentor
buffer_demo.pl
#!/usr/bin/perl
# buffer_demo.pl -- the same simulation stopped after 0.8 s by TERM and by
# KILL, stopped by TERM during its start-up, and a hung test stopped by KILL:
# what reaches the log?
use strict;
use warnings;
use Time::HiRes qw(sleep);

system("bin/build") == 0 or die "build failed\n";
system("bin/simv +test=smoke > /dev/null");   # warm-up: the first launch is the slowest
sub stopped_log {
    my ($sig, $after, @args) = @_;
    my $pid = fork // die "fork: $!\n";
    if ($pid == 0) {
        open STDOUT, ">", "stopped.log" or exit 126;
        open STDERR, ">&", \*STDOUT or exit 126;
        exec "bin/simv", @args;
        exit 127;
    }
    sleep $after;
    kill $sig, $pid;
    waitpid($pid, 0);
    my $signal = $? & 127;
    open my $fh, "<", "stopped.log" or die;
    my @l = <$fh>;
    return { signal  => $signal,
             bytes   => -s "stopped.log",
             errors  => scalar(grep { /^ERROR:/ } @l),
             header  => scalar(grep { /^TEST \S+ SEED/ } @l),
             verdict => scalar(grep { /^TEST (?:PASSED|FAILED)/ } @l) };
}

my $t = stopped_log("TERM", 0.8,   "+test=rand", "+seed=5", "+n=400000");
my $k = stopped_log("KILL", 0.8,   "+test=rand", "+seed=5", "+n=400000");
my $e = stopped_log("TERM", 0.002, "+test=rand", "+seed=5", "+n=400000");
my $s = stopped_log("KILL", 0.8,   "+test=stall");
printf "TERM: header line %s, ERROR lines: %s, verdict line: %s\n",
       $t->{header} ? "present" : "MISSING", $t->{errors} > 100 ? "more than 100" : $t->{errors},
       $t->{verdict} ? "yes" : "no";
printf "KILL: size is a whole number of 4096-byte blocks: %s; verdict line: %s\n",
       $k->{bytes} % 4096 == 0 ? "yes" : "no", $k->{verdict} ? "yes" : "no";
printf "TERM during start-up: ended by signal %d, %d bytes in the log\n", $e->{signal}, $e->{bytes};
printf "KILL on the hung test: %d bytes; its header line, printed at time 0: %s\n",
       $s->{bytes}, $s->{header} ? "present" : "MISSING";
Azvya Education Pvt. Ltd.VLSI Mentor
Captured output
TERM: header line present, ERROR lines: more than 100, verdict line: no
KILL: size is a whole number of 4096-byte blocks: yes; verdict line: no
TERM during start-up: ended by signal 15, 0 bytes in the log
KILL on the hung test: 0 bytes; its header line, printed at time 0: MISSING

When the simulator's output goes to a file, it does not write each line the moment it is printed. It collects output in a buffer and writes the buffer when it is full — on this machine, in steps of 4,096 bytes. What happens to the last, partly filled buffer depends on how the process ends:

  • TERM: Icarus vvp catches TERM and exits normally (Chapter 26.7 measured that), so it writes out its buffer. The log has everything printed up to that moment — but no verdict line, because the test never reached its end.
  • KILL: the process is destroyed instantly. The buffer is lost. The log holds only whole 4,096-byte blocks; everything printed after the last full block is gone, and the last line in the file is usually cut in the middle.
  • TERM during start-up: in the first few hundredths of a second, before vvp has installed its handler, TERM simply kills it — the third line shows signal 15 and an empty log. A runner that stops a job just after launching it gets KILL's result, not TERM's. (This was found the hard way: one of this chapter's own test runs, in a fresh directory where the first launch was slow, produced an empty TERM log. The scripts now make one warm-up run first.)
  • KILL on a run that printed less than one block: the log is empty. The hung test printed its TEST stall SEED 2 header at simulation time 0, and that line is not in the file.

This changes how a zero-byte log should be read. Chapter 21.8 read zero bytes as "the job died at startup", and that is one cause. With a buffering simulator there is a second: a run that printed hundreds of lines and was then killed can leave nothing at all. The collector cannot tell those apart from the log alone — and does not need to, because the waiter recorded which runs were killed. That is the theme of this chapter: the log is one witness, not the only one.

5. Two Quick Collectors, Measured

Here are the two collectors most people write. Both use the same classifier, so the only difference between them is which runs they look at:

Azvya Education Pvt. Ltd.VLSI Mentor
classify.pl
# classify.pl -- the log-only classifier both quick collectors share:
# a verdict line wins, otherwise errors mean FAIL, otherwise UNKNOWN.
use strict;
use warnings;

sub classify {
    my ($log) = @_;
    open my $fh, "<", $log or return "NO_LOG";
    my $text = do { local $/; <$fh> } // "";
    return "PASS"    if $text =~ /^TEST PASSED$/m;
    return "FAIL"    if $text =~ /^(?:TEST FAILED|ERROR:)/m;
    return "UNKNOWN";
}
1;
Azvya Education Pvt. Ltd.VLSI Mentor
glob_collect.pl
#!/usr/bin/perl
# glob_collect.pl -- collect by looking at whatever logs are in the run area.
use strict;
use warnings;
require "./classify.pl";

my %n;
for my $log (sort glob "runs/*/sim.log") {
    my ($id) = $log =~ m{^runs/([^/]+)/};
    my $v = classify($log);
    printf "  %-10s %s\n", $id, $v;
    $n{$v}++;
}
my $rows = 0; $rows += $_ for values %n;
printf "  %d rows; pass rate %d%%\n", $rows, 100 * ($n{PASS} // 0) / $rows;
Azvya Education Pvt. Ltd.VLSI Mentor
plan_collect.pl
#!/usr/bin/perl
# plan_collect.pl -- the registry's loop: every PLANNED run, log or NO_LOG.
use strict;
use warnings;
require "./classify.pl";

open my $p, "<", "runs/plan.tsv" or die; <$p>;
my %n;
while (<$p>) {
    my ($id) = split /\t/;
    my $log = "runs/$id/sim.log";
    my $v = -e $log ? classify($log) : "NO_LOG";
    printf "  %-10s %s\n", $id, $v;
    $n{$v}++;
}
my $rows = 0; $rows += $_ for values %n;
printf "  %d rows; pass rate %d%%\n", $rows, 100 * ($n{PASS} // 0) / $rows;

The second one is the registry's example for this chapter: every planned run, classified if it has a log, NO_LOG if not. Then the full collector, which the rest of this chapter builds. All three run on the same staged area:

Azvya Education Pvt. Ltd.VLSI Mentor
collect_demo.pl
#!/usr/bin/perl
# collect_demo.pl -- stage one regression's run area, then collect it three ways.
use strict;
use warnings;

$| = 1;
system("$^X stage.pl") == 0 or die "stage.pl failed\n";
print "\n--- 1. every log found in the run area ---\n";
system($^X, "glob_collect.pl");
print "\n--- 2. every planned run, log or NO_LOG ---\n";
system($^X, "plan_collect.pl");
print "\n--- 3. every planned run, all of the evidence ---\n";
system($^X, "collect.pl");
print "(collect.pl exit ", $? >> 8, ")\n";
Azvya Education Pvt. Ltd.VLSI Mentor
Captured output
staged regression r2: 10 planned runs

--- 1. every log found in the run area ---
  bogus_s1   UNKNOWN
  corner_s1  FAIL
  old_s7     PASS
  rand_s2    FAIL
  rand_s3    PASS
  rand_s5    FAIL
  smoke_s1   PASS
  smoke_s9   PASS
  stall_s1   UNKNOWN
  stall_s2   UNKNOWN
  10 rows; pass rate 40%

--- 2. every planned run, log or NO_LOG ---
  smoke_s1   PASS
  corner_s1  FAIL
  rand_s2    FAIL
  rand_s3    PASS
  bogus_s1   UNKNOWN
  stall_s1   UNKNOWN
  stall_s2   UNKNOWN
  rand_s5    FAIL
  smoke_s9   PASS
  rand_s4    NO_LOG
  10 rows; pass rate 30%

--- 3. every planned run, all of the evidence ---
RUN        VERDICT       REASON
smoke_s1   PASS          TEST PASSED, 0 errors, exit 0
corner_s1  FAIL          1 ERROR line(s)
rand_s2    FAIL          1 ERROR line(s)
rand_s3    PASS          TEST PASSED, 0 errors, exit 0
bogus_s1   FATAL         the bench stopped itself (exit 1)
stall_s1   TIMEOUT       stopped by the waiter (TIMEOUT: ran over 2s)
stall_s2   EMPTY_LOG     zero bytes; waiter: ended without done file
rand_s5    KILLED        ended before its job script did; the log already shows ERROR lines
smoke_s9   NOT_STARTED   the regression deadline came first (its directory holds files from regression r1: ignored)
rand_s4    NOT_SUBMITTED the farm never accepted it

PASS 2   FAIL 3   NO VERDICT 5   (10 planned, 10 reported)
rules no run reached this time: NO_LOG WRONG_RUN NO_VERDICT
regression INCOMPLETE: 3 failed, 5 without a verdict
(collect.pl exit 1)

The true result, from the table in Section 3, is 2 passes out of 10 planned runs: 20%. The quick collectors reported 40% and 30%. Here is where each one went wrong:

Collector 1, the glob, has ten rows, but they are not the ten planned runs:

  • It reported old_s7 — a run that is not in today's plan — as a PASS. Yesterday's file walked into today's report.
  • It reported smoke_s9 as PASS: yesterday's log, in a directory with today's name. Today, that run never started.
  • It left out rand_s4 completely. The farm refused it, so it has no log, so the glob never saw it.
  • It lumped three very different situations into UNKNOWN: the bench refusing a test, a timeout, and an empty log.

Two extra passes in the numerator, one missing run in the denominator. The row count happened to come out at ten, which is exactly the kind of coincidence that makes a wrong report look right.

Collector 2, the plan, fixed the denominator: every planned run has a row, and rand_s4 is NO_LOG. But it still reported smoke_s9 as PASS, because the log in that directory says TEST PASSED — and nothing in the log says it is from yesterday. And it still cannot say why anything is UNKNOWN.

Collector 3 has the right number of rows, the right verdict for each one, a reason on every row, and a count of 2 / 3 / 5 that adds up to the plan.

6. Evidence, Not Just Logs

The difference between collector 2 and collector 3 is not the classifier. It is that collector 3 gathers all the evidence about a run before deciding, into one hash:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
my %e = (
    job    => ...,   # did the farm accept it?         (jobs.tsv)
    wait   => ...,   # why did the waiter stop?         (wait.tsv)
    exit   => ...,   # the simulator's exit status      (status)
    done   => ...,   # did the job script finish?       (done)
    log    => ...,   # is there a log, and its size
    header => ...,   # what the log says was run
    passed => ..., failed => ..., fatal => ..., errors => ...,
    asked  => ...,   # what the plan says should have run
);

And it checks first that the run's files belong to this regression. The job script writes regr=r2 into status. If the directory's status says regr=r1, every file in that directory is treated as absent, and the reason says so: its directory holds files from regression r1: ignored. That single line is what turned smoke_s9 from a false PASS into the truth. (Chapter 17.9 covers stale artifacts in general; a regression id written by the job itself avoids comparing file timestamps from different machines' clocks, which Chapter 25.6 warned against.)

The better fix is upstream: the runner should empty or refuse a reused run directory before launching into it, as Chapter 25.4's launcher does. But the collector must not depend on every earlier stage having been perfect. It is the last line of defence, so it checks.

7. The Verdict Ladder

The decision is a table of rules, in the style of Chapter 21.8: each rule is a name, a test, and a function that writes the reason. The first rule whose test is true decides.

#VerdictTestCategory
1NOT_SUBMITTEDno job idno verdict
2NOT_STARTEDthe waiter recorded "never started"no verdict
3NO_LOGno sim.logno verdict
4EMPTY_LOGzero bytesno verdict
5TIMEOUTthe waiter killed itno verdict
6KILLEDno done: ended before its job script didno verdict
7FATALthe bench stopped itselffail
8WRONG_RUNthe log's header is not what was asked forno verdict
9FAILTEST FAILED or ERROR linesfail
10NO_VERDICTfinished, but no TEST PASSED lineno verdict
11PASSnothing else appliedpass

Why this order:

  • Rules 1 and 2 come from the runner's own records, and they explain why there is no other evidence. A run that was never submitted cannot have a log; if one is there, it is not this run's.
  • Rules 3 to 6 follow Chapter 21.8's order: existence of the log, then its size, then TIMEOUT before the general "did not finish" (KILLED), because the general rule matches everything the specific one does. Here, TIMEOUT is decided by the waiter's record, not by guessing from wall-clock times in the log.
  • FATAL before FAIL, so that "the bench refused to run" is not reported as an ordinary assertion failure.
  • WRONG_RUN before FAIL and PASS: a verdict about the wrong run is not a verdict about this one (Chapter 25.1's header check).
  • PASS is last and has no condition. It is reached only when every reason not to pass has been ruled out. Chapter 21.2 measured what happens when PASS is a condition instead: a crashed log with zero errors satisfies "no errors".

The categories are what a summary counts: PASS, FAIL, and NO VERDICT for every run whose outcome the regression did not establish. Keeping the third category separate is the point of the chapter. A regression with "3 failed" and a regression with "3 failed and 5 unknown" are different situations, and a report that merges them into one number hides the second.

8. The Collector

Azvya Education Pvt. Ltd.VLSI Mentor
collect.pl
#!/usr/bin/perl
# collect.pl -- one verdict, with a reason, for EVERY planned run, from all the
# evidence the runner kept; write runs/results.tsv; exit with a status that
# says whether the regression is complete.
#   exit 0  every run passed
#   exit 1  at least one run failed
#   exit 3  no run failed, but some runs have no verdict
use strict;
use warnings;
use List::Util qw(first);

my $REGR = do { open my $f, "<", "runs/REGRESSION" or die "runs/REGRESSION: $!\n"; <$f> };
chomp $REGR;

sub read_tsv {                             # first column => rest of the row
    my ($file) = @_;
    open my $fh, "<", $file or die "$file: $!\n";
    <$fh>;                                 # header
    return map { chomp; my ($k, @rest) = split /\t/; ($k => \@rest) } <$fh>;
}
my @plan = do {                            # plan order is report order
    open my $p, "<", "runs/plan.tsv" or die "runs/plan.tsv: $!\n";
    <$p>;
    map { chomp; my ($id, $test, $seed) = split /\t/; { id => $id, test => $test, seed => $seed } } <$p>;
};
my %jobs   = read_tsv("runs/jobs.tsv");    # job id => [run id]
my %job_of = map { $_->[0] => 1 } values %jobs;
my %wait   = read_tsv("runs/wait.tsv");    # run id => [why waiting ended]
my %why    = map { $_ => $wait{$_}[0] } keys %wait;

sub evidence {                             # everything we know about one run
    my ($r) = @_;
    my $dir = "runs/$r->{id}";
    my %e = (job => $job_of{ $r->{id} }, wait => $why{ $r->{id} } // "", note => "",
             asked => "TEST $r->{test} SEED $r->{seed}");
    my %st = -e "$dir/status" ? do { open my $s, "<", "$dir/status"; map { /^(\w+)=(.*)$/ } <$s> } : ();
    if (defined $st{regr} && $st{regr} ne $REGR) {         # yesterday's files
        $e{note} = " (its directory holds files from regression $st{regr}: ignored)";
        return \%e;                                        # trust none of them
    }
    $e{exit} = $st{exit};
    $e{done} = -e "$dir/done";
    if (open my $fh, "<", "$dir/sim.log") {
        $e{log}  = 1;
        $e{size} = -s "$dir/sim.log";
        while (my $l = <$fh>) {
            $e{header} //= $1 if $l =~ /^(TEST \S+ SEED \S+)$/;
            $e{passed}  = 1   if $l =~ /^TEST PASSED$/;
            $e{failed}  = 1   if $l =~ /^TEST FAILED/;
            $e{fatal}   = 1   if $l =~ /^FATAL:/;
            $e{errors}++      if $l =~ /^ERROR:/;
        }
    }
    return \%e;
}

# The verdict ladder: the first rule whose test is true decides.
my @RULES = (
  [ NOT_SUBMITTED => sub { !$_[0]{job} },                  sub { "the farm never accepted it" } ],
  [ NOT_STARTED   => sub { $_[0]{wait} =~ /never started/ }, sub { "the regression deadline came first" } ],
  [ NO_LOG        => sub { !$_[0]{log} },                  sub { "no log was written" } ],
  [ EMPTY_LOG     => sub { !$_[0]{size} },                 sub { "zero bytes; waiter: $_[0]{wait}" } ],
  [ TIMEOUT       => sub { $_[0]{wait} =~ /^TIMEOUT|was running/ }, sub { "stopped by the waiter ($_[0]{wait})" } ],
  [ KILLED        => sub { !$_[0]{done} },                 sub { "ended before its job script did"
                                                                 . ($_[0]{errors} ? "; the log already shows ERROR lines" : "") } ],
  [ FATAL         => sub { $_[0]{fatal} },                 sub { "the bench stopped itself (exit $_[0]{exit})" } ],
  [ WRONG_RUN     => sub { ($_[0]{header} // "") ne $_[0]{asked} }, sub { "log says '" . ($_[0]{header} // "") . "'" } ],
  [ FAIL          => sub { $_[0]{failed} || $_[0]{errors} }, sub { ($_[0]{errors} // 0) . " ERROR line(s)" } ],
  [ NO_VERDICT    => sub { !$_[0]{passed} },               sub { "finished without a TEST PASSED line" } ],
  [ PASS          => sub { 1 },                            sub { "TEST PASSED, 0 errors, exit $_[0]{exit}" } ],
);
my %CATEGORY = (PASS => "PASS", FAIL => "FAIL", FATAL => "FAIL");   # all else: NO VERDICT

my (%n, %fired);
open my $out, ">", "runs/results.tsv" or die "runs/results.tsv: $!\n";
print {$out} "run_id\ttest\tseed\tverdict\tcategory\treason\n";
printf "%-10s %-13s %s\n", "RUN", "VERDICT", "REASON";
for my $r (@plan) {
    my $e = evidence($r);
    my $rule = first { $_->[1]->($e) } @RULES;    # stops at the first true rule
    my ($verdict, $reason) = ($rule->[0], $rule->[2]->($e) . $e->{note});
    my $cat = $CATEGORY{$verdict} // "NO VERDICT";
    $reason =~ s/[\t\n]/ /g;               # keep the TSV one line per run
    print {$out} join("\t", @$r{qw(id test seed)}, $verdict, $cat, $reason), "\n";
    printf "%-10s %-13s %s\n", $r->{id}, $verdict, $reason;
    $n{$cat}++;
    $fired{$verdict}++;
}
close $out or die "runs/results.tsv: $!\n";

my $reported = 0; $reported += $_ for values %n;
die "BUG: ", scalar @plan, " planned but $reported reported\n" unless $reported == @plan;
printf "\nPASS %d   FAIL %d   NO VERDICT %d   (%d planned, %d reported)\n",
       map({ $n{$_} // 0 } "PASS", "FAIL", "NO VERDICT"), scalar @plan, $reported;
my @unused = map { $_->[0] } grep { !$fired{ $_->[0] } } @RULES;
print "rules no run reached this time: @unused\n" if @unused;

my $status = $n{FAIL} ? 1 : $n{"NO VERDICT"} ? 3 : 0;
print $status == 0 ? "regression COMPLETE: all passed\n"
    : "regression ", ($n{"NO VERDICT"} ? "INCOMPLETE" : "COMPLETE"),
      ": ", ($n{FAIL} // 0), " failed, ", ($n{"NO VERDICT"} // 0), " without a verdict\n";
exit $status;

Its output is the third block in Section 5. Walk through the parts that matter:

  • @plan drives everything. The loop is over planned runs, in plan order. A directory that is not in the plan (old_s7) is never visited, and a planned run with no directory still gets a row.
  • evidence gathers before deciding, and drops everything from a directory written by another regression.
  • first { ... } @RULES (from List::Util) returns the first rule whose test is true and stops there. The first version of this collector used my ($rule) = grep { ... } @RULES, which looks equivalent — the first element of grep's result is the first matching rule — but grep runs every test on every run. Rules written for complete evidence then ran on evidence they were never meant to see, and use warnings reported an undefined value. The answer happened to be right; the program was not.
  • Every row gets a reason, built by the rule's own function, so the reason always matches the rule that fired.
  • The count is checked. If the number of reported rows ever differs from the number of planned runs, the collector dies with a message that starts BUG:, because that can only be a defect in the collector itself.
  • Unreached rules are listed. In this staged regression, NO_LOG, WRONG_RUN and NO_VERDICT were never the deciding rule. That does not mean they are wrong; it means this run area did not test them. Saying so is how the collector avoids claiming more than its test showed. Exercise 1 reaches them.
  • runs/results.tsv is the machine-readable result: one line per planned run, with the test, the seed, the verdict, the category and the reason. Tabs and newlines are removed from the reason so that each run is exactly one line. Chapter 25.8 reads this file.

9. Evidence of Failure, and Absence of Evidence

Look at rand_s5. The farm stopped it before it finished, so it has no verdict line and no done file — the collector calls it KILLED. But its log already contains ERROR lines. The design did misbehave in that run; we just do not know everything it would have done.

The ladder puts KILLED before FAIL, following Chapter 21.8's rule that completion is checked before severity. The information is not lost: the reason says the log already shows ERROR lines. Some teams prefer to promote such a run to FAIL, on the grounds that evidence of failure is decisive even when the run is incomplete. That is a reasonable policy. Two things are not reasonable: calling it PASS, and reporting it without saying that it was cut short.

The general principle behind both choices is the same, and it is worth stating plainly:

  • Evidence of failure is evidence, even from an incomplete run. An ERROR line means something went wrong.
  • Absence of failure is not evidence of passing. An empty log, a log with only a header, a log cut off by KILL — none of them shows an error, and none of them shows a pass. That is why PASS must be reached by elimination, and why EMPTY_LOG and KILLED come before it.

10. The Exit Status

The collector's exit status is what a CI job or a Makefile sees, so it has to carry the difference between "failed" and "incomplete" too. The scheme in the script's header:

ExitMeaning
0every planned run passed — the regression is complete and clean
1at least one run failed
3nothing failed, but some runs have no verdict — the regression is incomplete

Status 2 is left free because Chapter 15.4 reserves it for usage errors. Status 3 matters more than it looks: without it, a nightly regression in which half the runs timed out and the other half passed would exit 0, and the CI dashboard would be green. With it, "incomplete" is a third colour that someone has to look at. Here the staged regression exits 1, because three runs failed — and the summary line still says it is incomplete, so the person who opens the report knows that five more runs need attention.

11. Exercises

1. Reach the unreached rules. Add three runs to stage.pl: one whose done and status exist but whose sim.log was deleted (reaches NO_LOG), one launched with a seed that wraps (Chapter 25.3: 4294967298) so its header disagrees with the plan (reaches WRONG_RUN), and one whose log ends without a verdict line although done exists. Run the collector and check that "rules no run reached" disappears.

2. A second witness for a timeout. Delete wait.tsv and run collect.pl again. Which verdicts change, and to what? Which runs can no longer be explained, and what would you add to the run directory so that a timeout could be recognised without the waiter's record?

3. Promote cut-short failures. Change the ladder so that a run with ERROR lines is FAIL even if it did not finish, and add (cut short) to its reason. Which row changes? Write two sentences for your team explaining which policy you chose and why.

4. Prove the stale check can fail. Remove the regr check from evidence and run the collector. Confirm that smoke_s9 becomes a false PASS. Then put the check back and confirm it is caught again. A check you have never seen fail has not been tested.

5. The 9am summary. Write summary.pl, which reads runs/results.tsv and prints at most ten lines: the counts by category, then the failing runs, then the runs with no verdict grouped by verdict. It must still say how many runs were planned.

12. What To Remember

  • One row for every planned run — not for every log, job or directory.
  • Measured: the true pass rate was 20%; a glob over the run area said 40%, and a loop over the plan that trusted logs said 30%.
  • A glob counts yesterday's directories and loses runs that left no log.
  • A log cannot tell you it is stale. Have the job write the regression id, and ignore files that carry another one.
  • Measured: KILL leaves only whole 4,096-byte blocks of the log here, and a hung test's log was empty — its header, printed at time 0, was lost. TERM during start-up did the same. Zero bytes is not only "died at startup".
  • Gather all the evidence first — the plan, the job map, the waiter's record, status, done, the log — then decide with an ordered rule table whose last rule is PASS.
  • Use first, not grep, to pick the deciding rule.
  • Keep three categories: PASS, FAIL, NO VERDICT, and exit 0, 1 or 3 so that "incomplete" cannot be green.
  • Evidence of failure counts even from an incomplete run; absence of failure never counts as a pass.

Chapter 25.8 reads runs/results.tsv from this regression and builds the next one from it: only the runs that did not pass, each with the same seed, so that a failure can be confirmed — or shown to have gone away — without rerunning everything.

Where this fits

Part of the Perl curriculum.