Bootstrapping curl’s CLI Safely
The tiniest part of a tool can decide its reliability. In curl’s case, that’s the entry point: a small file that sets the stage for everything the tool will do. I’m Mahmoud Zalt, and in this article I’ll walk you through the practical engineering behind curl’s bootstrap layer.
We’ll examine src/tool_main.c from the curl project—the command-line tool built on top of libcurl. This file orchestrates OS-specific initialization, file descriptor hygiene, signal handling, and debug toggles before delegating the real work to operate(). Expect concrete takeaways on maintainability, extensibility, usability/DX, and reliability at scale.
Roadmap: How It Works → What’s Brilliant → Areas for Improvement → Performance at Scale → Conclusion.
How It Works
Before we can improve anything, we have to understand the flow. The entry file is a classic bootstrap—sometimes called a composition root—that wires together early process concerns and then hands off to the tool’s core logic.
curl (project root) ├─ lib/ [libcurl] ├─ src/ │ ├─ tool_operate.c <-- main delegates here │ ├─ tool_cfgable.c │ ├─ tool_main.c <-- this file (entry/boot) │ ├─ tool_msgs.c │ └─ ... └─ docs/ Call graph (simplified): [OS loader] -> main/wmain -> tool_init_stderr -> (Windows) GetLoadedModulePaths [when --dump-module-paths] -> win32_init (Windows) -> main_checkfds -> signal(SIGPIPE, SIG_IGN) -> memory_tracking_init -> globalconf_init -> operate -> globalconf_free -> (Windows) fflush(NULL) -> return/vms_special_exit
In plain terms, here’s what the file does:
- Sets up stderr routing early via
tool_init_stderr(). - Handles Windows-specific initialization and a hidden diagnostic switch
--dump-module-paths(prints loaded module paths). - Ensures standard file descriptors are valid before any sockets are opened (
main_checkfds()). - Installs an ignore for SIGPIPE on POSIX so writes to broken pipes don’t kill the process.
- Optionally enables memory tracking during development builds using
CURL_MEMDEBUGandCURL_MEMLIMIT. - Initializes global config, calls
operate(argc, argv), cleans up, then exits with a mappedCURLcode.
There are a few essential invariants maintained along the way:
- No tool/libcurl operations happen before
globalconf_init(). operate()only runs if initialization succeeds.- File descriptors 0, 1, 2 are made safe before network activity begins.
- SIGPIPE is ignored globally to prefer error handling over abrupt termination.
Two helper routines carry a lot of practical weight: main_checkfds() and memory_tracking_init(). They’re small, but their behavior shapes reliability and developer experience.
File-descriptor hygiene
First, here’s the verbatim code curl uses to ensure the standard file descriptors exist. This matters because if stdin/stdout/stderr are closed, the first sockets created by curl could accidentally become those descriptors.
static int main_checkfds(void)
{
int fd[2];
while((fcntl(STDIN_FILENO, F_GETFD) == -1) ||
(fcntl(STDOUT_FILENO, F_GETFD) == -1) ||
(fcntl(STDERR_FILENO, F_GETFD) == -1))
if(pipe(fd))
return 1;
return 0;
}
By looping until 0, 1, and 2 are occupied, the process avoids misusing network sockets as stdio. It’s a pragmatic guard against surprising environments.
Memory tracking in debug builds
When building with CURLDEBUG, the tool reads two environment variables to enable fine-grained memory diagnostics: CURL_MEMDEBUG (filename for logs) and CURL_MEMLIMIT (fail on nth allocation). These are invaluable for troubleshooting allocation problems in CI or local dev.
Why a process-wide SIGPIPE ignore?
Ignoring SIGPIPE prevents abrupt termination when the other end of a pipe closes early. That converts a crash into a normal error path (e.g., EPIPE) you can handle gracefully. The trade-off is global: it applies to the entire process and any threads created later. Documenting this near the installation site helps future maintainers reason about write semantics and error handling.
What’s Brilliant
With the flow understood, let’s recognize the design choices that make this file robust and maintainable. These are practices you can lift into your own CLIs.
- Bootstrap done right. The entry point is a thin composition root that wires up process-wide concerns and delegates behavior to
operate(). This keeps policy out of the entry layer and makes the tool easier to evolve. - Platform abstraction via conditional compilation. Windows, VMS, Amiga, and POSIX flows are clearly separated. This isolates complexity and protects maintainability.
- Guarded debug feature flags. Memory tracking features are gated behind
CURLDEBUGand enabled by environment variables. This yields powerful diagnostics with negligible runtime cost in production builds. - FD hygiene prevents hard-to-debug misroutes. Proactively occupying descriptors 0–2 avoids a class of bugs that would only surface under unusual shells or embedding environments.
- Clear invariants. No libcurl usage before init; always cleanup after operate; process exit code is mapped from a strongly-typed
CURLcode.
As a bootstrap, the file keeps complexity low. Per-function metrics reinforce that point: main_checkfds is 13 SLOC with cyclomatic 3; memory_tracking_init is 24 SLOC with cyclomatic 4; main is still readable at 70 SLOC. That clarity pays dividends when debugging early failures.
Areas for Improvement
Even great bootstrap code benefits from polish. Here’s a prioritized list of risks and pragmatic fixes grounded in the code.
| Smell | Impact | Fix |
|---|---|---|
Use of strcpy on env-derived data |
Unsafe copy pattern; increases maintenance risk despite bounds checks. | Use snprintf with explicit bounds and NUL-termination. |
| Securing stdio FDs via anonymous pipes | Writes to stdout/stderr can block or raise EPIPE when no reader exists; behavior diverges from conventional null device semantics. |
Reopen missing FDs to the platform null device (/dev/null or NUL). |
Global SIGPIPE ignore |
Process-wide effect can mask broken-pipe expectations down the stack. | Document near the installation site; consider more localized handling in lower layers where possible. |
| Hidden Windows diagnostic switch | Undocumented behavior surprises users; may reveal sensitive path details. | Document guarded by a build flag or move under a clearly prefixed debug flag. |
Refactor 1: Safer, bounded copy for CURL_MEMDEBUG
Replace the strcpy-based copy with a bounded snprintf to simplify reasoning and guarantee termination.
--- a/src/tool_main.c +++ b/src/tool_main.c @@ - char fname[512]; - if(strlen(env) >= sizeof(fname)) - env[sizeof(fname)-1] = '\0'; - strcpy(fname, env); + char fname[512]; + /* Copy with explicit bound and guarantee NUL-termination */ + snprintf(fname, sizeof(fname), "%s", env);
This change removes an error-prone primitive and expresses the intent clearly: copy the env value into a fixed buffer, safely.
Refactor 2: Restore stdio using the null device
Instead of consuming anonymous pipes to occupy FDs 0–2, reopen any missing descriptor to the platform’s null device. This aligns behavior with Unix conventions and avoids surprising blocking.
/dev/null (or NUL on Windows)--- a/src/tool_main.c
+++ b/src/tool_main.c
@@
-static int main_checkfds(void)
-{
- int fd[2];
- while((fcntl(STDIN_FILENO, F_GETFD) == -1) ||
- (fcntl(STDOUT_FILENO, F_GETFD) == -1) ||
- (fcntl(STDERR_FILENO, F_GETFD) == -1))
- if(pipe(fd))
- return 1;
- return 0;
-}
+static int main_checkfds(void)
+{
+#ifdef _WIN32
+ const char *nul = "NUL";
+#else
+ const char *nul = "/dev/null";
+#endif
+ if(fcntl(STDIN_FILENO, F_GETFD) == -1) {
+ int n = open(nul, O_RDONLY);
+ if(n < 0) return 1;
+ if(n != STDIN_FILENO) close(n);
+ }
+ if(fcntl(STDOUT_FILENO, F_GETFD) == -1) {
+ int n = open(nul, O_WRONLY);
+ if(n < 0) return 1;
+ if(n != STDOUT_FILENO) close(n);
+ }
+ if(fcntl(STDERR_FILENO, F_GETFD) == -1) {
+ int n = open(nul, O_WRONLY);
+ if(n < 0) return 1;
+ if(n != STDERR_FILENO) close(n);
+ }
+ return 0;
+}
Occupying stdio with the null device prevents deadlocks and respects how other Unix tools behave when stdout/stderr are absent.
Refactor 3: Document global SIGPIPE semantics
One well-placed comment can save hours of debugging for future contributors.
--- a/src/tool_main.c +++ b/src/tool_main.c @@ -#if defined(HAVE_SIGNAL) && defined(SIGPIPE) - (void)signal(SIGPIPE, SIG_IGN); -#endif +#if defined(HAVE_SIGNAL) && defined(SIGPIPE) + /* Global process-level change: avoid termination on broken pipes. + Downstream writes must handle EPIPE returns explicitly. */ + (void)signal(SIGPIPE, SIG_IGN); +#endif
By stating the trade-off, we set clear expectations for all I/O that follows.
Performance at Scale
Although the entry point is not CPU-bound, bootstrap quality shows up in reliability and tail behavior. Here’s how to think about it operationally.
Hot paths and latency
operate(argc, argv)dominates runtime (outside this file).main_checkfds()can become a surprise hot path in environments that start processes with stdio closed.- Environment parsing (
CURL_MEMDEBUG,CURL_MEMLIMIT) is O(n) in small strings—negligible for latency.
Scalability and I/O safety
When stdout/stderr are closed, the current pipe-based strategy may block writers with no consumer. Reopening to the null device eliminates that risk and aligns with conventional tooling. If you keep pipes, be sure your write paths handle EPIPE and that logs don’t silently stall.
Observability suggestions
Bootstrap is a perfect place to emit cheap, high-signal measurements. Start with three metrics:
tool.startup.duration_ms: p95 SLO under 10ms on typical systems.tool.startup.stderr_fd_open: boolean; verify FD 2 is valid postmain_checkfds().tool.env.memdebug.enabled: track the rate of runs with memory tracking turned on.
These let you detect regressions (slow startups), environment anomalies (missing stdio), and the blast radius of debug features in production.
Testing the bootstrap
Entry-point code touches process-wide concerns that are hard to unit test. Favor integration harnesses that sandbox the environment, especially for file descriptors and signals. Here’s a minimal test harness inspired by the plan to verify FD restoration when 0–2 start closed.
#include#include int main(void) { close(0); close(1); close(2); execlp("curl", "curl", "--version", NULL); return 127; /* exec failed */ }
This validates that main_checkfds() succeeds and the process doesn’t fail with CURLE_FAILED_INIT even when launched without stdio.
Additional high-value tests:
- Memory tracking enablement: set
CURL_MEMDEBUGto a writable path; assert the log is written and the command still succeeds. - Allocation-failure injection: set
CURL_MEMLIMIT=10and expect a deterministic failure path in a debug build. - Windows module dump:
curl.exe --dump-module-pathsprints non-empty absolute paths and exits 0 if any.
Conclusion
Small files, big impact. Curl’s tool_main.c is a model bootstrap: cohesive, readable, and careful about the realities of cross-platform processes. A few finishing touches can make it even safer and more predictable in odd environments.
- Adopt safer copies for env-derived strings; prefer
snprintfoverstrcpy. - Restore stdio to the null device instead of consuming pipes—predictable behavior, fewer surprises.
- Document global effects like
SIGPIPEignores near the installation site.
I hope this walkthrough helps you design reliable bootstraps in your own tools. If you’re building a CLI with platform nuance, investing in a disciplined entry layer will pay off in stability, debuggability, and developer experience.
Supporting snippets
Signal handling for SIGPIPE
#if defined(HAVE_SIGNAL) && defined(SIGPIPE) (void)signal(SIGPIPE, SIG_IGN); #endif
Prevents abrupt termination on broken pipes; downstream writes must check for EPIPE instead.
Core run sequence
/* Initialize the curl library - do not call any libcurl functions before
this point */
result = globalconf_init();
if(!result) {
/* Start our curl operation */
result = operate(argc, argv);
/* Perform the main cleanup */
globalconf_free();
}
A clean orchestration: fail-fast on init errors, delegate the work, then always clean up.



