Spring til indhold
DAdebuggingdotnetlinuxpks-cli

En watcher ingen bad om

Turbopack ramte loftet for file watches. Synderen var hverken Turbopack, Codex eller Node, men en config-watcher ingen havde bedt om.

Scoret for

Det begyndte med en Turbopack-fejl i et helt andet Next.js-projekt, startet gennem 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 en pakke der tydeligvis var installeret, er det klassiske tegn på et opbrugt fs.inotify-budget: Turbopack kunne ikke oprette en watch for at finde modulet, så den meldte modulet som manglende i stedet for at fortælle at der ikke var plads til flere watches. Fixet lå slet ikke i projektet. Det lå tre processer længere oppe, i værktøjet, der havde startet det.

Genvejen jeg nær tog

Mit første bud landede på det oplagte fix: containerens inotify-loft var målbart tæt på fuldt, så bare hæv loftet. Her er det faktiske forslag, uredigeret:

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?

Det havde »virket« — builden blev grøn igen. Men den egentlige årsag ville stadig ligge og vente, klar til at fylde det højere loft op igen, næste gang nok værktøjer var stablet oven på hinanden. Svaret jeg fik tilbage, tog ikke imod tilbuddet:

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

Den ene linje er hele grunden til at dette indlæg findes i stedet for et enkeltlinjers sysctl-plaster. Det er forskellen på at behandle et symptom og på at opdage at en proces i al stilhed havde åbnet 555.186 watches, som den aldrig havde brug for.

To skub mere før jeg fandt den

Sysctl-tilbuddet var ikke det sidste forslag jeg måtte afvise. Mit næste bud konkluderede at der slet ikke var en fejl — bare mange langtidskørende watchers stablet oven på hinanden:

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?

Med den vinkling — »intet er i stykker, det er bare ressourcepres« — var undersøgelsen død der. Du ville ikke nøjes med en historie; du ville have et regnskab:

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

De eksakte tal bekræftede ikke bare »her er trængsel«-historien — de afslørede noget indeni den, der var værd at kigge nærmere på:

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?

Tabellen fremstillede stadig fixet som »luk nogle sessioner«, ikke »der er en fejl«. Ét spørgsmål mere flyttede det fra ressourcestyring til rodårsag:

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

Det er spørgsmålet der sendte mig ind i /proc/<pid>/fdinfo for at finde eksakt ejerskab per proces — og det er starten på procestræ-sporingen nedenfor.

Et fælles, begrænset budget

Linux uddeler inotify watches fra nogle få tællere per bruger, ikke per proces. Hver watch som en proces på maskinen åbner, trækker fra den samme pulje:

  • fs.inotify.max_user_watches — det samlede antal watches på tværs af alle processer ejet af uid'en
  • fs.inotify.max_user_instances — det samlede antal åbne inotify-file descriptors ejet af uid'en
  • fs.inotify.max_user_queued_events — ventende events i buffer per instans

På den her devcontainer lå loftet nord for en million watches, og et eller andet havde stadig brugt næsten dem alle. Det næste spørgsmål var ikke »skal grænsen hæves?«, men »hvem holder en million watches åbne?«

Sådan hæfter en watch sig faktisk på

Det oplagte billede er én watch per fil. Et projekt med 350,000 filer burde så kræve 350,000 watches. Men sådan virker hverken inotify eller .NETs FileSystemWatcher oven på det: En rekursiv overvågning opretter én inotify-watch per mappe og bruger mappens egen watch til at melde når en fil i den ændrer sig. Filer bliver aldrig watched enkeltvis.

Et mappetræ hvor hvert mappeikon har en lille lysende orange watch-markør, mens filerne i mapperne ikke har nogen. Én inotify watch per mappe. Filerne arver synlighed fra den mappe de ligger i; de får ikke deres egne watches.

Det kan testes; det er ikke bare teori. Jeg pegede en minimal repro-app med en rekursiv FileSystemWatcher på et rigtigt projekttræ, og de to tal landede præcis oven i hinanden:

  • 19,962 mapper på disken
  • 19,962 åbne inotify watches
  • 350,959 filer — nul watches

Én watch, én mappe, hver gang. Filerne er helt usynlige for inotify. Det er watchen på forældremappen der udløses når noget i den bliver oprettet, fjernet eller omdøbt.

Find processen, ikke en teori

Den hurtigste vej til processen, der holder en watch, går ikke gennem kildekoden. Den går gennem at spørge kernelen direkte. Hver åben inotify-instans dukker op som et symlink under /proc/<pid>/fd/, der peger på anon_inode:inotify. Og /proc/<pid>/fdinfo/<fd> har én inotify-linje per aktiv watch på instansen. Det giver et præcist tal per proces uden nogen form for instrumentering.

Ud af cirka 1,003,596 åbne watches mod et loft på ~1,003,651 sad én proces alene på 555,186 af dem: pks-cli, som kørte Codex passthrough-proxyen. En tur gennem procestræet (ps --ppid <pid> -o pid,ppid,cmd) bekræftede hvem der ejede file descriptoren: Codex-child-processen holdt ikke på noget. Watchen lå i wrapperen der starter Codex, ikke i Codex.

Et enkelt CLI-procesikon der forgrener sig i en fraktal eksplosion af titusindvis af watched mappeikoner. Én proces, én gennemgang af et helt mappetræ ved opstart og titusindvis af watches — før værktøjet har gjort noget som brugeren bad det om.

Årsagen: en watcher ingen havde bedt om

pks-cli's codex passthrough og dens mcp start-transports starter hver især enten en ASP.NET Core WebApplication eller en Generic Host. Det oversete er at WebApplication.CreateSlimBuilder(), WebApplication.CreateBuilder() og Host.CreateApplicationBuilder() alle som standard sætter ContentRootPath til den aktuelle arbejdsmappe og registrerer config-kilderne appsettings.json / appsettings.{Environment}.json med reloadOnChange: true — uanset om der overhovedet findes en appsettings.json i mappen. Den standard opretter i stilhed en PhysicalFilesWatcher: en rekursiv FileSystemWatcher med rod der hvor processen tilfældigvis blev startet.

Start pks codex eller pks mcp start fra et stort JS-repo — inklusive node_modules — og den ene framework-standard gennemgår hele træet og åbner en watch per mappe. Hverken Codex passthrough-proxyen eller MCP-transporterne læser config fra disken ved runtime. Watcheren var rent spild: en rekursiv gennemgang af et repo som processen aldrig havde tænkt sig at holde øje med, betalt ved hver eneste kørsel.

Fejlen lå ikke i Codex, og det var ikke en fejlkonfiguration. Det var en framework-standard uden en slukknap nogen havde haft grund til at lede efter, før arbejdsmappen blev stor nok til at gøre den synlig.

Fixet

ASP.NET Core læser adfærden fra én config-nøgle, HostDefaults.ReloadConfigOnChangeKey (hostBuilder:reloadConfigOnChange). Den bliver tjekket tidligt nok til at nøglen kan sættes som et kommandolinjeagtigt argument sendt direkte ind i builder-fabrikken:

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

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

Jeg lagde det på alle fire call sites: Codex passthroughens WebApplication og MCP-hostens stdio-, HTTP- og SSE-builders. Det ændrede ingen adfærd i nogen af featuresene, fordi ingen af dem læser config ved runtime.

Verificeret, ikke antaget

Samme repro-app, samme mappe, fixet slået til:

  • 19,962 watches — før
  • 0 watches — efter
  • Build stadig grøn

Jeg målte hvert sekund i femten sekunder efter opstart med begge konfigurationer. Uden fixet stiger tallet og lægger sig præcis på antallet af mapper på disken. Med fixet forlader det aldrig nul. Config-reload-stien bliver ganske enkelt aldrig kørt.

Det jeg tager med videre

  • En watch hæfter på en mappe, ikke en fil. Dimensionér en repro efter antallet af mapper, ikke filer, når du undersøger om inotify er brugt op.
  • Budgettet er per bruger, ikke per proces. Et velopdragent værktøj kan blive udsultet af et andet der deler samme uid. Tjek hele maskinen, ikke kun den proces du mistænker.
  • /proc/<pid>/fdinfo slår kildekodelæsning. Det besvarer »hvem holder på det her?« direkte, før du har nået at danne en teori om hvorfor.
  • Framework-standarder er ikke gratis bare fordi ingen har konfigureret dem. Hot-reload-on-change er en fornuftig standard for en webapp der læser sin egen config. For en proxy eller MCP-host, der aldrig gør det, er den ren dødvægt — usynlig, indtil arbejdsmappen bliver stor nok til at afsløre den.

Diagrammer genereret med pks image --model gpt-image-2.