Skip to main content

forge_server/
startup.rs

1//! Startup-time process-state bumps. Currently just the
2//! `RLIMIT_NOFILE` raise (#251) - macOS launchd hands GUI-spawned
3//! processes a soft cap of 256 open FDs, and multi-session forge
4//! steady-state crosses that ceiling (~15-25 FDs per session ×
5//! claude pipes / watchers / MCP / tokio kqueues). Without the bump,
6//! git scans and other openers fail with `EMFILE` once enough
7//! sessions are open.
8//!
9//! That per-session estimate was measured when each session also held
10//! proxy sockets, so it now runs high; re-measure before treating it
11//! as current.
12
13use rlimit::Resource;
14
15/// Target soft `RLIMIT_NOFILE` value. Well below macOS
16/// `kern.maxfilesperproc` (typically 184320) and Linux containers'
17/// usual hard caps (1024-65536). Capped against the process's hard
18/// limit at call time so a tighter inherited hard cap clamps the
19/// effective bump gracefully.
20const TARGET_NOFILE: u64 = 8192;
21
22/// Raise the soft `RLIMIT_NOFILE` limit toward `TARGET_NOFILE`,
23/// clamped at the process's hard cap. No-op when the inherited soft
24/// limit is already at or above the target. Logs the action at
25/// `info` on success and at `warn` when getrlimit / setrlimit fails.
26///
27/// Should be called once at binary startup AFTER tracing init (so
28/// the log lines land) and BEFORE any code that opens sockets /
29/// files / pipes (so the bump applies to every subsequent opener).
30/// Safe to call multiple times - the no-op branch makes repeats
31/// cheap and idempotent.
32pub fn raise_fd_limit() {
33    let (soft, hard) = match Resource::NOFILE.get() {
34        Ok(pair) => pair,
35        Err(err) => {
36            tracing::warn!(
37                target: "forge_server::startup",
38                error = %err,
39                "NOFILE getrlimit failed; leaving FD limit at inherited value",
40            );
41            return;
42        }
43    };
44    let new_soft = TARGET_NOFILE.min(hard);
45    if new_soft <= soft {
46        tracing::info!(
47            target: "forge_server::startup",
48            soft,
49            hard,
50            "RLIMIT_NOFILE already at or above target; no change",
51        );
52        return;
53    }
54    match Resource::NOFILE.set(new_soft, hard) {
55        Ok(()) => {
56            tracing::info!(
57                target: "forge_server::startup",
58                old_soft = soft,
59                new_soft,
60                hard,
61                "raised RLIMIT_NOFILE soft limit",
62            );
63        }
64        Err(err) => {
65            tracing::warn!(
66                target: "forge_server::startup",
67                error = %err,
68                soft,
69                hard,
70                new_soft,
71                "NOFILE setrlimit failed; leaving at inherited value",
72            );
73        }
74    }
75}
76
77/// The value `scripts/install.sh` exports before its cargo call. Any
78/// other value, including the empty string a build script sees when the
79/// variable is absent, means the guard did not run.
80const GUARDED_MARKER: &str = "install.sh";
81
82/// What to warn about `provenance`, or `None` when the build came
83/// through the guarded path.
84///
85/// `cargo install` ignores `Cargo.lock` unless `--locked` is passed, so
86/// a hand-rolled install silently resolves dependencies afresh and can
87/// ship a graph no CI run ever tested. That is not hypothetical: it is
88/// how a blocking terminal query reached a user while `just check` was
89/// green. The remedy belongs in the message because a log line that
90/// only names the problem leaves the reader where that incident
91/// started.
92fn build_provenance_warning(provenance: &str) -> Option<String> {
93    if provenance == GUARDED_MARKER {
94        return None;
95    }
96    Some(
97        "this binary was built outside scripts/install.sh, so its dependency graph may not be \
98         the one the lockfile pins and CI tested; rebuild with scripts/install.sh, or with \
99         cargo install --locked if installing by hand"
100            .to_owned(),
101    )
102}
103
104/// Record how this binary was built, so a drifted one says so instead
105/// of being silent.
106///
107/// Call once at startup after tracing init. The `Cargo.lock` digest is
108/// reported alongside because it catches a build that honoured a
109/// locally-modified one, which provenance cannot see because the guard
110/// did run. It only means something compared against a build of the
111/// released tag - a bare digest in a log is not self-evidently correct,
112/// and being FNV-1a it will not match `shasum`.
113pub fn report_build_provenance() {
114    let provenance = crate::FORGE_BUILD_PROVENANCE;
115    let cargo_lock = crate::FORGE_CARGO_LOCK_DIGEST;
116    match build_provenance_warning(provenance) {
117        None => tracing::info!(
118            target: crate::logging::targets::APP_LIFECYCLE,
119            event_name = "build_provenance",
120            message = "binary built through the guarded install path",
121            outcome = "success",
122            provenance,
123            cargo_lock_digest = cargo_lock,
124        ),
125        Some(remedy) => tracing::warn!(
126            target: crate::logging::targets::APP_LIFECYCLE,
127            event_name = "build_provenance_unguarded",
128            message = %remedy,
129            outcome = "failure",
130            provenance,
131            cargo_lock_digest = cargo_lock,
132        ),
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    /// A binary built outside the guard has to say so AND say what to
141    /// do about it - a log line that only names the problem leaves the
142    /// reader where this incident started.
143    #[test]
144    fn an_unguarded_build_reports_how_to_rebuild_it() {
145        let warning = build_provenance_warning("unguarded").expect("unguarded builds warn");
146        assert!(
147            warning.contains("scripts/install.sh"),
148            "the warning names the guarded path to rebuild with: {warning}",
149        );
150        assert!(
151            warning.contains("--locked"),
152            "the warning names the flag a hand-rolled build needs: {warning}",
153        );
154    }
155
156    /// The guarded path is the whole point of the marker: a build that
157    /// went through it must be silent, or the warning is noise on every
158    /// correct install and stops being read.
159    #[test]
160    fn a_guarded_build_does_not_warn() {
161        assert!(
162            build_provenance_warning(GUARDED_MARKER).is_none(),
163            "a build through the guarded path has nothing to report",
164        );
165    }
166
167    /// Anything that is not the marker is untrusted, including an empty
168    /// value - the default when the env var is absent, which is exactly
169    /// what a hand-rolled `cargo install` produces.
170    #[test]
171    fn an_unrecognised_marker_is_treated_as_unguarded() {
172        for value in ["", "yes", "1", "GUARDED", "guarded "] {
173            assert!(
174                build_provenance_warning(value).is_some(),
175                "{value:?} is not the marker and must not be trusted as guarded",
176            );
177        }
178    }
179
180    /// Pin both branches of `raise_fd_limit`: when the inherited soft
181    /// limit is already at or above `min(TARGET_NOFILE, hard)`, the
182    /// call is a no-op; otherwise the soft limit is bumped to that
183    /// value. `Resource::NOFILE` is process-wide state and other
184    /// tests may have already bumped it, so this test is robust to
185    /// either initial condition.
186    #[test]
187    fn raise_fd_limit_bumps_soft_toward_target() {
188        let (soft_before, hard) = Resource::NOFILE.get().expect("NOFILE getrlimit");
189
190        raise_fd_limit();
191
192        let (soft_after, _) = Resource::NOFILE.get().expect("NOFILE getrlimit");
193        let expected = TARGET_NOFILE.min(hard);
194        if soft_before >= expected {
195            assert_eq!(
196                soft_after, soft_before,
197                "no-op branch: soft already >= target ({expected}), must not regress",
198            );
199        } else {
200            assert_eq!(
201                soft_after, expected,
202                "bump branch: soft must rise to min(TARGET_NOFILE, hard) = {expected}",
203            );
204        }
205    }
206
207    /// Calling `raise_fd_limit` twice in a row is idempotent - the
208    /// second call sees the bumped soft limit and takes the no-op
209    /// branch. Pins the "safe to call multiple times" claim in the
210    /// docstring so a future bug that overwrites the bump (e.g. a
211    /// stray `set(soft, hard)` with the original soft) surfaces here.
212    #[test]
213    fn raise_fd_limit_is_idempotent() {
214        raise_fd_limit();
215        let (soft_after_first, _) = Resource::NOFILE.get().expect("NOFILE getrlimit");
216        raise_fd_limit();
217        let (soft_after_second, _) = Resource::NOFILE.get().expect("NOFILE getrlimit");
218        assert_eq!(
219            soft_after_first, soft_after_second,
220            "second call must be a no-op; got {soft_after_first} then {soft_after_second}",
221        );
222    }
223}