Skip to content
DAdebuggingdotnetlinuxpks-cli

The Watcher You Didn't Start

A Turbopack build failed with 'OS file watch limit reached.' The real culprit wasn't Turbopack, or Codex, or Node — it was a config-reload watcher neither tool asked for.

It started as a Turbopack error inside an unrelated Next.js project, launched through pks-cli agentics runner start:

⚠ Module not found: Can't resolve 'jose'
⚠ ENOSPC: System limit for number of file watchers reached
   watch /workspaces/family-calender/.next/... ENOSPC

"Module not found" for a package that was clearly installed is the classic tell of a starved fs.inotify budget: Turbopack couldn't set up a watch to find the module, so it reported the module as missing instead of reporting the watcher as full. The fix wasn't in that project at all — it was three processes upstream, in the tool that launched it.

The shortcut I almost took

My first pass at this landed on the obvious lever: the container's inotify ceiling was measurably close to full, so just raise the ceiling. Here's the actual proposal, unedited:

Claudereply

Two ways to fix it:

  1. Immediate relief (this session): raise the limits with sudo sysctl -w fs.inotify.max_user_watches=4194304 fs.inotify.max_user_watches=1048576. Doesn't survive a container rebuild.
  2. Persist it: add fs.inotify.max_user_watches / max_user_instances bumps to the devcontainer's postCreate/init (or a mounted /etc/sysctl.d/ file) so it's set every time the container starts. Per AGENTS.md I shouldn't touch .devcontainer/ without you explicitly asking.

Want me to run the sysctl bump now for immediate relief, and/or wire up the persistent version in the devcontainer?

That would have "worked" — the build would have gone green again, and the actual cause would still be sitting there, quietly refilling the bigger ceiling the next time enough tools stacked up. The response I got back didn't take the offer:

human prompt
1 lines2026-07-13 19:23
lets understand first - is this expected or is somethign wrong.

That one line is the whole reason this post exists instead of a one-line sysctl patch. It's the difference between treating a symptom and finding out that a process was silently opening 555,186 watches it never needed.

Two more nudges before I found it

The sysctl offer wasn't the last theory I had talked out of me. My next pass concluded there wasn't a bug at all — just a lot of long-running watchers stacked up:

Claudereply

Not a code bug — it's environmental, and here's the concrete evidence:

What's actually happening: You have a long-running devcontainer with a lot of heavy file-watchers stacked up simultaneously:

  • 5 separate pks-cli/codex processes (some running since 02:35, others since 11:02–11:37 today) — each holding 100k–150k inotify watches
  • 2 VS Code server instances (~149k watches each) — likely two separate connections/windows into the same container
  • 4+ next-server processes across different projects (pks-claude-plugins/src/www, www-site, plus a couple more unidentified ones), each watching their own node_modules
  • Roslyn/C# DevKit watching the whole .NET solution

None of these look like leaked/zombie processes — they're all real, active processes (checked their /proc/*/fd and start times). They're just legitimately expensive watchers, and this repo is a large monorepo with dozens of node_modules trees (submodules, projects/, external/, etc.), so each watcher's footprint is large.

Why now: the container's fs.inotify.max_user_watches limit is 1,003,651. Between the existing watchers you already had ~700k+ watches allocated before you even started the runner. When the family-calender project's Next.js dev server (spun up by the runner) tried to add its own watch tree on top, it hit the ceiling and Turbopack's watch call failed — which it then misreported as a "module not found" error for jose. The module is fine; the watcher just couldn't attach.

Is it expected? Given how many concurrent dev sessions/IDE connections/codex processes you're running in one container, yes — this is the natural consequence of scale, not a regression in your code or in family-calender. The one thing worth a second look: 5 separate pks-cli codex processes is a lot to have accumulated (some may be stale sessions from hours ago that never exited) — if you don't need all of them still running, killing the old ones would free real headroom without touching any limits.

So: nothing's broken in the runner or family-calender; it's resource pressure from everything else you have open. Want me to (a) check which of those 5 codex/pks-cli processes are stale and safe to kill, and/or (b) raise the inotify limit as a cushion regardless?

That framing — "nothing's broken, it's just resource pressure" — would have closed the investigation right there. You weren't satisfied with a story; you wanted an accounting:

human prompt
1 lines2026-07-13 19:27
but i just think we have been runnning many more things earlier without problem, so could we make a list of exactly what uses waht so we understand

The exact numbers didn't just confirm the "it's crowded" story — they surfaced something inside it worth a second look:

Claudereply

Got exact numbers — you're not imagining it, and it's not a mystery. Current inotify usage is 1,003,596 / 1,003,651 = 100.0%. You're pinned at the ceiling right now.

ConsumerWatches% of totalWhat it is
pks-cli codex processes555,18655%pid 1796494 (223k, running 2h39m), 14836 (113k, 11h07m), 43379 (111k, 10h15m), 1770212 (107k, 2h44m) — each a separate codex resume/codex runner session
VS Code C# DevKit (2 procs, pid 16160/17211)298,54930%Roslyn/vs-green-server watching the whole .NET solution — normal for this repo's size
VS Code core file watcher (pid 9394)140,88314%The main VS Code Server workspace watcher
Next.js dev servers (2 running)8,4410.8%pks-claude-plugins/src/www + www-site
Aspire AppHost, dotnet, codex helper procs, Claude Code sessions (7 of them)~537~0.05%Negligible each

Why it worked before and not now: nothing changed about the limit or about any single process leaking. What changed is you've accumulated 4 separate pks-cli codex sessions over the course of today (11:07 this morning down to 2h39m ago) that were each left running instead of exiting, and each one apparently walks and watches a huge chunk of the monorepo — averaging ~139k watches per process. Those 4 alone account for 55% of your entire watch budget. Earlier today, with fewer of them open, you had headroom; as you started more codex/pks-cli sessions without closing old ones, the shared per-user budget (1,003,651 total, no per-process quota) filled up. The family-calender runner's Next.js dev server was simply the request that landed after the tank was already full.

Worth noting: it's a bit surprising a CLI wrapper (pks-cli codex) needs 100–220k watches each — that suggests it's recursively watching the full repo tree (including nested node_modules across projects//external/) rather than something scoped. That's the one item I'd call "maybe not intended" rather than "expected," but I don't have pks-cli's watcher code in front of me to confirm.

Given this, the highest-leverage fix isn't raising the limit — it's closing stale pks-cli codex sessions you're not actively using; that alone frees over half the budget. Want me to check which of those 4 you still need (e.g. by checking if their parent shell/tmux pane is idle) before suggesting which to kill?

That table still framed the fix as "close some sessions," not "there's a bug." One more question moved it from resource management to root cause:

human prompt
1 lines2026-07-13 19:31
interesting, can we see if its codex itself or its pks-cli ?

That's the question that sent me into /proc/<pid>/fdinfo looking for exact per-process ownership — which is where the process-tree trace below actually starts.

A shared, finite budget

Linux hands out inotify watches from a small set of per-user counters, not per-process ones. Every watcher any process on the box opens draws from the same pool:

  • fs.inotify.max_user_watches — total watches across every process owned by the uid
  • fs.inotify.max_user_instances — total open inotify file descriptors owned by the uid
  • fs.inotify.max_user_queued_events — pending events buffered per instance

On this devcontainer the ceiling was north of a million watches — and something had still burned through nearly all of it. The next question wasn't "raise the limit," it was "who is holding a million watches open."

How a watch actually attaches

The instinct is to picture one watch per file — a project with 350,000 files sounds like it should need 350,000 watches. That's not how inotify, or .NET's FileSystemWatcher on top of it, actually works: a recursive watch attaches one inotify watch per directory, and relies on that directory's own watch to report when a file inside it changes. Files are never watched individually.

A directory tree where every folder icon has a small glowing orange watch indicator, while the files inside each folder have none. One inotify watch per directory — files inherit visibility from the folder that contains them, they don't get watches of their own.

That's testable, not just theoretical. A minimal repro app pointed a recursive FileSystemWatcher at a real project tree, and the two counts landed exactly on top of each other:

  • 19,962 directories on disk
  • 19,962 inotify watches opened
  • 350,959 files — zero watches

One watch, one directory, every time. The files are invisible to inotify entirely — it's the parent directory's watch that fires when something inside it is created, removed, or renamed.

Whodunit: process tree, not guesswork

The fast way to find out which process holds a watch isn't to reason about the code, it's to ask the kernel directly. Every open inotify instance shows up as a symlink under /proc/<pid>/fd/ pointing at anon_inode:inotify, and /proc/<pid>/fdinfo/<fd> lists one inotify line per live watch on that instance — a precise, per-process count, no instrumentation required.

Of roughly 1,003,596 watches open against a ~1,003,651 ceiling, one process alone held 555,186 of them: pks-cli, running the Codex passthrough proxy. Walking the process tree (ps --ppid <pid> -o pid,ppid,cmd) confirmed the fd ownership: the Codex child process itself held nothing. The watch lived on the wrapper that launches Codex — not on Codex.

A single CLI process icon branching into a fractal explosion of tens of thousands of watched folder icons. One process, one directory tree walk on startup, tens of thousands of watches — before the tool has done anything the user asked it to do.

Root cause: a watcher nobody asked for

pks-cli's codex passthrough and its mcp start transports each spin up an ASP.NET Core WebApplication or Generic Host. What's easy to miss is that WebApplication.CreateSlimBuilder(), WebApplication.CreateBuilder(), and Host.CreateApplicationBuilder() all default ContentRootPath to the current working directory and register appsettings.json / appsettings.{Environment}.json config sources with reloadOnChange: true — regardless of whether an appsettings.json exists there at all. That default silently constructs a PhysicalFilesWatcher, which is a recursive FileSystemWatcher, rooted wherever the process happened to be launched from.

Launch pks codex or pks mcp start from a large JS repo — node_modules included — and that one line of framework default walks the entire tree and opens a watch per directory. Neither the Codex passthrough proxy nor the MCP transports read config from disk at runtime. The watcher was pure overhead: a recursive walk of a repo the process never intended to watch, paid for on every invocation.

The bug wasn't in Codex, and it wasn't a misconfiguration — it was a framework default with no off switch that anyone had reached for, because nobody had a reason to until the working directory got big enough to notice.

The fix

ASP.NET Core reads that behavior from a single config key, HostDefaults.ReloadConfigOnChangeKey (hostBuilder:reloadConfigOnChange), and it's checked early enough that it can be set via a command-line-style argument passed straight into the builder factory:

- var builder = WebApplication.CreateSlimBuilder(Array.Empty<string>());
+ var builder = WebApplication.CreateSlimBuilder(NoConfigReloadArgs);

  // NoConfigReloadArgs = ["--hostBuilder:reloadConfigOnChange=false"]

Applied to all four call sites — the Codex passthrough's WebApplication, and the MCP host's stdio, HTTP, and SSE builders — with zero behavior change for either feature, since neither one reads config at runtime.

Verified, not assumed

Same repro app, same directory, fix toggled on:

  • 19,962 watches — before
  • 0 watches — after
  • Build still green

Sampled every second for fifteen seconds after startup in both configurations. Unfixed, the count climbs and settles at exactly the directory count on disk. Fixed, it never leaves zero — the config-reload path simply never runs.

What's worth carrying forward

  • A watch attaches to a directory, not a file. Size a repro by directory count, not file count, when reasoning about inotify exhaustion.
  • The budget is per-user, not per-process. A well-behaved tool can be starved by an unrelated one sharing the same uid — check the whole box, not just the process you suspect.
  • /proc/<pid>/fdinfo beats reading source. It answers "who's holding this" directly, before you've formed a theory about why.
  • Framework defaults aren't free just because nobody configured them. Hot-reload-on-change is a reasonable default for a web app that reads its own config; it's dead weight for a proxy or an MCP host that never does — and it stays invisible until the working directory is large enough to notice.

Diagrams generated with pks image --model gpt-image-2.