• Aug 22, 2026
  • 5 min read

Shell Traps While Watching Logs: grep Buffering and pkill -f Killing Itself

While testing my email pipeline I watched a Cloudflare Worker’s live logs in the background, waited for one real subscription, and then looked at what the Worker had done. Twice the shell got in my way: once the log file was empty although events had happened, and once my cleanup command killed the shell it ran in.

Neither is new, and both are easy to fall into again. Here’s what happened and what I do now.

Trap 1: the log file that stayed empty

I started the tail like this, in the background, with a filter to drop blank lines and redact link tokens:

timeout 900 npx wrangler tail leads-forms --format pretty \
  | grep -v "^\s*$" \
  | sed -E 's/t=[A-Za-z0-9._-]+/t=…/g' > tail.log

A subscription came in, the Worker handled it, and tail.log was 0 bytes.

Why

grep (and sed) decide how to buffer based on where their output goes. Writing to a terminal, they flush each line. Writing to a pipe or a file, they switch to block buffering: output is held until a buffer of a few kilobytes fills, or the program exits. A handful of log lines never fills the buffer, so nothing reached the file while I was looking.

When I then killed the pipeline, the buffered lines went with it. The event I needed was lost.

Fixes

Pick one:

# 1. tell grep to flush every line
... | grep --line-buffered -v "^\s*$" | sed -u -E '...' > tail.log

# 2. force line buffering on any program
... | stdbuf -oL grep -v "^\s*$" > tail.log

# 3. don't filter live at all: write raw output, filter afterwards
timeout 1200 npx wrangler tail leads-forms --format json > tail.jsonl

I went with the third. --format json gives one structured event per request, written straight to the file with no filters in between. Afterwards a few lines of Python pull out the method, path, outcome and log messages. It’s also better for redaction: I filter when reading, so nothing depends on a regex running correctly in real time.

The same family

This is the shell version of a trap from the same week: a Python service in Docker showed empty docker logs because Python block-buffers stdout when it isn’t a terminal (item 14 in my Cloudflare and Docker debugging notes). Same cause, different program. When output is “missing”, ask first whether it’s buffered.

Trap 2: pkill -f killed my own shell

To stop the background tail and a local preview server, I ran a cleanup like:

pkill -f "wrangler tail leads-forms"; do_the_next_thing

The whole command exited with code 144 and the next step never ran. Later, the same thing happened with:

pkill -f "astro preview --port 4329"; python3 -m http.server 4329 &

Why

pkill -f and pgrep -f match against the full command line of every process. My tools run each command as bash -c "<the whole command>", so the shell executing my cleanup had a command line that contained the very pattern I was matching. pkill found the target, and also found its own parent shell, and killed both. Any exit code above 128 means “died from a signal” (128 + the signal number), not a normal failure. (A plain SIGTERM, pkill’s default, shows up as 143; my tool wrapper reported 144, so take the exact number with a grain of salt. The point is the > 128.)

The same happens with for p in $(pgrep -f "…"); do kill $p; done: the pattern appears in the loop’s own shell.

Fixes

# 1. kill by port, when it's a server
fuser -k 4329/tcp

# 2. keep the PID when you start something, kill exactly that
python3 -m http.server 4329 & echo $! > server.pid
kill "$(cat server.pid)"

# 3. the bracket trick: the regex [w]rangler matches "wrangler",
#    but the literal text "[w]rangler" in your own command line doesn't
pkill -f "[w]rangler tail leads-forms"

For servers I now use fuser -k <port>/tcp: it targets exactly what holds the port. For background jobs, the cleanest option is to never need a kill: give them a timeout.

Habits for background log watching

What I do now when I want to catch one event in the logs:

  • Always put a timeout on a tail. timeout 900 npx wrangler tail … stops on its own after 15 minutes. No forgotten process, no cleanup, no pkill.
  • Start the watcher before asking for the action. In one test, the watcher started at the same second the request came in and missed it. Start it, wait for it to connect, then trigger the event.
  • Write raw, filter later. JSON lines to a file; read and redact afterwards.
  • Delete the log when done. Request logs from a forms endpoint contain personal data: addresses, sometimes messages. Once the test is answered, the file goes. They live in a scratch folder anyway, never in a repository.
  • Prefer the data store over logs when it can answer. Whether a subscription reached confirmed is easier to check in the database than to reconstruct from log lines. Logs explain failures; the data shows the outcome.

Takeaways

  • Output missing from a pipe or file? Suspect buffering: --line-buffered, stdbuf -oL, sed -u, PYTHONUNBUFFERED=1.
  • Filtering live is fragile. Capture raw output, filter after.
  • pkill -f and pgrep -f can match the shell running them. Kill by port, by saved PID, or use the [p]attern trick.
  • An exit code above 128 (I saw 144) means killed by a signal, not a normal failure.
  • Background watchers get a timeout, and their logs get deleted afterwards.

Previous: testing form states with Playwright route mocking.

Next: validate Mermaid diagrams before you push them.