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}