<?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>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;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;a href="https://github.com/gritfeld-design/workerdeck-" rel="noopener noreferrer"&gt;WorkerDeck&lt;/a&gt;, so I (and maybe you)&lt;br&gt;
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;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, 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>
