News3 days ago

C++26 Made Infinite Loops Legal and We Crashed One by Adding the Word continue

P2809R3 makes an empty infinite loop well defined, and compilers already backported it. We compiled five spellings of the same loop: two produce a real loop, three produce a trap instruction and exit code 133.

The WJS Desk

Sep 21, 2026 · 6 min read

Photo by Wolfgang Weiser on Pexels

For about 15 years, while (true) ; was undefined behaviour in C++. The compiler was allowed to assume it terminated, delete it, and let control flow wander into whatever code happened to sit after it. C++26 adopted P2809R3 and made the empty version well defined. The standards blogger Sándor Dargó wrote it up on 16 September and Hacker News put 251 comments on 163 points, which is a comment-to-point ratio you only get when people are genuinely annoyed.

We wanted to know how much of this is real today, so we compiled five spellings of the same loop on Apple clang 17.0.0 targeting arm64. Two of them produce an honest infinite loop. Three compile to a trap instruction and die with exit code 133.

What the rule actually says

The C++11 forward-progress guarantee let an implementation assume any thread will eventually terminate, call a library I/O function, access a volatile glvalue, or perform a synchronisation or atomic operation. A loop doing none of those is assumed to end, and if it cannot end, you have UB.

P2809R3, written by JF Bastien at Woven by Toyota, carves out an exception with two conditions, both required. The loop must be a "trivially empty iteration statement", and the controlling expression must be a constant expression evaluating to true.

The normative wording does not describe an empty body in prose. It enumerates exactly six forms, and this is the whole list:

while ( expression ) ;
while ( expression ) { }
do ; while ( expression ) ;
do { } while ( expression ) ;
for ( init-statement expression_opt ; ) ;
for ( init-statement expression_opt ; ) { }

If your loop is not character-for-character one of those six shapes, the exception does not apply to it. That is the detail everything below turns on.

C solved this differently and more generously. C11 says loops whose controlling expression is a constant expression may not be assumed to terminate, full stop, with no condition on the body. C++ declined to copy that because it "could inhibit useful optimizations."

We compiled five versions of the same loop

Same compiler, same flags, -std=c++26 -O2, looking at the first branch instruction emitted in main.

SourceEmittedBehaviour
while (true) ;b LBB0_1Real infinite loop
while (true) {}b LBB0_1Real infinite loop
while (true) { ; }brk #0x1Traps immediately
while (true) { continue; }brk #0x1Traps immediately
while (true) { "a string"; }brk #0x1Traps immediately

We did not stop at the assembly. We built and ran both ends of that table. The ; version was still spinning when we killed it after five seconds. The continue version exits instantly:

$ clang++ -std=c++26 -O2 -o loop3 loop3.cpp
$ ./loop3
$ echo $?
133

133 is 128 plus 5, which is SIGTRAP. The program did not loop, did not print, and did not return. It hit a deliberate trap instruction the optimiser planted where the loop used to be, because from the compiler's point of view that code is unreachable.

The row we find hardest to defend is the third one. { ; } is a block containing one null statement. To a human reading the diff, it is an empty loop body. It is simply not one of the six enumerated forms, so the exception does not apply and the UB comes straight back. The compiler is behaving exactly as specified, which is the uncomfortable part.

The fix is already in your compiler, in every mode

P2809R3 was also accepted as a defect report, so implementations can apply it to older language modes. We checked, and Apple clang does. The ; version emits an identical real loop under -std=c++17, c++20, c++23 and c++26. You cannot reproduce the historical bad behaviour on this compiler by asking for an old standard.

The optimisation level is what separates the good day from the bad one:

while (true) { continue; }
  -O0  ->  b LBB0_1      (infinite loop)
  -O1  ->  brk #0x1      (trap)
  -O2  ->  brk #0x1      (trap)
  -O3  ->  brk #0x1      (trap)
  -Os  ->  brk #0x1      (trap)

This is the worst possible failure shape. The disqualified spellings work perfectly in a debug build and trap in every optimised one. If your CI runs tests at -O0 and you ship at -O2, this class of bug is invisible until production. We also ran the classic version, a spin function followed by a function nobody calls, and it exits 133 the same way.

One claim we could not reproduce

The writeup says that when both conditions are met, "the loop body is replaced with a call to std::this_thread::yield()." That is not the blogger's invention: the paper itself says of one example that "the loop will now have an implicit call to yield at runtime." And that line is what set the thread off. HN user JoshTriplett wrote: "An infinite loop, with no library calls whatsoever, gets a system call inserted. That's a horrible surprise waiting to happen. The entire concept of the 'forward progress guarantee' is broken. An infinite loop should compile to an infinite loop. Nothing more, nothing less."

On our setup it does compile to nothing more. We grepped the generated assembly for a yield symbol and found none. Apple clang 17 emits a bare backward branch. So the yield insertion is an implementation choice some toolchains make rather than something the rule forces, and the strongest objection in the thread does not apply to the compiler most Mac developers have installed. We have not checked GCC, which is not available on this machine, so we cannot tell you what it does.

The thread found the spelling trap before we did

Credit where it is due. HN user omoikane posted Godbolt links showing while(true); looping and while(true) continue; restoring UB, and then named the practical consequence: "This is unfortunate since I know of one style guide that prefers continue over single semicolons." That style guide is Google's, and its formatting section does prefer the explicit continue to a bare semicolon precisely because a lone ; is easy to misread.

A style rule written to prevent one bug now causes a different, worse one.

The embedded developers were the angriest, and with cause. HN user peterus pointed out that autogenerated HAL code for STM32 parts uses while (1) ; in error handlers, and posted the idiom they use, a software breakpoint followed by the spin. Their loop is the spelling that survives. Plenty of hand-written error handlers are not.

The dissent worth reading came from wahern, who argued the cure is as bad as the disease: implicit yield insertion is "the epitome of the hidden code downside that Linus and many others dislike about C++", and the committee should have required a diagnostic instead of silently changing codegen. Given that we could not observe any yield at all on clang, that objection is currently more about the specification's intent than about what your binary does.

What to actually do

Write while (true) {} or while (true) ; and nothing else inside the braces, in any C++ mode, today. If you want a spin loop that survives any future tightening, the portable answer has not changed: touch a volatile, or make the body do a real atomic or I/O operation, and you are outside this rule entirely rather than depending on an exception with two conditions.

The open question we cannot settle is how much code this actually breaks. Our five variants are five lines of test code, not a survey. The compilers have been applying the defect report quietly for a while, which means any damage from the old behaviour is already done and any damage from the narrowness of the new rule is already live too.

Your turn

Run the two-line test on your own toolchain: compile int main() { while (true) { continue; } } at -O2 and tell us what your compiler emits and what the exit code is. We are specifically interested in GCC and in anything cross-compiling to Cortex-M, because that is where the error-handler spin loops actually live and where we have no data. If your build traps, you have a real bug in your error path.

If you like watching a toolchain fall over under a specific input, we did the same thing to a different rewrite recently: Ubuntu 26.10 finished moving rm to Rust, and we segfaulted it at 17,000 directories. Different language, same lesson about which edge cases nobody tested.

Share

C++26 made empty infinite loops well defined. We compiled five spellings on clang: 'while(true);' loops forever, 'while(true){ ; }' traps and exits 133. Two braces apart. #cpp #Cplusplus #Compilers #Programming

Never miss a ship

The best stuff that shipped this week, delivered every Thursday. Free, no spam. We read all the boring stuff so you get the fun parts.

Keep reading