Skip to content

Commit 6857ee6

Browse files
committed
test(leak): continuous memory-leak detection suite (FFI-driven)
test/leak_harness.c drives every allocating pb_* path (encode/decode/format/ hexlike + free) in a deterministic, TIME-BOUNDED loop, self-reporting RSS (mach on darwin, /proc on linux — ps -o rss is entitlement-blocked on macOS). test/leak_test runs it foreground (never backgrounded/infinite, so it can't run away), samples RSS on a wall-clock cadence, and FAILs on sustained growth (final > baseline*1.5 AND +4MB) vs. a flat plateau. Validated both ways: the real lib plateaus (PASS); a deliberately-leaking program is caught (FAIL). Wired into CI as the test-leak flake check (default Zig FFI lib; IMPLEMENTATION_TO_TEST selects a CLI's --leak-seconds mode, coming next). Announces the implementation under test on load.
1 parent e527e3f commit 6857ee6

3 files changed

Lines changed: 210 additions & 0 deletions

File tree

flake.nix

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,21 @@
329329
installPhase = "mkdir -p $out && touch $out/passed";
330330
};
331331

332+
# Continuous memory-leak detection: drive the FFI in a long-lived loop
333+
# and fail if RSS climbs over time (a leak) rather than plateauing.
334+
test-leak = pkgs.stdenv.mkDerivation {
335+
name = "test-leak";
336+
src = ./.;
337+
nativeBuildInputs = with pkgs; [ zig clang ];
338+
buildPhase = ''
339+
export HOME=$TMPDIR
340+
zig build
341+
clang -O2 -Isrc -o leak_harness test/leak_harness.c zig-out/lib/libprintable_binary.a
342+
LEAK_HARNESS=./leak_harness LEAK_SECONDS=6 bash test/leak_test
343+
'';
344+
installPhase = "mkdir -p $out && touch $out/passed";
345+
};
346+
332347
# Guard against a stale generated C header: regenerating from
333348
# character_map.txt must reproduce the committed character_map_embedded.h.
334349
test-embedded-map-sync = pkgs.stdenv.mkDerivation {

test/leak_harness.c

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/*
2+
* leak_harness — drives the printable_binary C FFI in a tight, time-bounded loop
3+
* and prints its own resident-set size (RSS) periodically, so test/leak_test can
4+
* watch for monotonically growing memory (a leak) vs. the flat, bounded memory of
5+
* correct streaming.
6+
*
7+
* Exercises every allocating FFI entry point (encode/decode/format/hexlike) plus
8+
* the non-allocating ones (validate/detect), freeing each result with pb_free.
9+
* Inputs are deterministic pseudo-random so runs are reproducible.
10+
*
11+
* leak_harness [seconds] # self-terminates after N seconds (default 8)
12+
*
13+
* It is deliberately TIME-BOUNDED (never an infinite loop) so it can never run
14+
* away if an external sampler forgets to kill it. It reports RSS itself because
15+
* `ps -o rss` is entitlement-blocked on macOS and /proc is absent there.
16+
*/
17+
#include <stdint.h>
18+
#include <stddef.h>
19+
#include "printable_binary.h"
20+
#include <stdio.h>
21+
#include <stdlib.h>
22+
#include <time.h>
23+
24+
#if defined(__APPLE__)
25+
#include <mach/mach.h>
26+
static long rss_kb(void) {
27+
mach_task_basic_info_data_t info;
28+
mach_msg_type_number_t count = MACH_TASK_BASIC_INFO_COUNT;
29+
if (task_info(mach_task_self(), MACH_TASK_BASIC_INFO,
30+
(task_info_t)&info, &count) != KERN_SUCCESS) return -1;
31+
return (long)(info.resident_size / 1024);
32+
}
33+
#else
34+
#include <unistd.h>
35+
static long rss_kb(void) {
36+
FILE *f = fopen("/proc/self/statm", "r");
37+
if (!f) return -1;
38+
long total = 0, resident = 0;
39+
int n = fscanf(f, "%ld %ld", &total, &resident);
40+
fclose(f);
41+
if (n != 2) return -1;
42+
return resident * (sysconf(_SC_PAGESIZE) / 1024);
43+
}
44+
#endif
45+
46+
static long now_ms(void) {
47+
struct timespec ts;
48+
clock_gettime(CLOCK_MONOTONIC, &ts);
49+
return ts.tv_sec * 1000L + ts.tv_nsec / 1000000L;
50+
}
51+
52+
/* Deterministic LCG — no libc rand() dependency, reproducible across platforms. */
53+
static uint64_t rng_state = 0x9E3779B97F4A7C15ULL;
54+
static uint32_t next_rng(void) {
55+
rng_state = rng_state * 6364136223846793005ULL + 1442695040888963407ULL;
56+
return (uint32_t)(rng_state >> 33);
57+
}
58+
59+
int main(int argc, char **argv) {
60+
long max_seconds = (argc > 1) ? atol(argv[1]) : 8;
61+
if (max_seconds <= 0) max_seconds = 8;
62+
const long start_ms = now_ms();
63+
const long deadline_ms = start_ms + max_seconds * 1000L;
64+
long last_sample_ms = start_ms;
65+
static unsigned char buf[8192];
66+
67+
printf("RSS %ld\n", rss_kb());
68+
fflush(stdout);
69+
70+
for (long i = 0;; i++) {
71+
if ((i & 0x3FF) == 0) { /* cheap gate; sample on a wall-clock cadence */
72+
const long t = now_ms();
73+
if (t >= deadline_ms) break;
74+
if (t - last_sample_ms >= 100) { /* ~10 samples/sec, loop-speed independent */
75+
printf("RSS %ld\n", rss_kb());
76+
fflush(stdout);
77+
last_sample_ms = t;
78+
}
79+
}
80+
81+
size_t len = (size_t)(next_rng() % (sizeof(buf) + 1));
82+
for (size_t k = 0; k < len; k++) buf[k] = (unsigned char)next_rng();
83+
84+
/* encode -> decode + format roundtrips */
85+
pb_ffi_result_t enc = pb_encode((const char *)buf, len, 0, NULL, 0);
86+
if (enc.error_code == 0 && enc.data) {
87+
pb_ffi_result_t dec = pb_decode(enc.data, enc.len, 0);
88+
if (dec.error_code == 0 && dec.data) pb_free(dec.data, dec.len);
89+
pb_ffi_result_t fmt = pb_format(enc.data, enc.len, 8, 10, 0);
90+
if (fmt.error_code == 0 && fmt.data) pb_free(fmt.data, fmt.len);
91+
pb_free(enc.data, enc.len);
92+
}
93+
94+
/* hexlike encode -> decode roundtrip */
95+
pb_ffi_result_t hx = pb_hexlike_encode((const char *)buf, len, 0);
96+
if (hx.error_code == 0 && hx.data) {
97+
pb_ffi_result_t hd = pb_hexlike_decode(hx.data, hx.len, 0);
98+
if (hd.error_code == 0 && hd.data) pb_free(hd.data, hd.len);
99+
pb_free(hx.data, hx.len);
100+
}
101+
102+
/* non-allocating exports */
103+
(void)pb_validate((const char *)buf, len, 0);
104+
(void)pb_detect_double_encode((const char *)buf, len, 0.05f);
105+
}
106+
107+
printf("RSS %ld\n", rss_kb());
108+
fflush(stdout);
109+
return 0;
110+
}

test/leak_test

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Memory-leak suite. Runs an implementation in a long-lived, TIME-BOUNDED loop
4+
# that prints its own RSS periodically, then FAILS if memory grows monotonically
5+
# over time (a leak) instead of plateauing after warmup (correct, bounded
6+
# streaming). The target self-terminates, so nothing can run away.
7+
#
8+
# Two modes (both supported):
9+
# 1. FFI mode (default): drives the Zig static lib via test/leak_harness.c —
10+
# the truest leak surface, exercising every pb_* alloc/free path.
11+
# 2. CLI mode: set IMPLEMENTATION_TO_TEST=<cli> to drive a CLI binary's
12+
# long-lived --leak-seconds loop (env-selectable across implementations).
13+
#
14+
# Env:
15+
# IMPLEMENTATION_TO_TEST CLI to leak-test (CLI mode). Unset => FFI mode.
16+
# LEAK_HARNESS Path to a prebuilt leak_harness (FFI mode).
17+
# LEAK_SECONDS Run/sample window in seconds (default 8).
18+
# LEAK_GROWTH_FACTOR Fail if final RSS > baseline * this (default 1.5).
19+
# LEAK_MIN_GROWTH_KB ...and grew by at least this many KB (default 4096).
20+
set -uo pipefail
21+
22+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
23+
SECONDS_RUN="${LEAK_SECONDS:-8}"
24+
GROWTH_FACTOR="${LEAK_GROWTH_FACTOR:-1.5}"
25+
MIN_GROWTH_KB="${LEAK_MIN_GROWTH_KB:-4096}"
26+
27+
RED='\033[0;31m'; GREEN='\033[0;32m'; BLUE='\033[0;34m'; YEL='\033[0;33m'; NC='\033[0m'
28+
29+
if [ -n "${IMPLEMENTATION_TO_TEST:-}" ]; then
30+
MODE="CLI"
31+
TARGET=("${IMPLEMENTATION_TO_TEST}" --leak-seconds "$SECONDS_RUN")
32+
LABEL="CLI: ${IMPLEMENTATION_TO_TEST}"
33+
else
34+
MODE="FFI"
35+
HARNESS="${LEAK_HARNESS:-$SCRIPT_DIR/leak_harness}"
36+
if [ ! -x "$HARNESS" ]; then
37+
echo -e "${RED}leak_harness not found at $HARNESS${NC} — build it first (see test-leak in flake.nix)" >&2
38+
exit 2
39+
fi
40+
TARGET=("$HARNESS" "$SECONDS_RUN")
41+
LABEL="Zig FFI lib (via leak_harness)"
42+
fi
43+
44+
echo -e "${BLUE}==> Memory-leak suite${NC} — implementation under test: ${YEL}${LABEL}${NC}" >&2
45+
echo " mode=$MODE window=${SECONDS_RUN}s fail if final RSS > baseline*${GROWTH_FACTOR} and +${MIN_GROWTH_KB}KB" >&2
46+
47+
# Run foreground (target self-terminates); a hard timeout is a belt-and-suspenders
48+
# guard in case a build regresses the time bound.
49+
LOG="$(mktemp)"
50+
trap 'rm -f "$LOG"' EXIT
51+
HARD_LIMIT=$(( SECONDS_RUN + 15 ))
52+
if command -v timeout >/dev/null 2>&1; then
53+
timeout "$HARD_LIMIT" "${TARGET[@]}" >"$LOG" 2>/dev/null
54+
else
55+
"${TARGET[@]}" >"$LOG" 2>/dev/null
56+
fi
57+
58+
mapfile -t RSS < <(awk '/^RSS /{print $2}' "$LOG" | grep -E '^[0-9]+$')
59+
N="${#RSS[@]}"
60+
if [ "$N" -lt 4 ]; then
61+
echo -e "${RED}FAIL${NC} — only $N RSS samples (target crashed or printed nothing?)." >&2
62+
sed -n '1,5p' "$LOG" >&2
63+
exit 1
64+
fi
65+
66+
median() { printf '%s\n' "$@" | sort -n | awk '{a[NR]=$1} END{m=int(NR/2); print (NR%2)? a[m+1] : int((a[m]+a[m+1])/2)}'; }
67+
68+
# baseline = median of the first third (after the first warmup sample);
69+
# final = median of the last quarter.
70+
w_start=1
71+
w_len=$(( N / 3 )); [ "$w_len" -lt 2 ] && w_len=2
72+
f_len=$(( N / 4 )); [ "$f_len" -lt 2 ] && f_len=2
73+
baseline=$(median "${RSS[@]:$w_start:$w_len}")
74+
final=$(median "${RSS[@]: -$f_len}")
75+
76+
echo " $N RSS samples (KB): first=${RSS[0]} baseline=${baseline} final=${final} last=${RSS[-1]}" >&2
77+
78+
grew=$(( final - baseline ))
79+
threshold=$(awk -v b="$baseline" -v f="$GROWTH_FACTOR" 'BEGIN{printf "%d", b*f}')
80+
if [ "$final" -gt "$threshold" ] && [ "$grew" -gt "$MIN_GROWTH_KB" ]; then
81+
echo -e "${RED}FAIL — RSS grew ${grew}KB (baseline ${baseline} -> final ${final}); likely a leak.${NC}" >&2
82+
exit 1
83+
fi
84+
echo -e "${GREEN}PASS — RSS stable after warmup (Δ${grew}KB).${NC}" >&2
85+
exit 0

0 commit comments

Comments
 (0)