Lindsay Edwards

Looks finished, isn't: streams and processes that lie to you

On this page

A whole category of bug comes from mistaking “it looked like it finished” for “it finished.” Streams and processes are especially good at this, because the code that handles them reads perfectly and is quietly wrong at the edges. Three from the same system.

The tailer that ate half a line#

I was tailing an append-only log: watch the file, and on each change read the newly appended bytes, split on newlines, parse each line. Keep an offset so you only read what is new. Clean, obvious, and lossy.

The problem is that a writer can flush in the middle of a record. So the newly read chunk ends with half a line: valid bytes, invalid JSON. The parser rejects it, logs a friendly “skipping malformed line,” and moves on. Meanwhile the offset has already jumped to the end of the file, so the second half of that record, when it arrives, is never read either. Silent data loss, presented as graceful error handling.

The fix is to keep a leftover buffer: only advance the offset up to the last confirmed newline, and carry the trailing partial bytes forward to be completed by the next chunk. (There is a second trap in the same code: if the file is rotated or truncated, its new size is smaller than your stored offset, and a naive “only read past the offset” check means you never read the rotated file at all. That one needs an explicit “size went backwards, reset” branch.)

“Skip the malformed line” plus “advance to the end” is data loss mislabelled as resilience. A byte-offset stream reader has to treat the tail after the last newline as unfinished, not as garbage.

The timeout that cancelled nothing#

Elsewhere, calls to a subprocess were guarded with a race between the real call and a timeout, so a wedged child could not block the caller past a deadline. Sensible. Except the timeout only rejects the promise the caller is awaiting. It does not touch the child process, which keeps running, holding its resources, having its in-flight request neither answered nor cancelled. The caller “timed out” and moved on; the actual work carried on in the dark.

A deadline is not cancellation. If you race a call against a timer, the timer firing has to also do the teardown: close the transport, kill the child, abort the request. Otherwise you have not stopped the slow thing, you have just stopped looking at it.

The process that ignored the polite request#

Related, and the last one: terminating a child process. Sending the graceful termination signal is a request, not a guarantee. A process can ignore it, and some do. So the shutdown path needs an escalation: ask nicely, wait a bounded time, and then hard-kill if it is still alive. Without the fallback, one stubborn worker becomes a hang.

And while you are in there, remove the stream listeners you attached to that child’s output. Handlers left bound across a process’s lifecycle pile up quietly and leak, which is another thing that looks finished (the process is gone) and is not (its listeners are still attached to nothing).

A parsed chunk, a fired timeout, a sent signal: all three look like completion and none of them are. The stream might have handed you half a record. The deadline stopped your waiting, not the work. The signal asked, it did not enforce. Finishing something, properly, nearly always needs the boring second half: buffer the remainder, tear down what you abandoned, escalate when the polite version is ignored.

For anyone downstream the cost is the same shape every time: data you believed was saved is quietly gone, and work you believed was stopped is still running and still holding resources, with nothing on screen to say so.

Keep reading