ps, top and lsof tell you what is running. witr tells you why. Point it at a process, PID, port, container or file and it traces the chain that explains it - systemd, supervisor, shell or cron - plus who started it, when, from where, and the warnings worth knowing. Run it bare for an interactive TUI with Processes, Ports, Containers and Locks tabs. Or script it: --short for a one-line chain, --json with real exit codes. One static Go binary for Linux, macOS, Windows and BSD.
traces host-level ancestry through container shims correctly (1)
single static binary, no deps, and it actually walks through container shims to the real host process instead of stopping at 'docker started this'. maintainer is also filing real bugs live off comments during launch, which says a lot.
What needs improvement
the ancestry walk doesn't validate start-time on the parent PID yet, so there's a theoretical PID-reuse edge case in the chain itself, already filed as an issue though.
Hey Product Hunt!
I built witr out of a recurring annoyance: you find something running - a process eating CPU, a port that's already bound, a container you don't remember starting - and every tool tells you what it is, but nothing tells you why it's there.
ps, top, lsof and systemctl all answer "what". witr answers "why". Give it a name, PID, port, container or file and it walks the ancestry chain back to whatever is actually responsible - a systemd unit, a supervisor, a cron job, a shell session, a container runtime - and shows who started it, when, from where, and anything worth knowing (running as root, bound to 0.0.0.0, deleted binary, LD_PRELOAD set, restarting in a loop).
Run it with no arguments and you get an interactive TUI with Processes, Ports, Containers and Locks tabs, live search and an ancestry side panel. Or script it: --short gives a one-line chain and --json comes with meaningful exit codes.
It's a single static Go binary, Apache-2.0, running on Linux, macOS, Windows and FreeBSD. Every operation is read-only, so it's safe to point at production.
If you'd rather try before installing, there's a browser playground with a guided tutorial: https://pranshuparmar.github.io/...
Would love feedback.
Report
The thing that'll decide whether I keep it is what it prints when the chain dead ends. A process reparented to init after its parent died, or something started inside a container runtime that hides the real caller, and the honest output there is that it can't tell you. Tools like this get uninstalled the moment they guess once and someone acts on the guess. The --json exit codes are the right instinct, so give the unknown case its own code instead of folding it into success.
@asadmalik901Ā Thanks for the detailed use case. Two things here, since witr treats them differently. Zombies are handled, a defunct process shows a [zombie] marker and a warning. But a live process that lost its parent isn't correctly handled. It prints shell as source which is inaccurate. I've filed a bug in the repo to handle this correctly. Thanks again.
Report
@pranshuparmarĀ Filing it the same day is more than I expected, thanks. The reason it's worth more than a cosmetic fix is that printing shell is a confident wrong answer, so I stop digging, where unknown would have kept me looking. If the fix ends up being a label change rather than real ancestry recovery, that's still most of the value for me.
@asadmalik901Ā And I completely agree with you on this Adad. This was a valuable input, thanks again.
Report
@pranshuparmarĀ One thing still open from my first comment: the exit code. If the label changes to unknown but --json still exits 0, any script using witr as a check keeps passing on exactly the case it couldn't answer. A third code, 0 resolved, 1 error, 2 can't determine, is a few lines and it's what makes the honesty machine readable.
Report
The Locks tab caught my eye. Does it cover advisory file locks (flock/fcntl) alongside mandatory ones, or is it scoped to one type? I work with Node.js processes sharing a SQLite file across workers, and the symptoms are usually slow queries rather than visible contention. Knowing whether witr can show which worker holds a read vs write lock on a specific file path would be the thing that saves me reaching for strace.
@hi_i_am_mimoĀ Thanks, good question. I tested this on your exact setup before answering.
Advisory, both kinds. On Linux the Locks tab reads /proc/locks, so fcntl/POSIX byte-range locks and BSD flock locks both appear, and the Type column tells them apart. No separate mandatory category. witr shows locks that are held, not workers blocked waiting.
Read vs write per worker: yes. Two Node workers on one SQLite file, one in a read txn, one in a write txn:
PID Process Type Mode Path
8964 node POSIX READ .../app.db
8972 node POSIX WRITE .../app.db
Report
Filing Asad's bug within hours of him raising it says more about where this tool will be in a year than the feature list does.
One push on the product itself. The chain answers what started it. It does not answer why, and you have named the tool after the harder claim.
systemd started it because a unit file says so. The real why is whether anyone still wants it running, and that lives outside the box entirely.
So the output I would value most is the inverse of ancestry. This is running, and nothing on this machine explains why it still should be. Started from a shell session that ended months ago. Unit enabled by someone who has left. Container from a compose file that is no longer in the repo.
That is the thing I actually go hunting for at 2am, and nothing tells me.
Is orphan detection something the ancestry data you already collect could support?
@rabnoor_sĀ Thank you for the kind words and a very fair challenge.
The name came from frustration rather than a claim. The moment it's built for is "why the hell is this port occupied", "why is this even running", and the ancestry chain happened to answer that version of why well enough that the two blended and thus I gave this name.
To answer your other questions, today witr stops at the chain. I made an orphan to check, and it just reports Source: process_api (init) with no warning. But the data to do better is largely already collected: session id and tty sit in the /proc/<pid>/stat slice I parse and currently skip, so checking whether the session leader still exists would give you the dead-shell case directly. systemd may need additional logic, I'll investigate more on this and file issue to enhance the logic.
Thanks again for in-depth technical challenge.
Report
You went and made an orphan to check. That is the part most people skip, and it is why this thread has been worth having.
One subtlety before you build it, because it is what will generate the false positives that get the feature switched off.
A missing session leader does not always mean something went wrong. nohup, disown, setsid and tmux all deliberately detach a process, and those are healthy. The signal you want is not "the session leader is gone", it is "the session leader is gone and nobody meant that".
Deliberate detachment usually leaves a trace. setsid gives a process its own session id equal to its pid, and there is typically no controlling tty from the very start. An accidental orphan looks different: it had a tty, and a session that has since disappeared.
If witr can tell those two apart, the warning is trustworthy and people will leave it on. If it cannot, every tmux user turns it off inside a week.@pranshuparmarĀ
@rabnoor_sĀ Valid points, I'll take care of all these during implementation. Thanks again.
Report
The PID reuse case is the one I'd want to understand. Linux recycles PIDs pretty fast under load, so if I point witr at a PID that already exited and got reused by something unrelated, does it know the two are different processes, or could it walk an ancestry chain that's actually stitched together from two different processes that happened to share a number?
@raffay_sajjadĀ All the actions in TUI are properly guarded, it compares process start time before a kill or renice, so a signal can't land on a recycled PID. The ancestry walk could be improved here, it walks ppid with loop protection but no start-time validation. So the risk does exists for the parent PID. I've filed an issue to handle this appropriately, and thank you for your comment.
Report
@pranshuparmarĀ Good to hear the parent PID case is already filed. that's the one that would've bitten someone in a container teardown scenario, glad it's on the list.
Report
How do you handle cases where a process has been re-parented, daemonized, or launched through several abstraction layers? Iām curious how WITR communicates uncertainty when it can reconstruct only part of the original startup chain.
@mrbrjanĀ A daemon reparented to PID 1 still resolves correctly, because the owning unit is read from the process cgroup rather than the parent walk:
Service : cron.service
Why It Exists :
systemd (pid 1) ā cron (pid 11300)
Source : cron.service (systemd)
Unit File : /usr/lib/systemd/system/cron.service
The chain there is degraded to systemd ā cron, but the real cause is still recovered. Containers work the same way, the cgroup identifies it through the runtime layers.
Where it falls down is when a bare orphan with no supervisor behind it. Then it walks ppid, lands on PID 1, and reports whatever is sitting there as the source, which is inaccurate. The issue to handle this exists.
Thank you for your comment and hope you personally try out witr.
Report
ps and lsof telling me what but never why is a decent chunk of my debugging life. Two questions. Does the Containers tab pick up things Docker Desktop started, and does it do anything sensible under WSL2 where there's no systemd to trace back to? That's usually where I lose the thread.
Docker Desktop containers are picked up. Container detection goes through the Docker API rather than the local process table, so it works even though Docker Desktop runs containers in its own VM where those PIDs don't exist in your distro at all:
Container : witr-dd-test (id d0a9b15ffb6f)
Image : alpine
Why It Exists :
docker ā witr-dd-test
Source : docker
Note : The owning process is not visible in this environment.
That last line is deliberate. When the processes live in the Docker Desktop VM it tells you, rather than inventing a chain.
On systemd, I ran it inside the docker-desktop distro, which has no systemd at all and PID 1 is init, and it resolved the source from the cgroup:
Thanks for the question, and I hope you try it out.
Report
@dale_mooneyĀ @pranshuparmarĀ Appreciate you actually running it rather than telling me it should work. That's more than I expected and it answers both questions.
The line I'd single out is "The owning process is not visible in this environment." Most tools hitting that wall pick one of two bad options: invent a plausible chain, or return nothing and let you assume. Saying which side of the wall you're on is the whole value, and it's why the cgroup fallback is the impressive part rather than the container detection. You resolved the source without pretending you could see the owner.
One follow up, for the scripted path rather than the TUI, since that's how I'd consume it. Is that state distinct in --json and in the exit codes? Can a caller tell apart no owner exists, an owner exists but is unreachable from here, and the lookup itself failed? Those three want different handling, and if they collapse into one empty field the guessing just moves up a layer to where nobody can see it happening.
So the json output is either a valid JSON or empty output with exit code. But the main concern that you raised stands, owner unreachable returns a JSON with exit code 0 is indistinguishable from a normal response with exit 0 unless you parse the JSON to extract 'Note' field which isn't the right way. I'll enhance error codes to handle this case distinctly.
Report
@dale_mooneyĀ @pranshuparmarĀ That table is exactly the answer I wanted, and you've spotted the same thing I was circling: the dangerous case is the one that currently looks like success.
One suggestion on the shape of the fix, since exit codes are hard to change once people script against them. Keep 0 meaning fully resolved and give unreachable its own code, rather than widening what 0 covers. That way the naive `if witr --json ...; then` stays honest for people who never read the docs, which is most of us.
And whatever code you land on, it's worth putting the same state in the JSON as a machine field rather than only in Note, something like resolution: unreachable. Otherwise the shell caller and the JSON caller are reading two different sources of truth about the same run, and they'll drift.
witr
The thing that'll decide whether I keep it is what it prints when the chain dead ends. A process reparented to init after its parent died, or something started inside a container runtime that hides the real caller, and the honest output there is that it can't tell you. Tools like this get uninstalled the moment they guess once and someone acts on the guess. The --json exit codes are the right instinct, so give the unknown case its own code instead of folding it into success.
witr
@asadmalik901Ā Thanks for the detailed use case. Two things here, since witr treats them differently. Zombies are handled, a defunct process shows a [zombie] marker and a warning. But a live process that lost its parent isn't correctly handled. It prints shell as source which is inaccurate. I've filed a bug in the repo to handle this correctly. Thanks again.
@pranshuparmarĀ Filing it the same day is more than I expected, thanks. The reason it's worth more than a cosmetic fix is that printing shell is a confident wrong answer, so I stop digging, where unknown would have kept me looking. If the fix ends up being a label change rather than real ancestry recovery, that's still most of the value for me.
witr
@asadmalik901Ā And I completely agree with you on this Adad. This was a valuable input, thanks again.
@pranshuparmarĀ One thing still open from my first comment: the exit code. If the label changes to unknown but --json still exits 0, any script using witr as a check keeps passing on exactly the case it couldn't answer. A third code, 0 resolved, 1 error, 2 can't determine, is a few lines and it's what makes the honesty machine readable.
The Locks tab caught my eye. Does it cover advisory file locks (flock/fcntl) alongside mandatory ones, or is it scoped to one type? I work with Node.js processes sharing a SQLite file across workers, and the symptoms are usually slow queries rather than visible contention. Knowing whether witr can show which worker holds a read vs write lock on a specific file path would be the thing that saves me reaching for strace.
witr
@hi_i_am_mimoĀ Thanks, good question. I tested this on your exact setup before answering.
Advisory, both kinds. On Linux the Locks tab reads /proc/locks, so fcntl/POSIX byte-range locks and BSD flock locks both appear, and the Type column tells them apart. No separate mandatory category. witr shows locks that are held, not workers blocked waiting.
Read vs write per worker: yes. Two Node workers on one SQLite file, one in a read txn, one in a write txn:
Filing Asad's bug within hours of him raising it says more about where this tool will be in a year than the feature list does.
One push on the product itself. The chain answers what started it. It does not answer why, and you have named the tool after the harder claim.
systemd started it because a unit file says so. The real why is whether anyone still wants it running, and that lives outside the box entirely.
So the output I would value most is the inverse of ancestry. This is running, and nothing on this machine explains why it still should be. Started from a shell session that ended months ago. Unit enabled by someone who has left. Container from a compose file that is no longer in the repo.
That is the thing I actually go hunting for at 2am, and nothing tells me.
Is orphan detection something the ancestry data you already collect could support?
witr
@rabnoor_sĀ Thank you for the kind words and a very fair challenge.
The name came from frustration rather than a claim. The moment it's built for is "why the hell is this port occupied", "why is this even running", and the ancestry chain happened to answer that version of why well enough that the two blended and thus I gave this name.
To answer your other questions, today witr stops at the chain. I made an orphan to check, and it just reports Source: process_api (init) with no warning. But the data to do better is largely already collected: session id and tty sit in the /proc/<pid>/stat slice I parse and currently skip, so checking whether the session leader still exists would give you the dead-shell case directly. systemd may need additional logic, I'll investigate more on this and file issue to enhance the logic.
Thanks again for in-depth technical challenge.
You went and made an orphan to check. That is the part most people skip, and it is why this thread has been worth having.
One subtlety before you build it, because it is what will generate the false positives that get the feature switched off.
A missing session leader does not always mean something went wrong. nohup, disown, setsid and tmux all deliberately detach a process, and those are healthy. The signal you want is not "the session leader is gone", it is "the session leader is gone and nobody meant that".
Deliberate detachment usually leaves a trace. setsid gives a process its own session id equal to its pid, and there is typically no controlling tty from the very start. An accidental orphan looks different: it had a tty, and a session that has since disappeared.
If witr can tell those two apart, the warning is trustworthy and people will leave it on. If it cannot, every tmux user turns it off inside a week.@pranshuparmarĀ
witr
@rabnoor_sĀ Valid points, I'll take care of all these during implementation. Thanks again.
The PID reuse case is the one I'd want to understand. Linux recycles PIDs pretty fast under load, so if I point witr at a PID that already exited and got reused by something unrelated, does it know the two are different processes, or could it walk an ancestry chain that's actually stitched together from two different processes that happened to share a number?
witr
@raffay_sajjadĀ All the actions in TUI are properly guarded, it compares process start time before a kill or renice, so a signal can't land on a recycled PID. The ancestry walk could be improved here, it walks ppid with loop protection but no start-time validation. So the risk does exists for the parent PID. I've filed an issue to handle this appropriately, and thank you for your comment.
@pranshuparmarĀ Good to hear the parent PID case is already filed. that's the one that would've bitten someone in a container teardown scenario, glad it's on the list.
witr
@mrbrjanĀ A daemon reparented to PID 1 still resolves correctly, because the owning unit is read from the process cgroup rather than the parent walk:
The chain there is degraded to systemd ā cron, but the real cause is still recovered. Containers work the same way, the cgroup identifies it through the runtime layers.
Where it falls down is when a bare orphan with no supervisor behind it. Then it walks ppid, lands on PID 1, and reports whatever is sitting there as the source, which is inaccurate. The issue to handle this exists.
Thank you for your comment and hope you personally try out witr.
ps and lsof telling me what but never why is a decent chunk of my debugging life. Two questions. Does the Containers tab pick up things Docker Desktop started, and does it do anything sensible under WSL2 where there's no systemd to trace back to? That's usually where I lose the thread.
witr
@dalemooneyĀ @dale_mooney Real results from Windows + WSL2 with Docker Desktop running.
Docker Desktop containers are picked up. Container detection goes through the Docker API rather than the local process table, so it works even though Docker Desktop runs containers in its own VM where those PIDs don't exist in your distro at all:
That last line is deliberate. When the processes live in the Docker Desktop VM it tells you, rather than inventing a chain.
On systemd, I ran it inside the docker-desktop distro, which has no systemd at all and PID 1 is init, and it resolved the source from the cgroup:
Thanks for the question, and I hope you try it out.
@dale_mooneyĀ @pranshuparmarĀ Appreciate you actually running it rather than telling me it should work. That's more than I expected and it answers both questions.
The line I'd single out is "The owning process is not visible in this environment." Most tools hitting that wall pick one of two bad options: invent a plausible chain, or return nothing and let you assume. Saying which side of the wall you're on is the whole value, and it's why the cgroup fallback is the impressive part rather than the container detection. You resolved the source without pretending you could see the owner.
One follow up, for the scripted path rather than the TUI, since that's how I'd consume it. Is that state distinct in --json and in the exit codes? Can a caller tell apart no owner exists, an owner exists but is unreachable from here, and the lookup itself failed? Those three want different handling, and if they collapse into one empty field the guessing just moves up a layer to where nobody can see it happening.
Installing it either way.
witr
@dale_mooneyĀ Thank you for the appreciation and a great follow up indeed. Here's what happens today:
So the json output is either a valid JSON or empty output with exit code. But the main concern that you raised stands, owner unreachable returns a JSON with exit code 0 is indistinguishable from a normal response with exit 0 unless you parse the JSON to extract 'Note' field which isn't the right way. I'll enhance error codes to handle this case distinctly.
@dale_mooneyĀ @pranshuparmarĀ That table is exactly the answer I wanted, and you've spotted the same thing I was circling: the dangerous case is the one that currently looks like success.
One suggestion on the shape of the fix, since exit codes are hard to change once people script against them. Keep 0 meaning fully resolved and give unreachable its own code, rather than widening what 0 covers. That way the naive `if witr --json ...; then` stays honest for people who never read the docs, which is most of us.
And whatever code you land on, it's worth putting the same state in the JSON as a machine field rather than only in Note, something like resolution: unreachable. Otherwise the shell caller and the JSON caller are reading two different sources of truth about the same run, and they'll drift.
Good luck with the rest of launch day.