Relationships
#1118 serve leaks file descriptors (EMFILE) — runFileSink.unregister runs after yield, not in finally
Opened by keeb · 7/13/2026· Shipped 7/13/2026
Summary
A long-running swamp serve process leaks file descriptors and eventually
fails every operation with Too many open files (os error 24). The leaked
resource is the per-run workflow log file opened by runFileSink.register().
The root cause is that runFileSink.unregister() in
WorkflowExecutionService.run() and .resume() is called after yield
statements instead of in a finally block, so when a consumer abandons the
event generator early the descriptor is never closed.
Symptom
Scheduled workflows begin failing in bulk, all at the first filesystem access:
error scheduled-execution Scheduled run failed for workflow "organize-and-clean":
"Too many open files (os error 24): readdir '/home/[REDACTED]/git/swamp-media/workflows'"The readdir is a red herring — it is simply the first syscall in the fire path
that needs a new FD. Once the process is at its RLIMIT_NOFILE soft limit, every
Deno.readDir/Deno.open fails, so all scheduled workflows fail at every tick
and never recover until restart (leaked FDs are held for the life of the
process). Observed on a serve instance whose soft limit is the default
1024 (hard limit 524288).
Root cause
src/domain/workflows/execution_service.ts — run() registers the workflow log
sink and then closes it at scattered points that follow yields rather than in a
finally:
workflowLogHandle = await runFileSink.register(...); // ~1634, opens a Deno.open FD
...
yield { kind: "completed", run }; // 1860
await this.saveRun(workflow.id, run);
runFileSink.unregister(workflowLogHandle); // 1869 — NOT in a finallyrun() (and resume()) are async generators. When a consumer stops iterating
after the terminal event, Deno invokes generator.return(), which unwinds only
finally blocks. Every runFileSink.unregister() sits after a yield
(lines ~1829, 1869, 1880, 1906, 1921 in run(); ~2184, 2206, 2213, 2233, 2242
in resume()), so they are skipped and the log-file descriptor leaks.
Trigger
Streaming serve handlers break out of the event loop when the client socket
closes, abandoning the generator:
src/serve/handlers/workflow_handlers.ts:859(workflow resume) —if (socket.readyState !== WebSocket.OPEN) break;src/serve/handlers/model_handlers.ts:206
Any client (TUI/dashboard/monitoring UI) that connects to watch a run and then disconnects mid-run leaks one FD per occurrence. An exception thrown while a consumer processes a yielded event has the same effect.
Note: the purely-scheduled path (executeWorkflowWithLocks) and the primary
workflow.run handler drain the generator fully, so they do not trigger the
leak on their own. The model-method run path is already safe because its
runLog.cleanup() is in a finally (src/libswamp/models/run.ts:1139) — the
workflow-level sink is the one that isn't.
What was ruled out (to save the next investigator time)
Deno.readDirearly-return(e.g.unified_data_repository.ts:389,482) does not leak — Deno calls the iterator's.return()onbreak/return. Verified empirically: 500 early-return loops → 0 FD growth.- The ephemeral
:memory:store holds 0 OS file descriptors even when leaked. (A file-based sqlite with WAL costs 3 FDs:.db+-wal+-shm.)
Secondary findings (separate, lower priority)
- Several
create*Deps()factories open a file-based catalog store (3 FDs) with noclose()— e.g.createModelEvaluateDeps(src/libswamp/models/evaluate.ts:99). Serve handlers that call these with onlyctx.repoDir(instead of injecting the sharedctx.repoContextrepo, the waycreateDataListDepsdoes) leak 3 FDs per API request.model.evaluate(model_handlers.ts:1078) is one such endpoint. Harmless in one-shot CLI (process exits), a leak underserve. src/libswamp/workflows/run.ts:491creates the ephemeral store before itstry(which starts at 569), so the early returns at 506/529 skipdispose().:memory:= 0 FDs, so this is a minor memory/handle leak, not the EMFILE cause.
Steps to reproduce
- Start
swamp serve(default softRLIMIT_NOFILEof 1024). - Open a WebSocket to stream a workflow resume (or model run) and disconnect before the run completes. Repeat.
- Watch open FDs climb:
ls -l /proc/<pid>/fdshows accumulatingworkflow-run-*.loghandles that are never released. - After enough leaks the process crosses 1024 open FDs and every subsequent
operation fails with
Too many open files (os error 24).
Suggested fix
- Move every
runFileSink.unregister(workflowLogHandle)inexecution_service.tsrun()andresume()into the outerfinally(alongsiderunSpan.end()), so it always runs when the generator is disposed — mirroring how the model-method path already usesfinally. - Separately, give the leaking
create*Depsfactories a shared-repo escape hatch (orclose()the store in the handler), and hoist the ephemeral-store creation inrun.tsinside itstry.
Mitigation
Restart serve, and raise the soft FD limit above the 1024 default (hard limit
is already 524288, so no privilege change needed): systemd LimitNOFILE=1048576,
or ulimit -n 1048576 before launch.
Environment
swamp serve(scheduled workflows enabled), Linux, Deno runtime.RLIMIT_NOFILEsoft=1024, hard=524288 (default).
Shipped
Click a lifecycle step above to view its details.
Sign in to post a ripple.