DEV Community

Cover image for Your app isn't frozen, it's buffering
richi.codes
richi.codes

Posted on Originally published at richi.codes

Your app isn't frozen, it's buffering

This note has an interactive demo. Try it in the original

The window looked frozen, but the program was working the whole time. It just had no way of saying so.

The job was to take a command-line calculation engine that worked fine in the hands of the person who wrote it, and turn it into an application other people could install and use on macOS, Windows and Linux. I put a graphical interface on top of the original engine, running it as a child process. That's where the interesting problems showed up.

The demo above recreates the first one. Press Run calculation with both switches off: that's how the original program behaved. Then turn the switches on one by one.

"It's hung"

Launch a long calculation and the window seemed to freeze. There were two separate causes, and they looked identical from the outside.

The engine was waiting for a keyboard answer. In a terminal that was normal: it asked a question, you typed y, and it carried on. Launched from the interface, nobody could answer, so it waited forever. input() reads a line from standard input and only gives up when it hits end of file, at which point it raises EOFError. A pipe that nobody writes to and nobody closes never gets there.

The output was held until the end. This one is sneakier. Python's docs are blunt about it: when interactive, stdout is line-buffered; otherwise it is block-buffered like a regular file. In a terminal, each print() shows up straight away. When the parent process reads stdout through a pipe, it's no longer interactive, so the lines pile up in memory and only reach the interface when the buffer fills or the process ends. The engine was printing progress the whole time. The window just didn't receive it.

That's what the middle column of the demo shows: with buffering on, the child writes every step, the bytes collect in the buffer, and the window gets everything at once at the end.

What I changed

Three things, all small:

  1. Options as arguments instead of questions. The interface passes everything the engine used to ask for on the command line, so it never reaches the input().
  2. PYTHONUNBUFFERED=1 in the child's environment. Setting it to any non-empty string is equivalent to running Python with -u, which forces stdout and stderr to be unbuffered.
  3. Step-by-step progress with an estimated time. Once lines arrive as they're written, the interface can show a real progress bar.

Here is the minimal version of both sides. The engine:

import argparse, time

parser = argparse.ArgumentParser()
parser.add_argument("--steps", type=int, default=8)
parser.add_argument("--defaults", action="store_true")
args = parser.parse_args()

start = time.monotonic()
for i in range(1, args.steps + 1):
    time.sleep(0.2)  # the real work goes here
    elapsed = time.monotonic() - start
    eta = elapsed / i * (args.steps - i)
    print(f"step {i}/{args.steps} ยท ETA {eta:.1f} s")
Enter fullscreen mode Exit fullscreen mode

And the side that launches it:

import os, subprocess, sys, time

env = {**os.environ, "PYTHONUNBUFFERED": "1"}
proc = subprocess.Popen(
    [sys.executable, "engine.py", "--defaults", "--steps", "4"],
    stdin=subprocess.DEVNULL,   # nobody will type: input() raises EOFError instead of waiting
    stdout=subprocess.PIPE,
    text=True,
    env=env,
)
t0 = time.monotonic()
for line in proc.stdout:        # each line arrives as soon as the child prints it
    print(f"{time.monotonic() - t0:.2f}s  {line.rstrip()}")
proc.wait()
Enter fullscreen mode Exit fullscreen mode

I ran it both ways. With PYTHONUNBUFFERED=1, the four lines arrived at 0.23, 0.43, 0.63 and 0.83 seconds. Without it, all four arrived together at 0.82 seconds. Same work, same total time; only the experience changes.

stdin=subprocess.DEVNULL is a cheap safety net on top of the arguments: if some forgotten input() is still lurking, it hits end of file and fails right away with EOFError instead of hanging silently. A loud failure beats an eternal wait.

The calculation didn't get any faster. It just stopped being scary.

A result shifted by one whole period

The second problem was worse, because it didn't look like a problem. With some data files, a date came out exactly one period early. The program didn't crash; it gave a number that looked fine.

The cause was that it took an auxiliary point in the data file as its reference instead of the last real observation. I excluded those points and checked that this case was corrected without changing the results of the other reference files.

This is the kind of bug that worries me most, because it doesn't warn you. That's why it pays to keep test cases with known results and rerun them after every change.

"The application is damaged"

On macOS, the freshly downloaded app showed that message. I solved it by signing it ad hoc during the automated build. I also left automated builds for the three systems and clear messages when the wrong file is loaded, instead of an incomprehensible error.

If you have the same problem

  • Does it ask anything on the keyboard? Search for input( in the engine. Turn every question into an argument, and launch the child with stdin=subprocess.DEVNULL so any leftover fails fast.
  • Does progress only show up at the end? Set PYTHONUNBUFFERED=1 in the child's env, run it with python -u, or pass flush=True to the print() calls you care about.
  • Does stderr behave differently from stdout? Since Python 3.9, non-interactive stderr is line-buffered, so warnings may show up while normal output doesn't. That mismatch is a strong clue.
  • Keep reading the pipe. The subprocess docs warn that wait() can deadlock with stdout=PIPE if the child fills the OS pipe buffer. Read it as it comes, and in a GUI do it off the interface thread so the window doesn't freeze for real.
  • Keep reference cases with known results. The bugs that don't crash are the ones that get through.

What to keep an eye on

  • Engines that aren't Python. PYTHONUNBUFFERED is a Python setting. If the engine is written in something else, check how that language buffers output to a pipe before assuming the same fix works.
  • Partial lines. A prompt like input("Continue? ") doesn't end in a newline, so a parent reading line by line won't see it even with -u. I checked: the question never shows up while the child waits.
  • Very chatty children. For a handful of progress lines, turning buffering off costs nothing noticeable. If the engine dumps huge logs, I'd rather leave buffering on and use flush=True only on the progress lines.

If you have a program that only its author knows how to use, write to me.

Written with AI help from real work of mine.

Sources


Originally published at richi.codes.

Top comments (0)