Skip to content

fix(daemon): raise proxy daemon file-descriptor limit at startup#439

Merged
dpup merged 3 commits into
mainfrom
fix/proxy-daemon-fd-limit
Jul 14, 2026
Merged

fix(daemon): raise proxy daemon file-descriptor limit at startup#439
dpup merged 3 commits into
mainfrom
fix/proxy-daemon-fd-limit

Conversation

@dpup

@dpup dpup commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Problem

Running multiple Claude sessions inside moat on macOS, all sessions would freeze simultaneously during a package install, then free up and recover gracefully a bit later.

That signature — every session stalling at once and then recovering cleanly (no crash, no OOM) — points at transient saturation of a shared dependency. The one thing every run shares is the single credential-injecting proxy daemon: all container egress is forced through it.

Root cause

The daemon inherited the host's default RLIMIT_NOFILE soft limit (typically 256 on macOS) — nothing raised it. A package install bursts a lot of concurrent connections through that one proxy: bun install opens up to 64 parallel connections (Bun's default since v1.1.33), each consuming file descriptors on both the client and upstream sides, and several runs can install at once. That easily exceeds 256, exhausting the proxy's FDs. While exhausted it can't accept/dial for any run, so every session's claude.ai traffic stalls — until the install finishes, the connections close, FDs free, and everyone recovers.

Fix

Raise the daemon's RLIMIT_NOFILE soft limit toward 65536 (capped at the hard limit) at startup, before the proxy accepts connections.

  • Cross-platform via golang.org/x/sys/unix (the daemon runs on the host — Linux or macOS).
  • macOS step-down fallback: macOS rejects a soft limit above kern.maxfilesperproc even when the hard limit is nominally higher, so the target steps down until one sticks (a typical Mac goes 256 → ~8192).
  • Capped at the hard limit so a non-root daemon can't fail.
  • Best-effort: a setrlimit failure logs a warning and the daemon still runs with the inherited limit.
  • Logs from/to so the raise is visible with --verbose.

Testing

  • go test -race ./internal/daemon/ — new filelimit_test.go asserts the limit is raised, never lowered, respects the hard cap, and is idempotent.
  • go build ./..., go vet, golangci-lint run — clean.

Notes

This is the primary remediation from a short investigation into Bun-in-Docker stability. Related findings that did not warrant a change: Bun's install concurrency is already capped at 64 (moat pins Bun ≥ 1.1.33) with no container-wide knob to lower it further; keep-alive/cert-cache tuning lives in the external gatekeeper proxy, not moat.

🤖 Generated with Claude Code

dpup added 2 commits July 14, 2026 04:47
The proxy daemon is the single shared egress point for every run. It
inherited the host's default RLIMIT_NOFILE soft limit (typically 256 on
macOS), so a burst of concurrent connections through the one proxy —
e.g. `bun install` opens up to 64 parallel connections — could exhaust
its file descriptors and stall every run's traffic until the burst
cleared. This surfaced as all Claude sessions inside moat freezing at
once on macOS during a package install, then recovering gracefully.

Raise RLIMIT_NOFILE toward 65536 (capped at the hard limit) at daemon
startup, before the proxy accepts connections. Cross-platform via
x/sys/unix with a macOS step-down fallback (macOS rejects a soft limit
above kern.maxfilesperproc even when the hard limit is higher).
Best-effort: a failure logs a warning and the daemon still runs.
@claude

claude Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor
📦 Previous Review (superseded by newer review)

Claude finished @dpup's task in 3m 57s —— View job


Reviewing this PR

  • Gather context (PR body, changed files)
  • Read changed files in full
  • Run /code-review skill
  • Verify findings with build/vet/test
  • Post final review

Summary

Solid, well-scoped fix. The root-cause analysis (single shared proxy, bun install's 64 parallel connections, macOS's low default RLIMIT_NOFILE) is convincing, and the fix is narrowly targeted at the actual bottleneck.

Verified locally:

  • go build ./... — clean
  • GOOS=darwin GOARCH=arm64 go build ./internal/daemon/... ./cmd/moat/... — cross-compiles cleanly (important since unix.Rlimit field types differ between Linux/macOS)
  • go vet ./internal/daemon/... ./cmd/moat/cli/... — clean
  • gofmt -l on all 4 changed files — clean
  • go test -race ./internal/daemon/... (including the new filelimit_test.go) — all pass
  • CHANGELOG entry has the real PR link (#439) filled in, not a placeholder

Notes (non-blocking)

  • internal/daemon/filelimit.go:50 — the step-down fallback only tries powers-of-two fractions of the target (target/2, target/4, ...). If the true OS ceiling sits between two halving steps (e.g. real max is 10000 but halving lands on 8192 then jumps to 4096), the daemon settles for a lower limit than it could actually get. Given the real-world values in play (256 → 65536, macOS kern.maxfilesperproc typically at nice round numbers like 8192/10240), this is unlikely to matter in practice, but a binary search between the last-failing and last-succeeding bounds would find the true ceiling more precisely if it's ever worth the extra complexity.
  • internal/daemon/filelimit.go correctly puts RaiseFileLimit() before proxyServer.Start() in cmd/moat/cli/daemon.go, matches the stated goal of raising the limit before the proxy accepts connections.
  • Good use of log.Warn/log.Info/log.Debug per the repo's logging conventions — this is daemon-internal diagnostic info, not user-facing, so internal/log (not internal/ui) is the right choice.
  • No existing rlimit-handling code elsewhere in the repo that this duplicates.

No blocking issues found. This looks ready to merge.

@claude

claude Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor
📦 Previous Review (superseded by newer review)

Claude finished @dpup's task in 3m 48s —— View job


Reviewing this PR

  • Gather context (PR body, changed files)
  • Read changed files in full
  • Run /code-review skill
  • Verify findings with build/vet/test
  • Post final review

Review

Verified: go build ./..., go vet ./..., gofmt -l, and go test -race ./internal/daemon/... (including the new filelimit_test.go) all pass clean.

The fix is well-targeted: raising RLIMIT_NOFILE before the proxy starts accepting connections is the right place, the platform split (//go:build unix / !unix) is clean, the "best-effort, never fatal" behavior matches how a daemon startup concern like this should be handled, and internal/log (not ui) is the correct choice here since this is diagnostic, not user-facing. The CHANGELOG entry follows the "Fix X — previously, Y" pattern correctly and links to #439.

One logic edge case worth a look, not blocking:

internal/daemon/filelimit.go:50 — the step-down fallback loop:

for t := target / 2; t > oldSoft; t /= 2 {

starts at target/2 and only iterates while t > oldSoft. If oldSoft is already more than half of target (e.g. a host/service manager already grants a soft limit of 40000 with target = 65536), the loop body never executes even once — the function falls straight through to return oldSoft, oldSoft, err without probing any value between oldSoft and target, even though an intermediate value (e.g. 50000) might well have succeeded. In practice this is unlikely to bite on the primary target platform (macOS typically starts at soft=256, well under target/2=32768), so I'd call this low-severity — but a binary search between oldSoft and target (or continuing to halve past oldSoft down toward it) would close the gap and handle hosts that start with an already-elevated soft limit.

Everything else — the macOS step-down rationale, the hard-limit cap, the idempotency test (TestRaiseFileLimitNonLowering), and the daemon.go call site placement/logging — looks correct and matches the PR description.

…mits

Address review feedback on #439. The step-down fallback started at
target/2, so if the process already had a soft limit above half the
target and the full target was rejected, the loop body never ran and no
intermediate value was probed (leaving the limit unraised even when a
value between oldSoft and target would have been accepted).

Replace it with a binary search over (oldSoft, target] for the highest
value the system accepts, and treat a limit that cannot be raised
further as success rather than a spurious error — avoiding a misleading
"could not raise" warning at startup when the existing limit is already
adequate.
@claude

claude Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Claude finished @dpup's task in 4m 0s —— View job


Reviewing this PR

  • Gather context (PR body, changed files, prior review threads)
  • Read changed files in full
  • Run /code-review skill
  • Verify findings with build/vet/test
  • Post final review

Summary

The latest commit (508bf35) replaces the halving fallback with a proper binary search and fully resolves the edge case flagged in both prior reviews (internal/daemon/filelimit.go:50): the loop now searches (oldSoft, target) directly with lo/hi bounds seeded from the known-good current limit and the known-bad rejected target, so it converges on the true OS ceiling regardless of whether oldSoft is above or below target/2. Walked through the invariants (lo always accepted and in effect, hi always rejected, mid = lo + (hi-lo)/2 avoids overflow, terminates when hi-lo <= 1) — logic is correct.

Verified locally:

  • go build ./... — clean
  • GOOS=darwin GOARCH=arm64 go build ./internal/daemon/... ./cmd/moat/... — cross-compiles cleanly
  • go vet ./internal/daemon/... ./cmd/moat/cli/... — clean
  • gofmt -l on all changed files — clean
  • go test -race ./internal/daemon/... -run TestRaiseFileLimit -v — both tests pass (TestRaiseFileLimit, TestRaiseFileLimitNonLowering)
  • Single call site (cmd/moat/cli/daemon.go:52), placed before proxyServer.Start() as intended, no duplicate rlimit-handling logic elsewhere in the repo
  • CHANGELOG entry follows the "Fix X — previously, Y" pattern and links the real PR (#439)

No blocking issues. This looks ready to merge.

@dpup dpup merged commit 2012cec into main Jul 14, 2026
4 checks passed
@dpup dpup deleted the fix/proxy-daemon-fd-limit branch July 14, 2026 05:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant