The Case of the Blocked Pipe

yesterday by David Grudl  

“This job teaches you that every operating system has skeletons in its closet. Linux? It plays it straight—open source, they call it. But Windows? Windows is that mysterious beauty in the corner of the bar. Smiling, telling you everything's fine, while bodies rot in her basement.”

Chapter 1: The Perfect Crime

It was January 2026 when the call came in.

Nette Tester had mastered the art of multi-threaded testing back in 2012. Fourteen years of flawless service. Eight threads working in perfect harmony by default, like a conducted orchestra. It was a pioneer—back when other libraries were running tests one by one like pensioners at the post office, Tester was already parallelizing. Sixty seconds of tests compressed into ten. Beautiful. Elegant.

But that was about to end.

The first reports came from Windows users. Tests that flew on Linux were crawling on Windows. Not walking—crawling. On their bellies. Through broken glass.

Linux: 3 seconds. Windows: 21.6 seconds.

Someone was murdering parallelism. And they were doing it in broad daylight.

Chapter 2: The Suspects

I opened the case file and started from the basics.

When Tester runs a test, it spawns a new PHP process. This process runs independently and writes its results to standard output—stdout. But how does the main program get those results? Through a pipe. An invisible tube between processes, through which data flows like water.

On Linux, this pipe is smart. When it's empty, you say “give me data” and it replies “got nothing” and you move on. No waiting. No delays.

But on Windows?

I tried everything. First suspect was stream_set_blocking()—supposed to switch the pipe to non-blocking mode.

Didn't work.

Next up was stream_select()—should monitor multiple pipes at once and tell you which one has data.

Dead too.

Finally, I tried plain old fread(). On Linux, reading from an empty pipe returns nothing immediately. On Windows? It waits. Waits until something arrives. Could be forever.

Three functions. Three corpses. Someone had killed non-blocking I/O years ago. A crime so old, the chalk outline had long faded.

Chapter 3: The Blockade

Picture this:

Eight threads enter a bar. The first one orders — he overdoes it with shots and gives sleep(3). Nothing unusual. Even a test needs its beauty sleep sometimes.

But then it gets interesting.

You want to read the output from that first test. You reach into the pipe. But there's no output—the test is sleeping, not printing anything. And on Windows, when you read from an empty pipe…

You wait.

You wait until the test wakes up. Frozen mid-motion with your hand in the pipe. The other threads stare at you. “Hello? You okay?” But you've started reading and you can't stop. Seven threads poke at you: “Hey, can we go? We've got tests to run!” But you can't do anything. Inside, you're cursing yourself—if you'd picked a different test, you could be mingling with the others right now, working, being useful. But no. You picked the sleeper and now you're its hostage.

Three seconds of silence. Then the test wakes up, spits out its output, and you can finally let go.

Eight threads. One stuck. Seven shuffling and waiting.

Twenty-one point six seconds instead of three.

This wasn't murder. This was torture.

Chapter 4: Files

My job was to solve this problem.

Every detective has that moment. A flash of brilliance. The feeling that you've finally cracked it. Mine came at 2 AM over cold coffee and frozen code.

“Files,” I whispered. “We'll write to files.”

A pipe is like a phone line—you have to listen when someone's talking, or you miss it. But a file? A file is like an answering machine. The test writes what it needs, and you read it when you have time. No waiting. No freezing.

proc_open($cmd, [
    ['pipe', 'r'],
    ['file', $tempFile, 'w'],  // Here it is!
    ...
]);

No pipes. No blocking. The test writes to a file, we read it when it's done. Clean. Simple. Genius.

27 seconds.

Twenty. Seven. Seconds.

I measured everything:

  • proc_open(): 2 ms. Innocent.
  • proc_get_status(): 4 ms per thousand calls. Clean too.
  • File read + delete: Under 10 ms. Nothing suspicious.

And yet the result was catastrophic.

I stared at those numbers until my eyes watered. Individual operations fine. The whole thing a disaster. How was this possible?

Then it hit me. Eighty-five tests. Eighty-five temp files. Eighty-five creates, writes, reads, and deletes. Each operation fast, but Windows filesystem works differently than Linux. NTFS journaling. Antivirus scanning every new file like a nervous border agent. And those milliseconds add up.

Death by a thousand cuts. Slow but certain.

Chapter 5: The Gamble

I was running out of ideas. And coffee.

Then something occurred to me. Something dangerous. The kind of idea that usually gets detectives killed in the third act.

What if we just didn't read the output right away?

The thought was simple: test is running, we leave it alone. Don't touch the pipe, don't wait, don't block. When the test finishes—when it *leaves the bar*—only then do we read what it wrote. Until then, we take care of the other threads. Work. Live.

if ($status['running']) {
    if (PHP_OS_FAMILY !== 'Windows') {
        // On Linux we read continuously, it works there
        $this->test->stdout .= stream_get_contents($this->stdout);
    }
    // On Windows? Don't read. Don't touch. Don't even breathe near the pipe.
    return true;
}

I implemented it. Held my breath. Ran the tests.

3 seconds.

Three. Seconds.

Just like Linux. Just like the good old days. I wanted to cry with joy. Dance. Run up to the roof and—

The phone rang.

“We have a problem,” said the voice. “Some tests just won't finish. Hanging there like laundry.”

My heart stopped.

“Which ones?”

“The ones with lots of output. Tons of echo statements. They write and write, and then—nothing. Silence. Forever.”

I closed my eyes. Of course. How could I forget.

The pipe buffer. Four kilobytes on Windows. A pipe isn't bottomless—it's a tube with limited capacity. When a test writes and writes and nobody reads, the buffer fills up. And when the buffer is full, the test freezes mid-write. Waiting for someone to make room. But we're not reading. We're waiting for the test to finish. It's waiting for us.

Deadlock.

A classic trap. Two people waiting for each other at a doorway: “After you.”—“No, after you.”—Forever.

We'd traded one killer for another.

Chapter 6: Last Attempts

The next forty-eight hours were a blur of caffeine and increasingly desperate ideas.

Timeout:

stream_set_timeout($this->stdout, 0, 1000); // 1ms timeout
$this->test->stdout .= fread($this->stdout, 8192);

Result: 18.5 seconds. Better. But still blocking. The timeout was just a polite request that Windows wiped its ass with.

Metadata check:

$meta = stream_get_meta_data($this->stdout);
if (!empty($meta['unread_bytes'])) {
    // Only read when there's something!
}

Result: unread_bytes was always zero. Always. Even when there was data. Even when there were megabytes. Windows was lying straight to our faces.

I slammed my fist on the desk.

Every lead hit a wall. Every piece of evidence crumbled in my hands. Every solution fixed one problem and created another. Like playing chess against an opponent who redraws the board after every move.

Chapter 7: The Truth

They don't teach you this in detective school:

Sometimes you don't catch the killer.

Sometimes the killer is the system itself. Hardcoded into the foundation. A decision someone made in Redmond twenty years ago. A person who never imagined PHP processes would want to communicate without waiting.

Windows simply doesn't support non-blocking I/O on anonymous pipes. Period. End of story. Case closed.

The only way out was through sockets. TCP sockets. Because stream_select() works with sockets. Even on Windows.

┌─────────────────────┐                      ┌─────────────────────┐
│    Test Runner      │   TCP localhost:N    │    Test Process     │
│                     │◄────────────────────►│                     │
│  stream_select()    │                      │  Output goes to     │
│  WORKS HERE         │                      │  socket, not stdout │
└─────────────────────┘                      └─────────────────────┘

It would work. I'm certain.

But it would mean rewriting everything. Changing how every test outputs data. Overhauling the runner's main loop. Open-heart surgery on a patient who's been running marathons for fourteen years.


Epilogue: The Bitter End

I lit a cigarette I don't smoke and stared out the window at rain that wasn't falling.

The case file sits on my desk. Unsolved. The current implementation—don't read while running—is fast as lightning, but carries a bomb in its pocket. Any test that dares to output more than 4 KB will freeze forever. Trapped in a digital limbo of its own making.

I closed the file.

Not every case has a happy ending. Not every killer ends up behind bars. And some operating systems just are what they are —no matter how many nights you spend on Stack Overflow or how many Microsoft engineers you curse.

Parallelism on Windows is dead. Long live parallelism.

Case closed. The killer is still out there. Sleep well.


I've seen things you people wouldn't believe. Attack ships on fire off the shoulder of Orion. I watched C-beams glitter in the dark near the Tannhäuser Gate. And I've seen PHP try to do non-blocking I/O on Windows pipes. All those moments will be lost in time. Like tears in rain.

— Roy Batty, if he'd been a PHP developer


David Grudl Programmer, blogger, and AI evangelist who created the Nette Framework powering hundreds of thousands of websites. He explores artificial intelligence on Uměligence and web development on phpFashion. Weekly, he hosts Tech Guys and teaches people to master ChatGPT and other AI tools. He's passionate about transformative technologies and excels at making them accessible to everyone.