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:
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:
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:
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:
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å:
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:
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'enfs.inotify.max_user_instances— det samlede antal åbne inotify-file descriptors ejet af uid'enfs.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.
É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.
É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
inotifyer 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>/fdinfoslå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.
