Perl Part I · Module 9 · Loops
Termination, Infinite Loops and Bounds
A loop whose condition depends on something outside the script — a file another tool is supposed to write, a lock another process is supposed to release, an operation that is supposed to succeed eventually — has no guarantee of ever finishing. When it does not, the script does not crash and does not print anything; it simply never returns, which in a nightly regression means a job that is still running at nine the next morning with nothing in its log. The fix is one counter and one comparison, and it converts the worst failure mode a script has into an ordinary error message with an exit code. There are two ways to get that counter wrong, and the second one is subtle enough that it makes a perfectly healthy input fail while reporting success.
Intermediate15 min readPerlPart IControl FlowLoops
Part I · Module 9 · Chapter 9.8 · Termination, Infinite Loops and Bounds
1. A Loop Nothing Can End
use strict;
use warnings;
my $ready = 0; # nothing in this loop will ever set it
my $passes = 0;
until ($ready) {
$passes++;
die "ABORT: the condition can never change (pass $passes)\n" if $passes > 5;
}
print "never reached\n";ABORT: the condition can never change (pass 6)255The die on the third line is mine, added only so that this measurement terminates at all. Delete it and the program does not fail — it runs, producing no output, until something outside it intervenes.
That is the shape worth recognising: $ready is tested by the loop and set by nothing. The real version is rarely that obvious, because the thing meant to change the condition is outside the loop body:
until (-e $netlist) {
# waiting for the synthesis job to write it
}If synthesis crashed, -e $netlist is false forever and this loop never exits.
There are three ways a Perl loop fails to terminate, and all three have been measured in this module:
| Shape | Why it never ends | Measured in |
|---|---|---|
while/until on a condition nothing changes | the test can never flip | this chapter |
a C-style for with no increment | the counter never advances | §4 below |
redo with a guard the body does not change | redo does not advance | Chapter 9.5 §3 |
A foreach over a fixed list cannot hang, which is one more reason Chapter 9.1 recommends it as the default.
2. The Bound
use strict;
use warnings;
my $ready = 0;
my $tries = 0;
my $MAX = 5;
until ($ready) {
if (++$tries > $MAX) {
print STDERR "giving up: not ready after $MAX attempts\n";
exit 2;
}
# a real script would re-check something external here
}
print "ready after $tries attempt(s)\n";giving up: not ready after 5 attempts2Same loop, same condition that never becomes true. The difference is that this one stops, says why, and reports a status a caller can branch on.
Three choices in that code are deliberate:
- The message names the bound. "not ready after 5 attempts" tells whoever reads the log both what failed and how hard the script tried. "giving up" alone does not.
- It goes to STDERR, so it is visible even when STDOUT is redirected into a report file.
exit 2, notexit 1. Chapter 8.6 §6 established the three-code taxonomy:0pass,1a real failure,2could not check. A timeout is emphatically the third — the script never found out whether the thing it was waiting for was good or bad.
Every loop whose condition depends on anything outside the script needs this. That includes waiting on a file, a lock, a process, a network mount, or a subroutine whose success is not guaranteed.
3. The Off-by-One
One character changes how many attempts you get.
use strict;
use warnings;
for my $form ("pre", "post") {
my $tries = 0;
my $body = 0;
while (1) {
$body++;
if ($form eq "pre") { last if ++$tries > 3 }
else { last if $tries++ > 3 }
}
printf " %-4s : body ran %d time(s), counter ended at %d\n", $form, $body, $tries;
} pre : body ran 4 time(s), counter ended at 4
post : body ran 5 time(s), counter ended at 5++$tries increments then compares; $tries++ compares then increments. With a bound of 3 the pre-increment form ran the body 4 times and the post-increment form 5.
Neither is wrong — they are different loops. What matters is knowing which one you wrote:
| Written | Body runs | MAX means |
|---|---|---|
last if ++$tries > $MAX | $MAX + 1 | attempts after the first |
last if $tries++ > $MAX | $MAX + 2 | — rarely what anyone means |
last if $tries >= $MAX with $tries++ at the top | exactly $MAX | total attempts |
The third form is the one to prefer, because $MAX then means exactly what the word says:
my $tries = 0;
while (1) {
$tries++;
last if $ready;
if ($tries >= $MAX) { print STDERR "giving up after $MAX attempts\n"; exit 2 }
}This matters more than style when the bound is a timeout expressed in real units. A loop meant to wait thirty seconds with a one-second interval and written with $tries++ > 30 waits thirty-two. On a single loop that is irrelevant; in a flow that nests three such waits it is the difference between a sensible timeout and one nobody can account for.
4. A Counter That Never Advances
use strict;
use warnings;
my $passes = 0;
for (my $i = 0; $i < 3; ) { # note: no $i++
$passes++;
die "ABORT: counter never advanced (pass $passes)\n" if $passes > 4;
}
print "done\n";ABORT: counter never advanced (pass 5)255The third part of the for header is empty, so $i stays 0 and $i < 3 stays true. Perl does not warn — an empty increment clause is legal and occasionally deliberate, because the body may be advancing the counter itself.
The realistic version is not an empty clause but a conditional one:
for (my $i = 0; $i < @rows; ) {
if (should_keep($rows[$i])) {
$i++; # only advances when kept
} else {
splice(@rows, $i, 1); # removes, so do not advance
}
}That is a legitimate and correct idiom — removing an element shifts the rest down, so not advancing is right. It is also one editing mistake away from an infinite loop: make should_keep return false while splice fails to shrink the array and nothing moves.
Chapter 9.1 §3 measured the opposite error on the same shape — a loop that does advance while splicing, visiting only [1 3 5] of five elements. Both failure modes come from the index and the array being able to disagree, which is the argument for rebuilding with grep instead:
@rows = grep { should_keep($_) } @rows;5. The Counter You Forgot to Reset
This is the one that produces a wrong answer rather than a hang.
use strict;
use warnings;
my @items = ("a", "b", "c");
print "WITHOUT a reset between items:\n";
my $tries = 0;
for my $item (@items) {
my $got = 0;
while (!$got) {
$tries++;
if ($tries > 4) { print " $item: gave up at $tries\n"; last }
$got = 1 if $tries % 2 == 0;
}
printf " %s: got=%d tries=%d\n", $item, $got, $tries;
}
print "WITH a reset between items:\n";
for my $item (@items) {
my $t = 0;
my $got = 0;
while (!$got) {
$t++;
if ($t > 4) { print " $item: gave up at $t\n"; last }
$got = 1 if $t % 2 == 0;
}
printf " %s: got=%d tries=%d\n", $item, $got, $t;
}WITHOUT a reset between items:
a: got=1 tries=2
b: got=1 tries=4
c: gave up at 5
c: got=0 tries=5
WITH a reset between items:
a: got=1 tries=2
b: got=1 tries=2
c: got=1 tries=2Item c failed, and it is identical to a and b. All three need two attempts. With the counter declared outside the item loop, a consumed two of the budget, b consumed two more, and c ran out before it could finish — so the loop reported got=0 for an item that was never actually a problem.
6. Why This Matters in Engineering Work
A hung script is the worst outcome available. A crash has a message and a stack; a wrong answer has a number you can question. A hang produces an empty log and a job that holds a licence and a queue slot until someone notices, which in a nightly flow is the next morning.
Every wait in an EDA flow is on something that can fail. Synthesis writes a netlist, a licence server grants a token, an NFS mount appears, a job queue reports completion. Each of those can not-happen, and a loop waiting on one without a bound converts an upstream failure into a hang in your script rather than an error from theirs.
Exit 2 routes the problem correctly. A timeout means the script could not determine the answer, which is Chapter 8.6's UNKNOWN. Reporting it as 1 sends a flow problem to the block owner.
And the shared-counter bug is a false failure, which is expensive in a different way. It makes a clean block look broken, on a run that exits 0, with an answer that changes if you reorder the input. Engineering time spent investigating a block that was never wrong is the most wasteful kind.
7. Common Mistakes
A while or until whose condition nothing in the loop can change. It never ends and prints nothing.
No attempt bound on a wait for anything external. Add a counter and a maximum.
exit 1 on a timeout. Use 2 — the script did not determine an answer.
last if $tries++ > $MAX. The body runs $MAX + 2 times. Prefer incrementing at the top and testing >= $MAX.
A C-style for with a conditional increment that can fail to fire. Rebuild with grep instead.
A retry counter declared outside the per-item loop. Later items inherit the spent budget and fail while identical earlier items passed.
Reporting "failed" without the attempt count. A consumed-budget bug is invisible without it.
A last that skips the per-item recording. The item vanishes from the report instead of appearing as failed.
8. Exercise
Exercise 1 — the unbounded loop. Write until ($ready) where nothing sets $ready, with a die bound at 5 passes so it terminates. Record the message and the exit status. Then remove the die, run it with a timeout you control, and describe what the terminal shows.
Exercise 2 — the bound. Add an attempt counter and exit 2 with a message naming the maximum. Confirm the message reaches STDERR and the exit status is 2.
Exercise 3 — the off-by-one. Run §3's comparison. Record how many times the body ran for each form. Then write the third form from §3's table and confirm the body runs exactly $MAX times.
Exercise 4 — units. Set $MAX = 30 in all three forms and record the body count for each. Say which one you would use for a thirty-second timeout and why.
Exercise 5 — no advance. Write a C-style for with an empty increment clause and a die bound. Record the abort. Then write the conditional-increment version from §4 and make it loop forever by having the removal not shrink the array.
Exercise 6 — the shared counter. Reproduce §5 exactly. Record both blocks of output. Then reorder @items to ("c", "b", "a") and record which item fails now.
Exercise 7 — the fix and the report. Fix Exercise 6 with a per-item counter, then change the report line to include the attempt count for every item, passing or failing. Show the output.
Exercise 8 — the missing record. In the failing version, move the per-item printf so that a last skips it. Record what the report shows for item c and say why that is worse than reporting a failure.
9. Summary
- A loop whose condition nothing can change never ends, prints nothing, and is the worst failure a script has. A
foreachover a fixed list cannot do this. - Three shapes can hang: a
while/untilon a static condition, a C-styleforthat does not advance, and an unguardedredo. - Bound every wait on anything external with an attempt counter, a message naming the maximum, and an exit status.
- Send the message to STDERR and
exit 2— a timeout is "could not check", not "failed". ++$tries > $MAXruns the body$MAX + 1times;$tries++ > $MAXruns it$MAX + 2. Measured: 4 and 5 passes for a bound of 3.- Prefer incrementing at the top and testing
>= $MAX, so$MAXis the attempt count. - A conditional increment in a C-style
forcan fail to fire. Rebuilding withgrepremoves the index entirely. - Declare the retry counter inside the per-item loop. Shared, it made an identical third item fail — measured, with exit 0 and empty STDERR, and the result changes if you reorder the input.
- Report the attempt count alongside the outcome, and make sure a bailed item still reaches the code that records it.
Chapter 9.9 closes the module with what loops are mostly for: the three accumulation patterns that between them cover almost every loop you will write.
Where this fits
Part of the Perl curriculum.
