System Architecture
Catter is a three-part system for intercepting and processing build commands. Each component has a distinct responsibility, and they communicate over IPC to form a pipeline that captures every compiler invocation a build system makes.
The Three Components
1. HOOK -- Process Creation Interceptor
A platform-specific shared library injected into build system processes. It intercepts calls to process creation functions (execve on Unix, CreateProcess on Windows) and rewrites them so that every child process is routed through catter-proxy before execution.
- Linux:
libcatter-hook-unix.so, injected viaLD_PRELOAD - macOS:
libcatter-hook-unix.dylib, injected viaDYLD_INSERT_LIBRARIES - Windows:
catter-hook-win64.dll, injected via DLL injection (VirtualAllocEx+LoadLibraryA)
The hook is passive -- it does not make decisions. It simply redirects process creation to the proxy.
2. PROXY (catter-proxy) -- Compiler Wrapper and Hook Manager
A standalone executable that operates in two modes:
- Injector Mode: Launched by
catterto start the build system with the hook library attached. This is the first proxy instance in a session. - Wrapper Mode: Launched by the hook library when a build command is intercepted. Each intercepted command spawns a new
catter-proxyprocess that acts as a stand-in for the original compiler/tool.
In both modes, the proxy connects to the catter daemon over IPC, sends information about the captured command, receives a decision (execute, drop, or modify), and acts on it.
3. DECISION (catter) -- The Daemon
The main process that the user invokes. It:
- Loads and initializes a JavaScript script via the embedded QuickJS runtime
- Spawns
catter-proxyin injector mode to start the build - Listens on a Unix domain socket (or named pipe on Windows) for IPC connections
- Receives intercepted commands from proxy instances
- Invokes JavaScript callbacks (
onCommand,onExecution,onStart,onFinish) to decide how to handle each command - Maintains a session tree that mirrors the process tree of the build
The JS runtime is single-threaded -- all script callbacks run sequentially on the event loop, so scripts do not need to handle concurrency.
Complete Workflow
Here is a step-by-step walkthrough of what happens when you run:
catter script::cdb -o compile_commands.json -- makeUser invokes
catter. The DECISION daemon starts, loads thescript::cdbscript, and initializes the QuickJS runtime. The script'sonStart()callback runs.catterspawnscatter-proxy -- make. This is the proxy in injector mode. The proxy is a child process ofcatter.Proxy connects to
cattervia IPC. It sends aCHECK_MODErequest to confirm the daemon is in inject mode.catterresponds: inject mode. The proxy now knows it should launch the build command with the hook library attached.Proxy starts
makewith the hook injected. On Linux, this means addinglibcatter-hook-unix.sotoLD_PRELOADand setting environment variables (__key_catter_proxy_path_v1,__key_catter_command_id_v1) before calling the realexecve. On Windows, the process is created suspended, the hook DLL is injected, then the process is resumed.makeruns and tries to spawng++ main.cpp -o main.o. The build system callsexecve("g++", ...)(orCreateProcesson Windows).The HOOK intercepts the
execve()call. The hook library, loaded inmake's address space, catches the call before it reaches the kernel.HOOK rewrites the command. Instead of executing
g++directly, the hook rewrites the command to:catter-proxy -p <parent_id> --exec /usr/bin/g++ -- g++ main.cpp -o main.oIt also cleans the environment: removes catter-specific variables and strips the hook library from
LD_PRELOADto prevent the proxy itself from being hooked.A new
catter-proxyinstance starts (PROXY in wrapper mode). This proxy instance calls the realexecvewith the rewritten command.Wrapper proxy connects to
cattervia IPC. It sends aCREATErequest (registering itself with its parent ID), then aMAKE_DECISIONrequest containing the full command details: working directory, resolved executable path, arguments, and environment.catterinvokesonCommand(ctx)in the JS script. The script inspects the command and returns an action: execute as-is, execute with modifications, or drop.Proxy acts on the decision:
- INJECT: Execute the command with the hook library re-attached (so grandchild processes are also intercepted)
- WRAP: Execute the command directly, capturing stdout/stderr
- DROP: Skip execution, return exit code 0
After execution, the proxy sends
FINISHtocatter. The result includes the exit code, captured stdout, and captured stderr.catterinvokesonExecution(ctx)in the JS script with the execution result.When
makefinishes, the original injector proxy exits, andcatterinvokesonFinish(result).cattershuts down and writes any output (e.g.,compile_commands.json).
Sequence Diagram
Two Modes of catter-proxy
The proxy binary serves dual purposes depending on how it is invoked:
Injector Mode
Launched by catter to run the build system with hooks. This is always the first proxy instance in a session.
catter-proxy -- make -j8The injector:
- Connects to the daemon, confirms inject mode
- Prepares the environment with
LD_PRELOAD(or performs DLL injection on Windows) - Sets catter-specific environment variables for the hook to read
- Launches the build command
- Waits for the build to complete, capturing stdout/stderr
Wrapper Mode
Launched by the hook library when a child process is intercepted. Each intercepted command creates a new wrapper instance.
catter-proxy -p <parent_id> --exec /usr/bin/g++ -- g++ main.cpp -o main.oThe wrapper:
- Connects to the daemon
- Registers itself as a child of
parent_idviaCREATE - Sends the captured command via
MAKE_DECISION - Executes (or drops) based on the daemon's response
- Reports the result via
FINISH
Key Design Decisions
Single-threaded JS runtime. All JavaScript callbacks run sequentially on the event loop. The daemon handles IPC connections concurrently (via async I/O with kota), but script execution is serialized. This eliminates race conditions in user scripts.
Binary IPC over Unix domain sockets / named pipes. Communication between proxy and daemon uses Bincode serialization via kota::ipc::BincodePeer. This is fast and avoids the overhead of text-based protocols. See the IPC Protocol document for details.
Recursive hooking. On Unix, LD_PRELOAD is inherited by child processes, so any subprocess spawned by the build system (including nested make invocations, shell scripts, or build tool wrappers) is automatically hooked. On Windows, the hook DLL's CreateProcess detour ensures every child process gets the DLL injected before it starts. The interception is transparent and recursive -- the build system has no way to tell it is being monitored.
Environment scrubbing. The hook cleans its own traces from the environment before launching the proxy. If it did not strip itself from LD_PRELOAD, the proxy binary would itself be hooked, causing infinite recursion. The proxy re-adds LD_PRELOAD only when launching commands that need interception (INJECT action).
Session tree. The daemon maintains a tree of session IDs that mirrors the build process tree. Each proxy instance registers with its parent's session ID, allowing the daemon to track which processes spawned which. This is essential for features like target tree reconstruction and build profiling.