<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: gritfeld-design</title>
    <description>The latest articles on DEV Community by gritfeld-design (@gritfelddesign).</description>
    <link>https://dev.to/gritfelddesign</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4016650%2F5084441e-d21f-421f-82cf-fb7769ad91db.png</url>
      <title>DEV Community: gritfeld-design</title>
      <link>https://dev.to/gritfelddesign</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/gritfelddesign"/>
    <language>en</language>
    <item>
      <title>WorkerDeck is now on PyPI (and its own test suite fell into the zombie trap)</title>
      <dc:creator>gritfeld-design</dc:creator>
      <pubDate>Sun, 12 Jul 2026 20:02:10 +0000</pubDate>
      <link>https://dev.to/gritfelddesign/workerdeck-is-now-on-pypi-and-its-own-test-suite-fell-into-the-zombie-trap-5cd5</link>
      <guid>https://dev.to/gritfelddesign/workerdeck-is-now-on-pypi-and-its-own-test-suite-fell-into-the-zombie-trap-5cd5</guid>
      <description>&lt;p&gt;A few days ago I wrote about the bug that taught me how to run one process&lt;br&gt;
per user&lt;br&gt;
and extracted the solution into a small library called&lt;br&gt;
WorkerDeck.&lt;br&gt;
Since then it grew up a little. As of today:&lt;br&gt;
bashpip install workerdeck&lt;br&gt;
That's it. No git clone, no requirements.txt. And here's the part I only&lt;br&gt;
realized while packaging it: the core is pure Python standard library.&lt;br&gt;
Zero dependencies. The Flask dashboard I ship in the repo is just a demo —&lt;br&gt;
the engine itself is one class built on subprocess, os, signal and&lt;br&gt;
json.&lt;br&gt;
What changed&lt;br&gt;
Three things, all boring, all the kind of boring that makes a library&lt;br&gt;
trustworthy:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Proper packaging. A pyproject.toml, a clean wheel, optional extras
(pip install "workerdeck[dashboard]" if you want the demo UI). The library
installs exactly two files: &lt;strong&gt;init&lt;/strong&gt;.py and manager.py.&lt;/li&gt;
&lt;li&gt;A test suite that tests the actual claims. WorkerDeck's whole pitch
is "safe to restart": your workers keep running while the manager restarts,
and the new manager re-adopts them instead of reporting them dead. So the
tests exercise exactly that — a second manager instance on the same
work_root must find the live worker, match it by pid and start time,
and be able to stop it. Plus the crash circuit breaker: a worker that exits
non-zero on every start must end up parked in a failed state, not
hot-looping forever.&lt;/li&gt;
&lt;li&gt;CI. Every push now runs the suite on Python 3.9 through 3.12. Green
badge in the README, the universal sign of "someone actually runs this."
The test suite fell into my own trap
Here's my favorite part. In the original article I wrote about the zombie
process trap: os.kill(pid, 0) happily reports a defunct (zombie) process
as alive, so a naive liveness check tells you a dead worker is still
running.
The first version of my test suite used... a naive liveness check.
The adoption test kills a worker behind the manager's back, then asserts a
fresh manager won't claim it's running. The kill worked. The assertion
failed. Why? The killed worker became a zombie — its Popen handle still
lived in the test process and nothing had reaped it — and my little
pid_alive() helper, built on os.kill(pid, 0), swore the corpse was
alive.
The library handles this correctly (its internal check reads the process
state and treats Z as dead). My test helper didn't. The fix was
satisfying: the tests now import the library's own liveness check, which
means the zombie handling is no longer just documented — it's load-bearing.
If it ever regresses, two tests fail immediately.
I wrote an article warning about a trap, then walked into it the same week
while testing the library that exists because of it. Process management
stays humbling.
Try it
pythonfrom workerdeck import WorkerManager&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;mgr = WorkerManager(worker_script="worker.py", work_root="./work")&lt;br&gt;
mgr.start(user_id=42)          # isolated process, own workdir, own log&lt;br&gt;
print(mgr.status(42).state)    # "running"&lt;/p&gt;

&lt;h1&gt;
  
  
  ...restart your app, rebuild the manager...
&lt;/h1&gt;

&lt;p&gt;mgr2 = WorkerManager(worker_script="worker.py", work_root="./work")&lt;br&gt;
print(mgr2.status(42).state)   # still "running" — same pid, re-adopted&lt;/p&gt;

&lt;p&gt;Install: pip install workerdeck&lt;br&gt;
Repo: &lt;a href="https://github.com/gritfeld-design/workerdeck" rel="noopener noreferrer"&gt;https://github.com/gritfeld-design/workerdeck&lt;/a&gt;&lt;br&gt;
Backstory: The bug that taught me how to run one process per user&lt;/p&gt;

&lt;p&gt;It's 0.1.0 and the scope is deliberately small: single host, Linux/POSIX,&lt;br&gt;
one class. If that's the shape of your problem, it might save you the week&lt;br&gt;
of pid-recycling lessons it cost me. Issues and war stories welcome.&lt;/p&gt;

</description>
      <category>python</category>
      <category>opensource</category>
      <category>devops</category>
      <category>testing</category>
    </item>
    <item>
      <title>The bug that taught me how to run one process per user</title>
      <dc:creator>gritfeld-design</dc:creator>
      <pubDate>Sun, 05 Jul 2026 20:15:56 +0000</pubDate>
      <link>https://dev.to/gritfelddesign/the-bug-that-taught-me-how-to-run-one-process-per-user-1i2n</link>
      <guid>https://dev.to/gritfelddesign/the-bug-that-taught-me-how-to-run-one-process-per-user-1i2n</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update (July 2026):&lt;/strong&gt; WorkerDeck is now on PyPI — &lt;code&gt;pip install workerdeck&lt;/code&gt;.&lt;br&gt;
Zero dependencies, a real test suite, CI on Python 3.9–3.12. The follow-up&lt;br&gt;
post covers what changed, including how my own test suite walked straight&lt;br&gt;
into the zombie trap described below:&lt;br&gt;
&lt;a href="https://dev.to/gritfelddesign/workerdeck-is-now-on-pypi-and-its-own-test-suite-fell-into-the-zombie-trap-5cd5"&gt;WorkerDeck is now on PyPI&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I was building a platform where every user runs their own long-lived&lt;br&gt;
background process. Doesn't matter what the process does — an agent, a&lt;br&gt;
pipeline, a scheduled job. What matters is that each user gets their own,&lt;br&gt;
it runs for days, and I need to start it, watch it, and stop it from one&lt;br&gt;
place.&lt;/p&gt;

&lt;p&gt;The lifecycle code for that turned out to be the part that bit me. Not the&lt;br&gt;
hard, interesting part of my product — the boring plumbing around it. I want&lt;br&gt;
to tell you about the specific bug that cost me an afternoon, because if&lt;br&gt;
you're building anything similar, you &lt;em&gt;will&lt;/em&gt; hit it, and it's not obvious&lt;br&gt;
until you do.&lt;/p&gt;
&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;My manager did the obvious thing. It kept a dictionary of the processes it&lt;br&gt;
had started — user id to process handle — and answered "is this user's worker&lt;br&gt;
running?" by looking in that dictionary. Start a worker, put it in the dict.&lt;br&gt;
Stop it, take it out. Check status, look it up. Clean, simple, worked in&lt;br&gt;
testing.&lt;/p&gt;

&lt;p&gt;It worked in production too. Right up until I deployed a change.&lt;/p&gt;
&lt;h2&gt;
  
  
  The afternoon it went wrong
&lt;/h2&gt;

&lt;p&gt;After the deploy, my dashboard told me every user's worker was stopped. That&lt;br&gt;
was alarming, because the workers were clearly still &lt;em&gt;doing things&lt;/em&gt; — I could&lt;br&gt;
see their output, their side effects, their logs ticking over. But the&lt;br&gt;
control panel showed them all as down.&lt;/p&gt;

&lt;p&gt;So I did the natural thing: I clicked "start" on one.&lt;/p&gt;

&lt;p&gt;Now I had two copies of that user's worker running, fighting each other for&lt;br&gt;
the same port. One of them started throwing "address already in use." I&lt;br&gt;
clicked "stop" on another — and nothing happened. The worker kept running.&lt;br&gt;
The button did nothing.&lt;/p&gt;

&lt;p&gt;I spent a while convinced something was deeply broken. It wasn't. The bug&lt;br&gt;
was much dumber than that, and much more instructive.&lt;/p&gt;
&lt;h2&gt;
  
  
  The actual problem
&lt;/h2&gt;

&lt;p&gt;When I deployed, the manager process restarted. And that in-memory dictionary&lt;br&gt;
of process handles? It lives in the manager's memory. A restart wipes it.&lt;/p&gt;

&lt;p&gt;The workers themselves were fine — they're separate processes, they don't&lt;br&gt;
care that the thing that launched them blinked. But the &lt;em&gt;new&lt;/em&gt; manager came up&lt;br&gt;
with an empty dictionary. It had amnesia. It had no idea those workers&lt;br&gt;
existed, so it reported them all as stopped. When I clicked "start," it&lt;br&gt;
happily launched a second copy, because as far as it knew there was no first&lt;br&gt;
copy. When I clicked "stop," it looked in its empty dictionary, found nothing,&lt;br&gt;
and did nothing.&lt;/p&gt;

&lt;p&gt;Every symptom I saw traced back to one root cause: &lt;strong&gt;the manager tracked its&lt;br&gt;
workers only in memory, and memory doesn't survive a restart.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Once you see it, it's obvious. But it's invisible until the day you restart&lt;br&gt;
the manager while workers are running — which, if you deploy, is every day.&lt;/p&gt;
&lt;h2&gt;
  
  
  Fixing it properly
&lt;/h2&gt;

&lt;p&gt;The fix is to write down what's running somewhere that survives a restart, and&lt;br&gt;
to re-attach to those processes when the manager comes back up. So the manager&lt;br&gt;
now keeps a small registry on disk: for each user, the process id of their&lt;br&gt;
worker.&lt;/p&gt;

&lt;p&gt;On startup, it reads that registry, and for each entry checks: is a process&lt;br&gt;
with this pid still alive? If yes, adopt it — treat it as a running worker I'm&lt;br&gt;
responsible for again.&lt;/p&gt;

&lt;p&gt;That's the idea. But there's a trap in it that's worth slowing down for.&lt;/p&gt;
&lt;h2&gt;
  
  
  The trap: pids get recycled
&lt;/h2&gt;

&lt;p&gt;You cannot just check "is a process with this pid alive?" and call it done.&lt;br&gt;
Operating systems reuse pid numbers. The pid that was your worker yesterday&lt;br&gt;
might belong to something completely unrelated today — a database, a cron job,&lt;br&gt;
anything. If you blindly re-adopt whatever now holds that pid, you'll "manage"&lt;br&gt;
a process that isn't yours, and eventually send a stop signal to some innocent&lt;br&gt;
bystander.&lt;/p&gt;

&lt;p&gt;A live pid is not proof it's &lt;em&gt;your&lt;/em&gt; process.&lt;/p&gt;

&lt;p&gt;What makes it proof is the pid &lt;strong&gt;plus the process start time.&lt;/strong&gt; If I recorded&lt;br&gt;
that my worker was pid 4021 started at 14:03:07, and on restart I find a pid&lt;br&gt;
4021 that also started at 14:03:07, that's my worker — the odds of a recycled&lt;br&gt;
pid coincidentally sharing a start time are effectively nil. If the start time&lt;br&gt;
differs, it's an impostor, and I leave it alone.&lt;/p&gt;

&lt;p&gt;That check — pid plus start time — is the difference between "usually works"&lt;br&gt;
and "safe to restart." It's a few lines of code, and it's the whole ballgame.&lt;/p&gt;
&lt;h2&gt;
  
  
  The bonus trap: zombies
&lt;/h2&gt;

&lt;p&gt;While testing the fix, I hit a second one. I killed a worker, then asked the&lt;br&gt;
manager to stop it, and the stop call hung there insisting the worker was&lt;br&gt;
still alive. But I'd just killed it. What?&lt;/p&gt;

&lt;p&gt;Zombies. On Linux, when a process exits but its parent hasn't yet collected&lt;br&gt;
its exit status, it lingers as a "defunct" process — a zombie. It's not&lt;br&gt;
running, it does nothing, it holds no resources. But a naive liveness check&lt;br&gt;
("does this pid exist?") says &lt;em&gt;yes, it exists&lt;/em&gt; — because technically the&lt;br&gt;
zombie entry is still there.&lt;/p&gt;

&lt;p&gt;This shows up exactly in the re-attach case, because the original parent (the&lt;br&gt;
old manager) is gone, so nobody's around to reap the zombie. My liveness check&lt;br&gt;
had to learn to read the process state and treat a zombie as dead. Otherwise&lt;br&gt;
"stop" waits forever for a worker that already left.&lt;/p&gt;

&lt;p&gt;Another few lines. Another thing you only learn by hitting it.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I ended up with
&lt;/h2&gt;

&lt;p&gt;Once I'd solved this for my own platform, I realised the whole lifecycle layer&lt;br&gt;
was generic — none of it was specific to what my workers actually did. So I&lt;br&gt;
pulled it out into a small library,&lt;br&gt;
&lt;a href="https://github.com/gritfeld-design/workerdeck" rel="noopener noreferrer"&gt;WorkerDeck&lt;/a&gt;, so I (and maybe&lt;br&gt;
you) never have to rediscover these traps from scratch.&lt;/p&gt;

&lt;p&gt;It's deliberately small: one class, one host, no orchestration layer. It gives&lt;br&gt;
you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zero dependencies&lt;/strong&gt; — the core is pure Python standard library.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One isolated process per user&lt;/strong&gt; — own directory, own config injected as
environment variables, own log.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Safe start / stop / restart&lt;/strong&gt; — graceful SIGTERM escalating to SIGKILL,
signalling the whole process group so children die too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Survival of its own restart&lt;/strong&gt; — the whole point of this post. Workers keep
running across a manager restart, and the manager re-adopts them by pid and
start time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optional self-healing&lt;/strong&gt; — turn it on and crashed workers auto-restart, with
exponential backoff and a circuit breaker so a worker that crashes on
startup can't spin forever; after too many crashes it's parked in a "failed"
state for a human instead of hot-looping.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An events hook&lt;/strong&gt; — a callback fired on every meaningful change (started,
stopped, crashed, restarted, failed, adopted) so you can wire up logging or
alerting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A small dashboard&lt;/strong&gt; — so you can see and drive it all without building a UI
first.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's not a Kubernetes replacement and doesn't try to be. It solves the problem&lt;br&gt;
you actually have when you're running per-user processes on one box — which is&lt;br&gt;
where most projects start, and where these failure modes (orphans, zombies,&lt;br&gt;
ignored stop signals, and the restart amnesia that started this whole story)&lt;br&gt;
show up long before you need anything bigger.&lt;/p&gt;

&lt;p&gt;If any of the symptoms above sounded familiar:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;workerdeck
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The code is on &lt;a href="https://github.com/gritfeld-design/workerdeck" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;br&gt;
Take it, and skip the afternoon I spent.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by Giulio Ritfeld. WorkerDeck is MIT-licensed — use it, change it,&lt;br&gt;
ship it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>devops</category>
      <category>tutorial</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
