- 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, nopkill. - 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
confirmedis 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 -fandpgrep -fcan match the shell running them. Kill by port, by saved PID, or use the[p]atterntrick.- 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.