From f2185373a5b89fc51abff2a2d04a53c58886ceb7 Mon Sep 17 00:00:00 2001 From: Michael Russo Date: Sat, 26 Sep 2026 07:45:15 -0400 Subject: [PATCH 1/4] refactor: remove unused erl_interface lookups Nothing reads `ERL_INTERFACE_INCLUDE_DIR` or `ERL_INTERFACE_LIB_DIR`. Both are recursively expanded, so their `erl` calls never ran. --- Makefile | 2 -- 1 file changed, 2 deletions(-) diff --git a/Makefile b/Makefile index 23a12cc..b834c6f 100644 --- a/Makefile +++ b/Makefile @@ -9,8 +9,6 @@ C_SRC_DIR = c_src # Erlang NIF include paths ERTS_INCLUDE_DIR ?= $(shell erl -noshell -eval "io:format(\"~ts/erts-~ts/include\", [code:root_dir(), erlang:system_info(version)])." -s init stop) -ERL_INTERFACE_INCLUDE_DIR ?= $(shell erl -noshell -eval "io:format(\"~ts\", [code:lib_dir(erl_interface, include)])." -s init stop) -ERL_INTERFACE_LIB_DIR ?= $(shell erl -noshell -eval "io:format(\"~ts\", [code:lib_dir(erl_interface, lib)])." -s init stop) # Platform detection UNAME_S := $(shell uname -s) From a738a54dca7fd95409218cb6a45387c13ee84ac5 Mon Sep 17 00:00:00 2001 From: Michael Russo Date: Sun, 27 Sep 2026 10:04:12 -0400 Subject: [PATCH 2/4] fix: compile the shepherd with -fPIE `-fPIE` appeared only in the shepherd's link flags, so the compiler never received it. The `-pie` link then worked only with compilers that produce position-independent code by default. Others failed with "recompile with -fPIE". Add `SHEPHERD_CFLAGS` with `-fPIE`, matching `NIF_CFLAGS`, and remove `-fPIE` from the link flags. --- CHANGELOG.md | 3 +++ Makefile | 7 ++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e49c823..ff1bb6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,9 @@ remaining findings across shepherd/NIF, lib, tests, docs, and CI). ### Fixed +- **The shepherd is compiled with `-fPIE`.** The flag was passed only when + linking, so a compiler that does not produce position-independent code by + default could not link the shepherd. - **EINTR is retried** in the NIF `read(2)`/`write(2)` loops and shepherd I/O; a transient signal can no longer permanently wedge a drain loop. - **Shepherd diagnostics surface.** A spawn-stage `MSG_ERROR` (cgroup diff --git a/Makefile b/Makefile index b834c6f..31b2119 100644 --- a/Makefile +++ b/Makefile @@ -47,12 +47,11 @@ ifeq ($(UNAME_S),Darwin) # += so packager-provided CFLAGS are honored, not clobbered. CFLAGS += $(CFLAGS_BASE) -D_DARWIN_C_SOURCE NIF_LDFLAGS = -dynamiclib -undefined dynamic_lookup - SHEPHERD_LDFLAGS = -fPIE NIF_EXT = .so else CFLAGS += $(CFLAGS_BASE) -D_GNU_SOURCE NIF_LDFLAGS = -shared -Wl,-z,relro,-z,now -Wl,-z,noexecstack - SHEPHERD_LDFLAGS = -fPIE -pie -Wl,-z,relro,-z,now -Wl,-z,noexecstack + SHEPHERD_LDFLAGS = -pie -Wl,-z,relro,-z,now -Wl,-z,noexecstack NIF_EXT = .so endif @@ -65,6 +64,8 @@ endif # already marks it default-visible. NIF_CFLAGS = $(CFLAGS) -I$(ERTS_INCLUDE_DIR) -I$(C_SRC_DIR) -fPIC -fvisibility=hidden +SHEPHERD_CFLAGS = $(CFLAGS) -fPIE + # Targets SHEPHERD = $(PRIV_DIR)/shepherd NIF_LIB = $(PRIV_DIR)/net_runner_nif$(NIF_EXT) @@ -94,7 +95,7 @@ $(SHEPHERD): $(SHEPHERD_OBJ) $(CC) $(SHEPHERD_LDFLAGS) $(LDFLAGS) -o $@ $< $(SHEPHERD_OBJ): $(SHEPHERD_SRC) $(HEADERS) - $(CC) $(CFLAGS) -I$(C_SRC_DIR) -c -o $@ $< + $(CC) $(SHEPHERD_CFLAGS) -I$(C_SRC_DIR) -c -o $@ $< # NIF shared library $(NIF_LIB): $(NIF_OBJ) From cd1afa4ee06b0c496cb202ee2e7c3398e44add3c Mon Sep 17 00:00:00 2001 From: Michael Russo Date: Sun, 27 Sep 2026 10:04:12 -0400 Subject: [PATCH 3/4] feat: build native code without writing to the source tree The build wrote object files into `c_src/`, so a build into another `PRIV_DIR` still changed the source tree. A parallel build into a new `PRIV_DIR` could also link before the directory existed. Compile and link each binary in one command, and remove `c_src/*.o` from `.gitignore`. Make `PRIV_DIR` an order-only prerequisite of both binaries. Remove `-I$(C_SRC_DIR)`, which the quoted includes do not need. CI now builds into a separate `PRIV_DIR` before any other build step and fails if `c_src/` or `priv/` changes. --- .github/workflows/ci.yml | 7 +++++++ .gitignore | 1 - CHANGELOG.md | 2 ++ Makefile | 37 +++++++++++++++++-------------------- 4 files changed, 26 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23f3c2b..0ee2077 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,13 @@ jobs: elixir-version: ${{ env.ELIXIR_VERSION }} otp-version: ${{ env.OTP_VERSION }} + # Run this before any other build step. Later steps build into priv/, and + # a stray write there would then go unnoticed. + - name: Build into another PRIV_DIR, leaving the tree unchanged + run: | + make all PRIV_DIR="$RUNNER_TEMP/priv" + test -z "$(git status --porcelain --ignored -- c_src priv)" + - name: Cache deps & build uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: diff --git a/.gitignore b/.gitignore index 0484175..810a8fd 100644 --- a/.gitignore +++ b/.gitignore @@ -28,7 +28,6 @@ net_runner-*.tar priv/*.so priv/*.dylib priv/shepherd -c_src/*.o # Dialyzer /priv/plts/ diff --git a/CHANGELOG.md b/CHANGELOG.md index ff1bb6f..a02e0ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,8 @@ remaining findings across shepherd/NIF, lib, tests, docs, and CI). ### Changed +- **The native build writes no object files.** Each binary is compiled and + linked in one step, so `make PRIV_DIR=…` leaves the source tree unchanged. - **`NetRunner.Process.Nif` renamed to `NetRunner.Nif`** (internal, `@moduledoc false`, but referenced in tests/benches). - **Option-validation convention unified.** Malformed options (unknown keys, diff --git a/Makefile b/Makefile index 31b2119..6080703 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,12 @@ # Makefile for NetRunner native code # -# Builds: -# priv/shepherd - persistent child-process shepherd binary -# priv/net_runner_nif - NIF shared library for async I/O +# Builds these files in PRIV_DIR, which defaults to priv/: +# shepherd - persistent child-process shepherd binary +# net_runner_nif - NIF shared library for async I/O +# +# Only a value on the make command line overrides PRIV_DIR. It is assigned +# with = so that an unrelated PRIV_DIR in the environment cannot move the +# output. PRIV_DIR = priv C_SRC_DIR = c_src @@ -62,7 +66,7 @@ endif # -fvisibility=hidden: only nif_init needs to be exported, and ERL_NIF_INIT # already marks it default-visible. -NIF_CFLAGS = $(CFLAGS) -I$(ERTS_INCLUDE_DIR) -I$(C_SRC_DIR) -fPIC -fvisibility=hidden +NIF_CFLAGS = $(CFLAGS) -I$(ERTS_INCLUDE_DIR) -fPIC -fvisibility=hidden SHEPHERD_CFLAGS = $(CFLAGS) -fPIE @@ -73,14 +77,11 @@ NIF_LIB = $(PRIV_DIR)/net_runner_nif$(NIF_EXT) SHEPHERD_SRC = $(C_SRC_DIR)/shepherd.c NIF_SRC = $(C_SRC_DIR)/net_runner_nif.c -SHEPHERD_OBJ = $(C_SRC_DIR)/shepherd.o -NIF_OBJ = $(C_SRC_DIR)/net_runner_nif.o - HEADERS = $(C_SRC_DIR)/protocol.h $(C_SRC_DIR)/utils.h .PHONY: all clean asan bench bench-perf bench-claims bench-deadlock bench-spawn bench-exec -all: $(PRIV_DIR) $(SHEPHERD) $(NIF_LIB) +all: $(SHEPHERD) $(NIF_LIB) # Convenience: force a sanitizer rebuild. Same as SANITIZE=1 make clean all. asan: @@ -90,22 +91,18 @@ asan: $(PRIV_DIR): mkdir -p $(PRIV_DIR) -# Shepherd binary -$(SHEPHERD): $(SHEPHERD_OBJ) - $(CC) $(SHEPHERD_LDFLAGS) $(LDFLAGS) -o $@ $< - -$(SHEPHERD_OBJ): $(SHEPHERD_SRC) $(HEADERS) - $(CC) $(SHEPHERD_CFLAGS) -I$(C_SRC_DIR) -c -o $@ $< +$(SHEPHERD) $(NIF_LIB): | $(PRIV_DIR) -# NIF shared library -$(NIF_LIB): $(NIF_OBJ) - $(CC) $(NIF_LDFLAGS) $(LDFLAGS) -o $@ $< +# Each binary has one source file, so each rule compiles and links it in one +# command. +$(SHEPHERD): $(SHEPHERD_SRC) $(HEADERS) + $(CC) $(SHEPHERD_CFLAGS) $(SHEPHERD_LDFLAGS) $(LDFLAGS) -o $@ $< -$(NIF_OBJ): $(NIF_SRC) $(HEADERS) - $(CC) $(NIF_CFLAGS) -c -o $@ $< +$(NIF_LIB): $(NIF_SRC) $(HEADERS) + $(CC) $(NIF_CFLAGS) $(NIF_LDFLAGS) $(LDFLAGS) -o $@ $< clean: - rm -f $(SHEPHERD) $(NIF_LIB) $(SHEPHERD_OBJ) $(NIF_OBJ) + rm -f $(SHEPHERD) $(NIF_LIB) # --- Benchmarks (repo-only; see bench/README.md) --- # From e0cf417191c718e093f59dfe09f4167d0f0b144b Mon Sep 17 00:00:00 2001 From: Michael Russo Date: Sat, 26 Sep 2026 09:50:05 -0400 Subject: [PATCH 4/4] feat: add SHEPHERD_STATIC=1 to link the shepherd statically Burrito ships a musl BEAM to hosts that may have only glibc. The NIF uses the libc that the BEAM loads, but the shepherd is a separate executable and needs its own libc. Add `SHEPHERD_STATIC=1` to link the shepherd as a static PIE. It requires a static libc, such as musl's. Reject it on macOS, which has no static libc, and with `SANITIZE=1`, because the sanitizer runtimes do not link statically. Document a cross-target build with `zig cc`, which links musl targets statically without this option. Add an Alpine CI step that links a static shepherd and checks that it is a PIE with no program interpreter and that it starts. --- .github/workflows/ci.yml | 7 +++++++ CHANGELOG.md | 2 ++ Makefile | 12 ++++++++++++ README.md | 19 +++++++++++++++++++ 4 files changed, 40 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ee2077..1211fcb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -189,6 +189,13 @@ jobs: - run: mix compile - run: mix test + - name: Link the shepherd as a static PIE + run: | + make all SHEPHERD_STATIC=1 PRIV_DIR=/tmp/static + readelf -h /tmp/static/shepherd | grep -q 'DYN' + if readelf -l /tmp/static/shepherd | grep -q INTERP; then exit 1; fi + /tmp/static/shepherd 2>&1 | grep -q '^usage: shepherd' + deps-latest: # Simulates consumer dependency resolution: throw away the lockfile and # build against the newest versions Hex resolves. Non-blocking by design. diff --git a/CHANGELOG.md b/CHANGELOG.md index a02e0ad..b4b86a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,8 @@ remaining findings across shepherd/NIF, lib, tests, docs, and CI). ### Added +- **`SHEPHERD_STATIC=1`** links the shepherd as a static PIE (Linux only), for + a release that runs where the build's libc may be absent. - **`NetRunner.Process.read_batch/3` and `read_stderr_batch/3`.** Read up to `max_chunks` pipe chunks in one GenServer round trip. A batch never waits once it has data (it ends at the first EAGAIN), and an EOF/error after ≥1 diff --git a/Makefile b/Makefile index 6080703..e4ea7c7 100644 --- a/Makefile +++ b/Makefile @@ -59,6 +59,18 @@ else NIF_EXT = .so endif +# SHEPHERD_STATIC=1 links the shepherd as a static PIE. The NIF does not need +# this, because it uses the libc that the BEAM loads. +ifeq ($(SHEPHERD_STATIC),1) +ifeq ($(UNAME_S),Darwin) +$(error SHEPHERD_STATIC=1 is Linux-only: macOS has no static libc) +endif +ifeq ($(SANITIZE),1) +$(error SHEPHERD_STATIC=1 cannot be combined with SANITIZE=1: the sanitizer runtimes do not link statically) +endif + SHEPHERD_LDFLAGS += -static-pie +endif + ifeq ($(SANITIZE),1) NIF_LDFLAGS += $(SAN_FLAGS) SHEPHERD_LDFLAGS += $(SAN_FLAGS) diff --git a/README.md b/README.md index 77b7f14..7e17e41 100644 --- a/README.md +++ b/README.md @@ -539,6 +539,25 @@ relying on them. - `make` - Linux or macOS +### Building for another target + +`make` writes the shepherd and NIF to `priv/`, or to the directory given as +`PRIV_DIR` on its command line, and writes nothing else. A release for another +target, such as one that ships a musl BEAM to glibc hosts, can build into its +own `priv`. The NIF must be built against the ERTS that will load it. Run the +build on a Linux host, because the Makefile selects its platform flags from the +host's `uname`. + +```sh +make all \ + CC="zig cc -target x86_64-linux-musl" \ + ERTS_INCLUDE_DIR=/path/to/target/erts-VERSION/include \ + PRIV_DIR=/path/to/release/lib/net_runner-VERSION/priv +``` + +zig cc already links musl targets statically. With gcc or clang and a static +libc, such as musl's, `SHEPHERD_STATIC=1` links the shepherd as a static PIE. + ## License Apache-2.0 — see [LICENSE](LICENSE).