diff --git a/Makefile.cbm b/Makefile.cbm index 65f73a394..97d56a8fd 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -67,8 +67,18 @@ EDITOR_TEST_DEFINES = -DCBM_JSON_LIKE_ENABLE_TEST_API=1 \ -DCBM_TOML_EDIT_ENABLE_TEST_API=1 -DCBM_YAML_ENABLE_TEST_API=1 \ -DCBM_TEXT_EDIT_ENABLE_TEST_API=1 -DCBM_CLI_ENABLE_TEST_API=1 \ -DCBM_DIAGNOSTICS_ENABLE_TEST_API=1 -CFLAGS_TEST = $(CFLAGS_COMMON) $(EDITOR_TEST_DEFINES) -g -O1 $(SANITIZE) -CXXFLAGS_TEST = $(CXXFLAGS_COMMON) -g -O1 $(SANITIZE) +# The build system is the single source of truth for "is this binary +# instrumented": compiler-specific probes (__SANITIZE_ADDRESS__) miss +# clang's feature-check spelling and every non-ASan sanitizer, so the +# trap-UBSan leg once ran NATIVE timing budgets on an instrumented +# binary. Any non-empty SANITIZE ⇒ sanitized budgets everywhere. +ifneq ($(strip $(SANITIZE)),) +SANITIZED_DEFINE = -DCBM_SANITIZED_BUILD=1 +else +SANITIZED_DEFINE = +endif +CFLAGS_TEST = $(CFLAGS_COMMON) $(EDITOR_TEST_DEFINES) $(SANITIZED_DEFINE) -g -O1 $(SANITIZE) +CXXFLAGS_TEST = $(CXXFLAGS_COMMON) $(SANITIZED_DEFINE) -g -O1 $(SANITIZE) # TSan (can't combine with ASan) TSAN_SANITIZE = -fsanitize=thread -fno-omit-frame-pointer diff --git a/scripts/run-tests-parallel.sh b/scripts/run-tests-parallel.sh index 5062d20af..a9eb0d432 100644 --- a/scripts/run-tests-parallel.sh +++ b/scripts/run-tests-parallel.sh @@ -54,9 +54,17 @@ stamp_windows_build_dir() { MINGW* | MSYS*) ;; *) return 0 ;; esac - local runner_dir_w me stamp_out reset_out + local runner_dir_w me norm_out stamp_out reset_out runner_dir_w="$(cygpath -w "$(dirname "$RUNNER")")" me="$(whoami | tr -d '\r')" + # Normalize FIRST: some runner images stamp EXPLICIT (non-inherited) + # Authenticated-Users ACEs onto the workspace tree, which /inheritance:r + # cannot strip and /grant:r does not touch (it replaces only the granted + # SIDs' own entries). /reset drops every explicit ACE and restores pure + # inheritance, so the protect-and-grant below starts from a known shape + # regardless of image provisioning. + norm_out=$(MSYS2_ARG_CONV_EXCL='*' icacls "$runner_dir_w" /reset /Q 2>&1) || + echo "WARN: build-dir DACL normalize ($when) failed: $norm_out" stamp_out=$(MSYS2_ARG_CONV_EXCL='*' icacls "$runner_dir_w" /inheritance:r \ /grant:r "${me}:(OI)(CI)F" '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' \ /Q 2>&1) || echo "WARN: build-dir DACL stamp ($when) failed (user=$me dir=$runner_dir_w): $stamp_out" @@ -67,8 +75,9 @@ stamp_windows_build_dir() { # even see WHETHER it had run. if MSYS2_ARG_CONV_EXCL='*' icacls "$runner_dir_w" 2>/dev/null | grep -qE 'Authenticated Users|CREATOR OWNER'; then - echo "WARN: build-dir DACL still grants cross-account mutation after $when stamp:" + echo "FAIL: build-dir DACL still grants cross-account mutation after $when stamp:" MSYS2_ARG_CONV_EXCL='*' icacls "$runner_dir_w" 2>&1 | head -8 + exit 1 else echo "build-dir DACL stamped clean ($when, $runner_dir_w, user=$me)" fi @@ -215,6 +224,14 @@ run_one() { fi secs=$((SECONDS - t0)) summary=$(grep -E '^ [0-9]+ passed' "$LOGDIR/$s.log" | tail -1) + # A suite that exits 0 WITHOUT printing its completion summary ran zero + # tests as far as anyone can prove (mis-parsed argv, drifted registration + # macro, early return) — that is a failure, not a green with pass=0. + if [ "$rc" -eq 0 ] && [ -z "$summary" ]; then + rc=97 + echo " FAIL: suite '$s' exited 0 without a completion summary (ran nothing?)" \ + >> "$LOGDIR/$s.log" + fi pass=$(printf '%s' "$summary" | sed -n 's/^ \([0-9]*\) passed.*/\1/p') failn=$(printf '%s' "$summary" | sed -n 's/.* \([0-9]*\) failed.*/\1/p') skip=$(printf '%s' "$summary" | sed -n 's/.* \([0-9]*\) skipped.*/\1/p') diff --git a/scripts/security-fuzz-random.sh b/scripts/security-fuzz-random.sh index 5015a9df2..baa71e5ae 100755 --- a/scripts/security-fuzz-random.sh +++ b/scripts/security-fuzz-random.sh @@ -1,6 +1,17 @@ #!/usr/bin/env bash set -euo pipefail +# The fuzzer IS python3; without it this gate would run zero mutations while +# still reporting green. Fail loudly instead. +command -v python3 >/dev/null 2>&1 || { + echo "FAIL: python3 missing — the fuzz gate cannot generate payloads" >&2 + exit 1 +} +# Reproducibility: every payload derives from a logged seed, so any crash is +# re-runnable by construction (CBM_FUZZ_SEED overrides for replay). +FUZZ_SEED="${CBM_FUZZ_SEED:-$$}" +echo "fuzz seed: $FUZZ_SEED (replay: CBM_FUZZ_SEED=$FUZZ_SEED)" + # Fuzz testing: feeds random/mutated inputs to the MCP server and CLI # to find crashes, hangs, and memory errors. Runs for a limited time. # @@ -25,11 +36,15 @@ END_TIME=$((SECONDS + DURATION)) # Portable timeout run_with_timeout() { + # PRESERVES the child's exit status: the whole gate exists to observe + # crash codes (139/134/136), and an `|| true` here once made EC + # unconditionally 0 — the crash checks were dead code and a SIGSEGV + # read green. Timeout reports 124, which the checks correctly ignore. local secs="$1"; shift if command -v timeout &>/dev/null; then - timeout "$secs" "$@" || true + timeout "$secs" "$@" else - perl -e "alarm($secs); exec @ARGV" -- "$@" || true + perl -e "alarm($secs); exec @ARGV" -- "$@" fi } @@ -43,6 +58,7 @@ while [ $SECONDS -lt $END_TIME ]; do # Generate a random mutated JSON-RPC payload PAYLOAD=$(python3 -c " import random, string, json +random.seed($FUZZ_SEED + $ITERATIONS) # Base valid request base = { @@ -111,8 +127,8 @@ except: printf '%s\n%s\n' "$INIT" "$PAYLOAD" > "$FUZZ_TMPDIR/input.jsonl" # Run and check for crashes (not exit code — we expect errors, just not crashes) - run_with_timeout 5 "$BINARY" < "$FUZZ_TMPDIR/input.jsonl" > /dev/null 2>&1 - EC=$? + EC=0 + run_with_timeout 5 "$BINARY" < "$FUZZ_TMPDIR/input.jsonl" > /dev/null 2>&1 || EC=$? # 139 = SIGSEGV, 134 = SIGABRT, 136 = SIGFPE — real crashes if [ $EC -eq 139 ] || [ $EC -eq 134 ] || [ $EC -eq 136 ]; then @@ -135,7 +151,8 @@ while [ $SECONDS -lt $CLI_END ]; do CLI_ITERATIONS=$((CLI_ITERATIONS + 1)) QUERY=$(python3 -c " -import random, string +import random +random.seed($FUZZ_SEED + 100000 + $CLI_ITERATIONS), string parts = ['MATCH', 'WHERE', 'RETURN', '(', ')', '-[', ']->', '<-[', ']-', 'n', 'r', '.name', '.label', '=', '\"', \"'\", ';', '--', 'DROP', 'DELETE', 'ATTACH', 'DETACH', '*', 'COUNT', 'LIMIT', 'ORDER BY'] @@ -146,7 +163,8 @@ if random.random() > 0.5: print(q) " 2>/dev/null || echo "MATCH (n) RETURN n") - run_with_timeout 3 "$BINARY" cli query_graph "{\"query\":\"$QUERY\"}" > /dev/null 2>&1 + EC=0 + run_with_timeout 3 "$BINARY" cli query_graph "{\"query\":\"$QUERY\"}" > /dev/null 2>&1 || EC=$? EC=$? if [ $EC -eq 139 ] || [ $EC -eq 134 ] || [ $EC -eq 136 ]; then diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 6e53aad3e..bfa7541e6 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -82,6 +82,21 @@ retire_account_daemon() { fi } +# Tolerate ERRORS, never CRASHES: the adversarial phases assert "doesn't +# crash", and a bare `|| true` discards exactly the crash exit the claim is +# about. rc >= 128 means killed by signal / fatal status (SIGSEGV=139, +# SIGABRT=134 — ASan aborts land here too). +run_no_crash() { + local label="$1"; shift + local rc=0 + "$@" > /dev/null 2>&1 || rc=$? + if [ "$rc" -ge 128 ]; then + echo "FAIL $label: crashed (rc=$rc)" + exit 1 + fi + return 0 +} + TMPDIR=$(smoke_mktemp_dir) DRYRUN_HOME="" # On MSYS2/Windows, convert POSIX path to native Windows path for the binary @@ -2596,7 +2611,12 @@ echo "--- Phase 9b: adversarial install/uninstall tests ---" # completes without crash and prints "Detected agents:" line. EMPTY_HOME=$(smoke_mktemp_dir) mkdir -p "$EMPTY_HOME/.local/bin" -INSTALL_OUT=$(HOME="$EMPTY_HOME" LOCALAPPDATA="$EMPTY_HOME/AppData/Local" "$BINARY" install -y 2>&1) || true +INSTALL_RC=0 +INSTALL_OUT=$(HOME="$EMPTY_HOME" LOCALAPPDATA="$EMPTY_HOME/AppData/Local" "$BINARY" install -y 2>&1) || INSTALL_RC=$? +if [ "$INSTALL_RC" -ge 128 ]; then + echo "FAIL 9b-1: install crashed (rc=$INSTALL_RC)" + exit 1 +fi if ! echo "$INSTALL_OUT" | grep -qi 'detected agents'; then echo "FAIL 9b-1: install output missing 'Detected agents' line" exit 1 @@ -2609,12 +2629,12 @@ rm -rf "$EMPTY_HOME" IDEM_HOME=$(smoke_mktemp_dir) mkdir -p "$IDEM_HOME/.claude" "$IDEM_HOME/.local/bin" copy_smoke_binary "$IDEM_HOME/.local/bin/codebase-memory-mcp" -HOME="$IDEM_HOME" LOCALAPPDATA="$IDEM_HOME/AppData/Local" "$BINARY" install -y 2>&1 > /dev/null || true +run_no_crash 9b-2 env HOME="$IDEM_HOME" LOCALAPPDATA="$IDEM_HOME/AppData/Local" "$BINARY" install -y IDEM_INSTALLER="$BINARY" if [[ "$BINARY" == *.exe ]]; then IDEM_INSTALLER="$IDEM_HOME/.local/bin/codebase-memory-mcp.exe" fi -HOME="$IDEM_HOME" LOCALAPPDATA="$IDEM_HOME/AppData/Local" "$IDEM_INSTALLER" install -y 2>&1 > /dev/null || true +run_no_crash 9b-2-second env HOME="$IDEM_HOME" LOCALAPPDATA="$IDEM_HOME/AppData/Local" "$IDEM_INSTALLER" install -y # Count MCP entries — should be exactly 1 COUNT=$(cat "$IDEM_HOME/.claude.json" 2>/dev/null | python3 -c " import json, sys @@ -2632,7 +2652,12 @@ rm -rf "$IDEM_HOME" # 9b-3: Uninstall without prior install CLEAN_HOME=$(smoke_mktemp_dir) mkdir -p "$CLEAN_HOME/.claude" "$CLEAN_HOME/.local/bin" -UNINSTALL_OUT=$(HOME="$CLEAN_HOME" "$BINARY" uninstall -y -n 2>&1) || true +UNINSTALL_RC=0 +UNINSTALL_OUT=$(HOME="$CLEAN_HOME" "$BINARY" uninstall -y -n 2>&1) || UNINSTALL_RC=$? +if [ "$UNINSTALL_RC" -ge 128 ]; then + echo "FAIL 9b-3: uninstall crashed (rc=$UNINSTALL_RC)" + exit 1 +fi echo "OK 9b-3: uninstall without install doesn't crash" retire_account_daemon "9b-3-cleanup" rm -rf "$CLEAN_HOME" @@ -2642,7 +2667,7 @@ CORRUPT_HOME=$(smoke_mktemp_dir) mkdir -p "$CORRUPT_HOME/.claude" "$CORRUPT_HOME/.local/bin" copy_smoke_binary "$CORRUPT_HOME/.local/bin/codebase-memory-mcp" echo '{invalid json here' > "$CORRUPT_HOME/.claude.json" -HOME="$CORRUPT_HOME" "$BINARY" install -y 2>&1 > /dev/null || true +run_no_crash 9b-4 env HOME="$CORRUPT_HOME" "$BINARY" install -y # Should either fix it or handle gracefully — not crash echo "OK 9b-4: install over corrupt JSON doesn't crash" retire_account_daemon "9b-4-cleanup" @@ -2652,13 +2677,13 @@ rm -rf "$CORRUPT_HOME" DBL_HOME=$(smoke_mktemp_dir) mkdir -p "$DBL_HOME/.claude" "$DBL_HOME/.local/bin" copy_smoke_binary "$DBL_HOME/.local/bin/codebase-memory-mcp" -HOME="$DBL_HOME" "$BINARY" install -y 2>&1 > /dev/null || true +run_no_crash 9b-8-install env HOME="$DBL_HOME" "$BINARY" install -y DBL_UNINSTALLER="$BINARY" if [[ "$BINARY" == *.exe ]]; then DBL_UNINSTALLER="$DBL_HOME/.local/bin/codebase-memory-mcp.exe" fi -HOME="$DBL_HOME" "$DBL_UNINSTALLER" uninstall -y -n 2>&1 > /dev/null || true -HOME="$DBL_HOME" "$BINARY" uninstall -y -n 2>&1 > /dev/null || true +run_no_crash 9b-8-first env HOME="$DBL_HOME" "$DBL_UNINSTALLER" uninstall -y -n +run_no_crash 9b-8-second env HOME="$DBL_HOME" "$BINARY" uninstall -y -n echo "OK 9b-8: double uninstall doesn't crash" retire_account_daemon "9b-8-cleanup" rm -rf "$DBL_HOME" @@ -2783,7 +2808,8 @@ echo "=== Phase 11: process kill E2E ===" # Start MCP server in background MCP_KILL_INPUT=$(smoke_mktemp_file) echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"kill-test","version":"1.0"}}}' > "$MCP_KILL_INPUT" -"$BINARY" < "$MCP_KILL_INPUT" > /dev/null 2>&1 & +KILL_OUT=$(smoke_mktemp_file) +"$BINARY" < "$MCP_KILL_INPUT" > "$KILL_OUT" 2>&1 & KILL_PID=$! sleep 1 @@ -2798,8 +2824,20 @@ if kill -0 "$KILL_PID" 2>/dev/null; then fi echo "OK 11c-d: process killed successfully" else - echo "OK 11: MCP server already exited (clean shutdown on EOF)" + # The server ended before the probe: distinguish clean shutdown-on-EOF + # from an instant crash — the old branch treated both as OK with the + # output discarded, so a startup segfault read as a clean pass on any + # fast machine. A clean run must have exited 0 AND produced the + # initialize response. + KILL_RC=0 + wait "$KILL_PID" 2>/dev/null || KILL_RC=$? + if [ "$KILL_RC" -ge 128 ] || ! grep -q '"jsonrpc"' "$KILL_OUT"; then + echo "FAIL 11: server exited before probe with rc=$KILL_RC and $(wc -c < "$KILL_OUT") bytes of output — crash, not clean EOF shutdown" + exit 1 + fi + echo "OK 11: MCP server already exited (clean shutdown on EOF, initialize answered)" fi +rm -f "$KILL_OUT" rm -f "$MCP_KILL_INPUT" @@ -3228,13 +3266,24 @@ fi echo "" echo "=== Phase 15: UI HTTP server ===" -UI_PORT=19876 +# A kernel-assigned free port instead of a fixed one: a squatter on a fixed +# port made the whole phase read as SKIP. The tiny TOCTOU window between +# probe and bind is the residual risk, not the common case. +UI_PORT=$(python3 -c 'import socket; s = socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()') UI_INPUT=$(smoke_mktemp_file) "$BINARY" --port "$UI_PORT" < "$UI_INPUT" > /dev/null 2>&1 & UI_PID=$! -sleep 1 +# Readiness poll instead of a fixed sleep: SKIP is legitimate ONLY when the +# process exited (the documented no-embedded-assets case); a slow start on a +# loaded runner must not masquerade as it. +UI_READY=0 +for _ in $(seq 1 100); do + if ! kill -0 "$UI_PID" 2>/dev/null; then break; fi + if curl -sf "http://127.0.0.1:$UI_PORT/" -o /dev/null 2>/dev/null; then UI_READY=1; break; fi + sleep 0.1 +done -if kill -0 "$UI_PID" 2>/dev/null; then +if [ "$UI_READY" -eq 1 ] || kill -0 "$UI_PID" 2>/dev/null; then # 15a: GET / returns 200 with HTML content UI_BODY=$(curl -sf "http://127.0.0.1:$UI_PORT/" 2>/dev/null || echo "") if echo "$UI_BODY" | grep -qi "/dev/null; then echo "SKIP 15b: /rpc not reachable" else echo "FAIL 15b: /rpc did not return JSON-RPC" + kill "$UI_PID" 2>/dev/null || true + exit 1 fi kill "$UI_PID" 2>/dev/null || true diff --git a/scripts/soak-test.sh b/scripts/soak-test.sh index 76b09bc75..6d032b4dc 100755 --- a/scripts/soak-test.sh +++ b/scripts/soak-test.sh @@ -340,6 +340,19 @@ mcp_call() { MCP_LAST_RESPONSE="$resp" if json_rpc_response_ok "$id" "$resp"; then exit_code=0 + else + # A stale (previous, late) response desyncs every later + # round-trip: drain queued lines until the matching id shows up + # or the stream runs dry, so one slow reply costs one failed op + # instead of corrupting the whole run's verdicts. + local drain="" + while read -r -t 2 drain <&4 2>/dev/null; do + MCP_LAST_RESPONSE="$drain" + if json_rpc_response_ok "$id" "$drain"; then + exit_code=0 + break + fi + done fi fi local t1 @@ -426,7 +439,9 @@ wait_for_diagnostics_snapshot() { return 1 } +SNAPSHOT_ATTEMPTS=0 collect_snapshot() { + SNAPSHOT_ATTEMPTS=$((SNAPSHOT_ATTEMPTS + 1)) refresh_diagnostics_paths || return 0 if [ -f "$DIAG_FILE" ]; then python3 -c " @@ -681,6 +696,16 @@ fi # ── Analysis ───────────────────────────────────────────────────── +# Check 0: the analysis must have DATA. Snapshot collection silently +# tolerates transient misses, so a run where diagnostics never materialized +# used to sail through every check vacuously — the leak/FD/latency verdicts +# below are only meaningful if most sampling attempts actually landed. +SNAPSHOT_ROWS=$(($(wc -l < "$METRICS_CSV") - 1)) +if [ "$SNAPSHOT_ATTEMPTS" -gt 0 ] && [ $((SNAPSHOT_ROWS * 10)) -lt $((SNAPSHOT_ATTEMPTS * 6)) ]; then + echo "FAIL: only ${SNAPSHOT_ROWS}/${SNAPSHOT_ATTEMPTS} snapshots collected — analysis would be vacuous" | tee -a "$SUMMARY" + PASS=false +fi + # Check 1: Memory leak detection via RSS trend # This is the primary leak detector on ALL platforms (including Windows # where LeakSanitizer is unavailable). Catches both linear leaks (slope) diff --git a/scripts/test-windows.ps1 b/scripts/test-windows.ps1 index bd3dd7b26..ff67a9d7a 100644 --- a/scripts/test-windows.ps1 +++ b/scripts/test-windows.ps1 @@ -227,9 +227,16 @@ foreach ($t in $guards) { } elseif ($code -eq 1 -or $t -eq "tests\windows\test_windows_launcher.py") { Write-Host "RED ($t) - REGRESSION: a fixed Windows bug is broken again" -ForegroundColor Red $guardFailures += $t - } else { - Write-Host "PRECONDITION ($t) exit=$code - skipped (see message above)" -ForegroundColor Yellow + } elseif ($code -eq 2) { + # Exit 2 is the guards' DOCUMENTED precondition-skip contract; every + # other unexpected code (a crashed python, an access-violation status, + # a mistyped guard) is a FAILURE - an uncontracted exit once let a + # crashing guard read as an invisible skip under a green banner. + Write-Host "PRECONDITION ($t) exit=2 - skipped (see message above)" -ForegroundColor Yellow $guardSkips += $t + } else { + Write-Host "FAILED ($t) exit=$code - crashed or exited outside the guard contract 0/1/2" -ForegroundColor Red + $guardFailures += $t } } @@ -266,6 +273,10 @@ if ($guardSkips.Count -gt 0) { if ($fixedKeepers.Count -gt 0) { Write-Host ("Known-red repros that are now GREEN (promote to guards): {0}" -f ($fixedKeepers -join ", ")) -ForegroundColor Green } +if ($guardSkips.Count -eq $guards.Count -and $guards.Count -gt 0) { + Write-Host "FAIL: every guard skipped - nothing was actually verified" -ForegroundColor Red + exit 1 +} if ($guardFailures.Count -gt 0) { Write-Host ("REGRESSION: {0} green guard(s) went red: {1}" -f $guardFailures.Count, ($guardFailures -join ", ")) -ForegroundColor Red Write-Host "A previously-fixed Windows bug is broken again (see the guard's docstring and its referenced issue)." -ForegroundColor Red diff --git a/src/daemon/application.c b/src/daemon/application.c index 20ecbb6ad..f1a7c673e 100644 --- a/src/daemon/application.c +++ b/src/daemon/application.c @@ -211,6 +211,35 @@ void cbm_daemon_application_fail_next_job_thread_start_for_test(void) { memory_order_release); } +static atomic_bool g_application_hold_job_before_start_for_test = ATOMIC_VAR_INIT(false); + +/* Counts completed background-initialize passes (the request-handler tail + * that evaluates auto-index admission). The client can observe its + * initialize RESPONSE before this tail finishes, so a test asserting the + * tail's NEGATIVE outcome ("no job was admitted") needs this positive + * completion signal — a fixed sleep is a lottery in both directions. */ +static atomic_int g_application_background_initializes_for_test = ATOMIC_VAR_INIT(0); + +int cbm_daemon_application_background_initializes_for_test(void) { + return atomic_load_explicit(&g_application_background_initializes_for_test, + memory_order_acquire); +} + +/* Counts iterations of the explicit-index busy-queue wait loop. A test that + * needs "the request is parked behind the physical job limit" waits for a + * DELTA here — the positive signal that queueing (not an error return) + * happened — before releasing the slot. */ +static atomic_int g_application_busy_queue_waits_for_test = ATOMIC_VAR_INIT(0); + +int cbm_daemon_application_busy_queue_waits_for_test(void) { + return atomic_load_explicit(&g_application_busy_queue_waits_for_test, memory_order_acquire); +} + +void cbm_daemon_application_hold_job_before_start_for_test(bool hold) { + atomic_store_explicit(&g_application_hold_job_before_start_for_test, hold, + memory_order_release); +} + static int application_job_thread_create(cbm_thread_t *thread, void *context) { if (atomic_exchange_explicit(&g_application_fail_next_job_thread_start_for_test, false, memory_order_acq_rel)) { @@ -1541,6 +1570,15 @@ static void *application_job_thread(void *opaque) { cbm_daemon_application_job_t *job = opaque; application_job_execution_t execution; application_job_execution_init(&execution); + /* Deterministic-interleaving seam: a held gate parks this thread before + * its first pre-start cancel check, so tests can force the + * cancel-wins-before-worker-start ordering that otherwise needs a + * descheduled thread on a loaded runner (CI is never a lottery: the + * interleaving must be reproducible by construction). */ + while ( + atomic_load_explicit(&g_application_hold_job_before_start_for_test, memory_order_acquire)) { + cbm_usleep(1000); + } /* The linked non-terminal job is the daemon-internal reservation: it * coalesces identical requests and blocks same-project daemon mutations. @@ -1982,7 +2020,7 @@ static char *application_auto_index_args(const char *root_path) { return args; } -static void application_background_initialize(cbm_daemon_application_session_t *session) { +static void application_background_initialize_impl(cbm_daemon_application_session_t *session) { if (!session || !session->application || !session->context_set || session->tool_profile != CBM_MCP_TOOL_PROFILE_ALL || session->hook_event || session->hook_dialect) { @@ -2061,6 +2099,12 @@ static void application_background_initialize(cbm_daemon_application_session_t * application_refresh_watch(session); } +static void application_background_initialize(cbm_daemon_application_session_t *session) { + application_background_initialize_impl(session); + atomic_fetch_add_explicit(&g_application_background_initializes_for_test, 1, + memory_order_release); +} + static bool application_jsonrpc_success(const char *response) { yyjson_doc *document = response ? yyjson_read(response, strlen(response), 0) : NULL; yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; @@ -2244,18 +2288,36 @@ static char *application_index_execute(void *context, const char *root_path, return cbm_mcp_text_result("failed to derive index project identity", true); } application_job_subscribe_status_t subscribe_status = APPLICATION_JOB_SUBSCRIBE_UNAVAILABLE; - cbm_daemon_application_job_t *job = application_job_subscribe( - session->application, project_key, root_path, args_json, &subscribe_status); + cbm_daemon_application_job_t *job = NULL; + for (;;) { + job = application_job_subscribe(session->application, project_key, root_path, args_json, + &subscribe_status); + if (job || (subscribe_status != APPLICATION_JOB_SUBSCRIBE_BUSY && + subscribe_status != APPLICATION_JOB_SUBSCRIBE_CANCELLING)) { + break; + } + /* Physical job limit reached (or a same-project cancel still + * draining): QUEUE instead of surfacing a raw busy error. The + * request thread blocks for the whole index anyway, so waiting for + * a slot is the same contract — and the wait stays cancellable and + * shutdown-aware (a stopping coordinator answers UNAVAILABLE, which + * exits this loop with the error below). */ + atomic_fetch_add_explicit(&g_application_busy_queue_waits_for_test, 1, + memory_order_release); + cbm_mutex_lock(&session->application->mutex); + bool queued_cancelled = application_request_cancelled_locked(session); + cbm_mutex_unlock(&session->application->mutex); + if (queued_cancelled) { + free(project_key); + return cbm_mcp_text_result("index operation cancelled for this session", true); + } + cbm_usleep(APPLICATION_JOB_POLL_US); + } free(project_key); if (!job) { const char *message = "daemon index coordinator is stopping or unavailable"; if (subscribe_status == APPLICATION_JOB_SUBSCRIBE_OPTIONS_CONFLICT) { message = "another index operation for this project is active with different options"; - } else if (subscribe_status == APPLICATION_JOB_SUBSCRIBE_BUSY) { - message = - "daemon index coordinator is busy: physical index job limit reached; retry later"; - } else if (subscribe_status == APPLICATION_JOB_SUBSCRIBE_CANCELLING) { - message = "another index operation for this project is cancelling; retry shortly"; } else if (subscribe_status == APPLICATION_JOB_SUBSCRIBE_ALLOCATION_FAILED) { message = "daemon index coordinator could not allocate an index job"; } diff --git a/src/daemon/application_internal.h b/src/daemon/application_internal.h index 81f283161..a358cb21c 100644 --- a/src/daemon/application_internal.h +++ b/src/daemon/application_internal.h @@ -19,4 +19,20 @@ bool cbm_daemon_application_session_retains_store_for_test( * back rather than retained as terminal background state. */ void cbm_daemon_application_fail_next_job_thread_start_for_test(void); +/* Park every new job thread before its first pre-start cancel check while + * held. This makes the cancel-wins-before-worker-start interleaving — which + * otherwise needs a descheduled thread on a loaded runner — reproducible by + * construction: hold, subscribe, cancel, observe CANCELLED, release, then + * assert the worker was never started and its ops were never invoked. */ +void cbm_daemon_application_hold_job_before_start_for_test(bool hold); + +/* Completed background-initialize passes (auto-index admission tail). + * Waiting for a delta here replaces sleep-then-assert negatives. */ +int cbm_daemon_application_background_initializes_for_test(void); + +/* Iterations of the explicit-index busy-queue wait (request parked behind + * the physical job limit). Waiting for a delta here is the positive signal + * that a request QUEUED rather than erroring or starting. */ +int cbm_daemon_application_busy_queue_waits_for_test(void); + #endif /* CBM_DAEMON_APPLICATION_INTERNAL_H */ diff --git a/src/discover/discover.c b/src/discover/discover.c index 832cc1f5a..b2169f1aa 100644 --- a/src/discover/discover.c +++ b/src/discover/discover.c @@ -764,7 +764,17 @@ typedef struct { cbm_gitignore_t *local_gi; /* nested .gitignore for this subtree */ char local_gi_prefix[CBM_SZ_4K]; /* rel_prefix when local_gi was loaded */ } walk_frame_t; +/* Initial capacity only — the stack grows on demand. A single directory can + * hold more pending sibling frames than any fixed cap (dotnet/runtime has 855 + * subdirs in one JIT regression dir), so a hard cap here means whole-repo + * discovery failure, not a depth guard. */ #define WALK_STACK_CAP 512 + +typedef struct { + walk_frame_t *frames; + int top; + int cap; +} walk_stack_t; /* Build abs/rel paths and process one directory entry. */ /* Try to load a nested .gitignore from this directory. Returns owned pointer or NULL. */ static cbm_gitignore_t *try_load_nested_gitignore(const walk_frame_t *frame) { @@ -780,35 +790,44 @@ static cbm_gitignore_t *try_load_nested_gitignore(const walk_frame_t *frame) { return NULL; } -/* Push a subdirectory onto the walk stack, inheriting local gitignore context. */ -static void walk_push_subdir(walk_frame_t *stack, int *top, const char *abs_path, - const char *rel_path, const walk_frame_t *parent, file_list_t *out) { - if (*top >= WALK_STACK_CAP) { - out->failed = true; - return; +/* Push a subdirectory onto the walk stack, inheriting local gitignore + * context. Grows the stack geometrically; the caller's `parent` must not + * point into the stack array (walk_dir pops into a local copy). */ +static void walk_push_subdir(walk_stack_t *ws, const char *abs_path, const char *rel_path, + const walk_frame_t *parent, file_list_t *out) { + if (ws->top >= ws->cap) { + int new_cap = ws->cap * 2; + walk_frame_t *grown = realloc(ws->frames, (size_t)new_cap * sizeof(*grown)); + if (!grown) { + out->failed = true; + return; + } + ws->frames = grown; + ws->cap = new_cap; } - int directory_length = snprintf(stack[*top].dir, CBM_SZ_4K, "%s", abs_path); - int prefix_length = snprintf(stack[*top].prefix, CBM_SZ_4K, "%s", rel_path); + walk_frame_t *slot = &ws->frames[ws->top]; + int directory_length = snprintf(slot->dir, CBM_SZ_4K, "%s", abs_path); + int prefix_length = snprintf(slot->prefix, CBM_SZ_4K, "%s", rel_path); if (directory_length <= 0 || directory_length >= CBM_SZ_4K || prefix_length < 0 || prefix_length >= CBM_SZ_4K) { out->failed = true; return; } - stack[*top].local_gi = parent->local_gi; + slot->local_gi = parent->local_gi; int local_prefix_length = - snprintf(stack[*top].local_gi_prefix, CBM_SZ_4K, "%s", parent->local_gi_prefix); + snprintf(slot->local_gi_prefix, CBM_SZ_4K, "%s", parent->local_gi_prefix); if (local_prefix_length < 0 || local_prefix_length >= CBM_SZ_4K) { out->failed = true; return; } - (*top)++; + ws->top++; } static void walk_dir_process_entry(cbm_dirent_t *entry, const walk_frame_t *frame, const cbm_discover_opts_t *opts, const cbm_gitignore_t *gitignore, const cbm_gitignore_t *global_gi, - const cbm_gitignore_t *cbmignore, walk_frame_t *stack, int *top, + const cbm_gitignore_t *cbmignore, walk_stack_t *ws, file_list_t *out) { char abs_path[CBM_SZ_4K]; char rel_path[CBM_SZ_4K]; @@ -836,7 +855,7 @@ static void walk_dir_process_entry(cbm_dirent_t *entry, const walk_frame_t *fram if (S_ISDIR(st.st_mode)) { if (!should_skip_directory(entry->name, rel_path, opts, gitignore, global_gi, cbmignore, frame->local_gi, frame->local_gi_prefix)) { - walk_push_subdir(stack, top, abs_path, rel_path, frame, out); + walk_push_subdir(ws, abs_path, rel_path, frame, out); } else { /* Record the excluded subtree root so callers can report it (#411). */ file_list_add_excluded(out, rel_path); @@ -871,8 +890,9 @@ static bool walk_owned_gitignore_append(cbm_gitignore_t ***owned, size_t *count, static void walk_dir(const char *dir_path, const char *rel_prefix, const cbm_discover_opts_t *opts, const cbm_gitignore_t *gitignore, const cbm_gitignore_t *global_gi, const cbm_gitignore_t *cbmignore, file_list_t *out) { - walk_frame_t *stack = calloc(WALK_STACK_CAP, sizeof(walk_frame_t)); - if (!stack) { + walk_stack_t ws = { + .frames = calloc(WALK_STACK_CAP, sizeof(walk_frame_t)), .top = 0, .cap = WALK_STACK_CAP}; + if (!ws.frames) { out->failed = true; return; } @@ -882,19 +902,18 @@ static void walk_dir(const char *dir_path, const char *rel_prefix, const cbm_dis size_t owned_count = 0; size_t owned_capacity = 0; - int top = 0; - int initial_directory_length = snprintf(stack[top].dir, CBM_SZ_4K, "%s", dir_path); - int initial_prefix_length = snprintf(stack[top].prefix, CBM_SZ_4K, "%s", rel_prefix); + int initial_directory_length = snprintf(ws.frames[0].dir, CBM_SZ_4K, "%s", dir_path); + int initial_prefix_length = snprintf(ws.frames[0].prefix, CBM_SZ_4K, "%s", rel_prefix); if (initial_directory_length <= 0 || initial_directory_length >= CBM_SZ_4K || initial_prefix_length < 0 || initial_prefix_length >= CBM_SZ_4K) { out->failed = true; - free(stack); + free(ws.frames); return; } - top++; + ws.top++; - while (top > 0 && !file_list_should_stop(out)) { - walk_frame_t frame = stack[--top]; + while (ws.top > 0 && !file_list_should_stop(out)) { + walk_frame_t frame = ws.frames[--ws.top]; cbm_gitignore_t *loaded = try_load_nested_gitignore(&frame); if (loaded) { @@ -920,8 +939,7 @@ static void walk_dir(const char *dir_path, const char *rel_prefix, const cbm_dis cbm_dirent_t *entry; while (!file_list_should_stop(out) && (entry = cbm_readdir(d)) != NULL) { - walk_dir_process_entry(entry, &frame, opts, gitignore, global_gi, cbmignore, stack, - &top, out); + walk_dir_process_entry(entry, &frame, opts, gitignore, global_gi, cbmignore, &ws, out); } cbm_closedir(d); } @@ -929,7 +947,7 @@ static void walk_dir(const char *dir_path, const char *rel_prefix, const cbm_dis cbm_gitignore_free(owned_gis[i]); } free(owned_gis); - free(stack); + free(ws.frames); } /* ── Public API ───────────────────────────────── */ diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 68b66545c..21b9561b3 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -2539,7 +2539,11 @@ static void resolve_def_inherits(resolve_ctx_t *rc, resolve_worker_state_t *ws, } const cbm_gbuf_node_t *bn = cbm_gbuf_find_by_qn(rc->main_gbuf, bqn); if (bn && node->id != bn->id) { - cbm_gbuf_insert_edge(ws->local_edge_buf, node->id, bn->id, "INHERITS", "{}"); + /* Same Interface-label split as the sequential semantic pass — + * hardcoding INHERITS here demoted every explicit `implements` + * on large (parallel-path) corpora. */ + cbm_gbuf_insert_edge(ws->local_edge_buf, node->id, bn->id, + cbm_semantic_base_edge_type(bn), "{}"); ws->semantic_resolved++; } } @@ -3010,6 +3014,10 @@ int cbm_parallel_resolve(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files, /* Go-style implicit interface satisfaction (needs full graph, serial) */ int go_impl = cbm_pipeline_implements_go(ctx); + /* Explicit-language override detection (same serial full-graph tail the + * sequential pipeline runs — the two venues must emit identical graphs). */ + total_lsp_overrides += cbm_pipeline_override_explicit(ctx); + if (atomic_load(ctx->cancelled)) { return CBM_NOT_FOUND; } diff --git a/src/pipeline/pass_semantic.c b/src/pipeline/pass_semantic.c index 8a3bb01f0..c77917117 100644 --- a/src/pipeline/pass_semantic.c +++ b/src/pipeline/pass_semantic.c @@ -454,12 +454,13 @@ static void sem_process_def_edges(cbm_pipeline_ctx_t *ctx, const CBMDefinition * if (base_node && node->id != base_node->id) { /* A base that resolves to an Interface is an IMPLEMENTS relation * (Java `implements`, C# `: IFace`, TS `implements`); a Class/ - * Type/Enum base is plain INHERITS. */ - const char *base_label = cbm_registry_label_of(ctx->registry, base_qn); - const char *edge_type = (base_label && strcmp(base_label, "Interface") == 0) - ? "IMPLEMENTS" - : "INHERITS"; - cbm_gbuf_insert_edge(ctx->gbuf, node->id, base_node->id, edge_type, "{}"); + * Type/Enum base is plain INHERITS. Keyed off the target NODE's + * label — the graph truth the edge attaches to — so the + * sequential and parallel venues cannot diverge (the parallel + * path once hardcoded INHERITS and demoted every explicit + * implements at scale). */ + cbm_gbuf_insert_edge(ctx->gbuf, node->id, base_node->id, + cbm_semantic_base_edge_type(base_node), "{}"); (*inherits_count)++; } } @@ -576,8 +577,77 @@ int cbm_pipeline_pass_semantic(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *f int go_impl = cbm_pipeline_implements_go(ctx); implements_count += go_impl; + /* ── Explicit-language override detection (full graph, serial) ── */ + int overrides = cbm_pipeline_override_explicit(ctx); + cbm_log_info("pass.done", "pass", "semantic", "inherits", itoa_log(inherits_count), "decorates", - itoa_log(decorates_count), "implements", itoa_log(implements_count), "errors", - itoa_log(errors)); + itoa_log(decorates_count), "implements", itoa_log(implements_count), "overrides", + itoa_log(overrides), "errors", itoa_log(errors)); return 0; } + +const char *cbm_semantic_base_edge_type(const cbm_gbuf_node_t *base_node) { + return (base_node && base_node->label && strcmp(base_node->label, "Interface") == 0) + ? "IMPLEMENTS" + : "INHERITS"; +} + +/* Create OVERRIDE edges from one class's methods to same-named methods of one + * explicit base (interface or superclass). */ +static int override_match_methods(cbm_pipeline_ctx_t *ctx, const cbm_gbuf_node_t *cls, + const cbm_gbuf_node_t *base) { + const cbm_gbuf_edge_t **cls_dm = NULL; + int cls_dm_count = 0; + cbm_gbuf_find_edges_by_source_type(ctx->gbuf, cls->id, "DEFINES_METHOD", &cls_dm, + &cls_dm_count); + if (cls_dm_count == 0) { + return 0; + } + const cbm_gbuf_edge_t **base_dm = NULL; + int base_dm_count = 0; + cbm_gbuf_find_edges_by_source_type(ctx->gbuf, base->id, "DEFINES_METHOD", &base_dm, + &base_dm_count); + int created = 0; + for (int c = 0; c < cls_dm_count; c++) { + const cbm_gbuf_node_t *cm = cbm_gbuf_find_by_id(ctx->gbuf, cls_dm[c]->target_id); + if (!cm || !cm->name) { + continue; + } + for (int b = 0; b < base_dm_count; b++) { + const cbm_gbuf_node_t *bm = cbm_gbuf_find_by_id(ctx->gbuf, base_dm[b]->target_id); + if (bm && bm->name && cm->id != bm->id && strcmp(cm->name, bm->name) == 0) { + cbm_gbuf_insert_edge(ctx->gbuf, cm->id, bm->id, "OVERRIDE", "{}"); + created++; + break; + } + } + } + return created; +} + +int cbm_pipeline_override_explicit(cbm_pipeline_ctx_t *ctx) { + if (!ctx || !ctx->gbuf) { + return 0; + } + int created = 0; + static const char *base_edge_types[] = {"IMPLEMENTS", "INHERITS"}; + for (size_t t = 0; t < sizeof(base_edge_types) / sizeof(base_edge_types[0]); t++) { + const cbm_gbuf_edge_t **edges = NULL; + int edge_count = 0; + cbm_gbuf_find_edges_by_type(ctx->gbuf, base_edge_types[t], &edges, &edge_count); + for (int e = 0; e < edge_count; e++) { + const cbm_gbuf_node_t *cls = cbm_gbuf_find_by_id(ctx->gbuf, edges[e]->source_id); + const cbm_gbuf_node_t *base = cbm_gbuf_find_by_id(ctx->gbuf, edges[e]->target_id); + if (!cls || !base) { + continue; + } + /* Go's implicit satisfaction already emits OVERRIDE with interface + * semantics; running both would double-cover .go sources. */ + if (cls->file_path && fp_ends_with(cls->file_path, ".go")) { + continue; + } + created += override_match_methods(ctx, cls, base); + } + } + return created; +} diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index e6c211f44..9886d5718 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -259,6 +259,20 @@ int cbm_compute_change_coupling(const cbm_commit_files_t *commits, int commit_co * creates IMPLEMENTS + OVERRIDE edges. Returns edge count created. */ int cbm_pipeline_implements_go(cbm_pipeline_ctx_t *ctx); +/* Edge type for an explicit base-class relation, keyed off the resolved + * TARGET node's label: Interface → IMPLEMENTS, anything else → INHERITS. + * The single decision point for BOTH the sequential semantic pass and the + * parallel per-file resolve — the two venues must never diverge. */ +const char *cbm_semantic_base_edge_type(const cbm_gbuf_node_t *base_node); + +/* Explicit-language override detection on the full graph (serial tail). + * For every IMPLEMENTS/INHERITS edge whose source is a non-Go class, matches + * the class's DEFINES_METHOD children by name against the base's and creates + * Method→Method OVERRIDE edges (Java @Override, TS/C#/Kotlin override, PHP + * redefinition). Go is excluded: implicit satisfaction already covers it. + * Returns edge count created. */ +int cbm_pipeline_override_explicit(cbm_pipeline_ctx_t *ctx); + /* ── Git diff helpers (pass_gitdiff.c) ───────────────────────────── */ typedef struct { diff --git a/src/ui/httpd.c b/src/ui/httpd.c index b5b3ebb14..9677bb285 100644 --- a/src/ui/httpd.c +++ b/src/ui/httpd.c @@ -63,6 +63,7 @@ struct cbm_httpd { struct cbm_http_conn *active; bool interrupted; int send_buffer_for_test; + int send_deadline_for_test_ms; }; struct cbm_http_conn { @@ -311,6 +312,17 @@ bool cbm_httpd_close(cbm_httpd_t *d) { return true; } +void cbm_httpd_set_send_deadline_for_test(cbm_httpd_t *d, int ms) { + /* Lets a test pin the send deadline far above (or below) the default so + * an observed abort has exactly ONE possible cause — the interruption + * tests once discriminated interrupt-abort from deadline-abort by wall + * clock, which sanitizer slowdown turns into a lottery. */ + if (!d) { + return; + } + d->send_deadline_for_test_ms = ms; +} + void cbm_httpd_set_send_buffer_for_test(cbm_httpd_t *d, int bytes) { if (!d) return; @@ -374,7 +386,8 @@ cbm_http_conn_t *cbm_httpd_accept(cbm_httpd_t *d, int timeout_ms) { } c->fd = cfd; c->owner = d; - c->send_deadline_ms = CBM_HTTP_SEND_DEADLINE_MS; + c->send_deadline_ms = d->send_deadline_for_test_ms > 0 ? d->send_deadline_for_test_ms + : CBM_HTTP_SEND_DEADLINE_MS; atomic_init(&c->response_started, false); cbm_mutex_lock(&d->active_mutex); diff --git a/src/ui/httpd.h b/src/ui/httpd.h index 7e1afdfcc..dccfbe072 100644 --- a/src/ui/httpd.h +++ b/src/ui/httpd.h @@ -90,6 +90,7 @@ cbm_httpd_activity_t cbm_httpd_activity_for_test(cbm_httpd_t *d); /* Caps SO_SNDBUF on subsequently accepted sockets so a non-reading peer * produces a deterministic backpressure point on every platform. */ void cbm_httpd_set_send_buffer_for_test(cbm_httpd_t *d, int bytes); +void cbm_httpd_set_send_deadline_for_test(cbm_httpd_t *d, int ms); /* ── Connection handling ──────────────────────────────────────── */ diff --git a/test-infrastructure/Dockerfile b/test-infrastructure/Dockerfile index 59e11a7da..65804197f 100644 --- a/test-infrastructure/Dockerfile +++ b/test-infrastructure/Dockerfile @@ -6,7 +6,9 @@ # Build: docker build -t cbm-test test-infrastructure/ # Run: docker run --rm -v $(pwd):/src cbm-test -FROM ubuntu:noble +# Pinned by digest (supply-chain: no floating tags) — multi-arch manifest-list +# digest of ubuntu:noble as of 2026-07-23; bump deliberately, never to a tag. +FROM ubuntu:noble@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90 # Minimal: gcc + zlib only. sqlite3 is vendored (compiled from source with ASan). # curl + zsh mirror the GitHub runner images: the self-update tests shell out diff --git a/test-infrastructure/Dockerfile.lint b/test-infrastructure/Dockerfile.lint index 58c26e4cd..a2ca13211 100644 --- a/test-infrastructure/Dockerfile.lint +++ b/test-infrastructure/Dockerfile.lint @@ -5,7 +5,9 @@ # Build: docker build -t cbm-lint -f test-infrastructure/Dockerfile.lint test-infrastructure/ # Run: docker run --rm -v $(pwd):/src cbm-lint -FROM ubuntu:noble +# Pinned by digest (supply-chain: no floating tags) — multi-arch manifest-list +# digest of ubuntu:noble as of 2026-07-23; bump deliberately, never to a tag. +FROM ubuntu:noble@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90 RUN apt-get update && apt-get install -y --no-install-recommends \ gcc g++ make cmake \ diff --git a/test-infrastructure/Dockerfile.mingw b/test-infrastructure/Dockerfile.mingw index d496a7049..9d97fe089 100644 --- a/test-infrastructure/Dockerfile.mingw +++ b/test-infrastructure/Dockerfile.mingw @@ -7,7 +7,10 @@ # docker compose -f test-infrastructure/docker-compose.yml run --rm build-windows # docker compose -f test-infrastructure/docker-compose.yml run --rm test-windows -FROM mstorsjo/llvm-mingw:latest +# Pinned by digest (supply-chain: no floating tags). This is the multi-arch +# manifest-list digest of mstorsjo/llvm-mingw:latest as of 2026-07-23; bump +# deliberately by re-resolving the digest, never back to a bare tag. +FROM mstorsjo/llvm-mingw:latest@sha256:5cbf421906f889e01c8d4005787474e302df6daa197286ebf212d419024966af # curl+zstd are required (zlib sysroot fetch below). Wine is BEST-EFFORT: it # only runs the .exe, and its i386 packages are unavailable on an arm64 Docker @@ -19,10 +22,16 @@ RUN (dpkg --add-architecture i386 && apt-get update && \ apt-get install -y --no-install-recommends wine64 && rm -rf /var/lib/apt/lists/*) \ || echo "WARN: Wine unavailable on this host arch -> compile-only mode" -# Install zlib into the llvm-mingw sysroot (from MSYS2 CLANG64 repo) +# Install zlib into the llvm-mingw sysroot (from MSYS2 CLANG64 repo). +# The package is verified against its pinned sha256 before extraction — +# a bare curl|tar would execute whatever the mirror serves. RUN mkdir -p /tmp/zlib-pkg && \ curl -sL "https://mirror.msys2.org/mingw/clang64/mingw-w64-clang-x86_64-zlib-1.3.1-1-any.pkg.tar.zst" \ - | zstd -d | tar xf - -C /tmp/zlib-pkg && \ + -o /tmp/zlib.pkg.tar.zst && \ + echo "b7f0c06e6d48128209a3a441f96b92351e565a0d031278c56a8db8a9b5ec291a /tmp/zlib.pkg.tar.zst" \ + | sha256sum -c - && \ + zstd -d < /tmp/zlib.pkg.tar.zst | tar xf - -C /tmp/zlib-pkg && \ + rm /tmp/zlib.pkg.tar.zst && \ cp -rn /tmp/zlib-pkg/clang64/include/* /opt/llvm-mingw/x86_64-w64-mingw32/include/ && \ cp -rn /tmp/zlib-pkg/clang64/lib/* /opt/llvm-mingw/x86_64-w64-mingw32/lib/ && \ rm -rf /tmp/zlib-pkg diff --git a/tests/test_cs_lsp_bench.c b/tests/test_cs_lsp_bench.c index bed176678..2a7ee2915 100644 --- a/tests/test_cs_lsp_bench.c +++ b/tests/test_cs_lsp_bench.c @@ -238,7 +238,7 @@ TEST(cslsp_bench_resolution_ratio) { /* Time budget. ASan+UBSan instrumentation slows the parse ~5-10×, so * scale the budget when a sanitizer is active. Native: 200 ms for a * ~260-line fixture; sanitized: 2000 ms. */ -#ifdef __SANITIZE_ADDRESS__ +#if defined(CBM_SANITIZED_BUILD) || defined(__SANITIZE_ADDRESS__) ASSERT(ms < 2000.0); #else ASSERT(ms < 200.0); diff --git a/tests/test_daemon_application.c b/tests/test_daemon_application.c index 167d5255f..2911d07df 100644 --- a/tests/test_daemon_application.c +++ b/tests/test_daemon_application.c @@ -1425,6 +1425,21 @@ static bool app_wait_for_atomic_int_at_least(atomic_int *value, int minimum) { return false; } +static bool app_wait_for_background_initializes(int minimum) { + /* Positive completion signal for the request-handler's background tail: + * negatives about auto-index admission ("nothing was started") are only + * meaningful once the admission pass has RUN — a fixed sleep loses that + * race in both directions. */ + uint64_t deadline = cbm_now_ms() + APP_TEST_TIMEOUT_MS; + while (cbm_now_ms() < deadline) { + if (cbm_daemon_application_background_initializes_for_test() >= minimum) { + return true; + } + cbm_usleep(1000); + } + return false; +} + static bool app_wait_for_atomic_bool(atomic_bool *value, bool expected) { uint64_t deadline = cbm_now_ms() + APP_TEST_TIMEOUT_MS; while (cbm_now_ms() < deadline) { @@ -1856,13 +1871,14 @@ TEST(daemon_application_initialize_coalesces_auto_index_for_full_sessions) { sessions[i] = app_test_open(&callbacks, (cbm_daemon_client_id_t)(4110U + i)); } + int bg_baseline = cbm_daemon_application_background_initializes_for_test(); bool scout_initialized = app_test_initialize_profile(&callbacks, sessions[2], root, CBM_MCP_TOOL_PROFILE_SCOUT, NULL, NULL); bool hook_initialized = app_test_initialize_profile( &callbacks, sessions[3], root, CBM_MCP_TOOL_PROFILE_ALL, "SessionStart", "copilot"); - cbm_usleep(50000); + bool admissions_evaluated = app_wait_for_background_initializes(bg_baseline + 2); bool restricted_started_nothing = - atomic_load(&fake.starts) == 0 && application && project && + admissions_evaluated && atomic_load(&fake.starts) == 0 && application && project && cbm_daemon_application_job_subscribers(application, project) == 0 && atomic_load(&update.starts) == 0; @@ -1990,10 +2006,12 @@ TEST(daemon_application_auto_index_honors_tracked_file_limit) { cbm_daemon_application_runtime_callbacks(application); cbm_daemon_runtime_application_session_t *session = application ? app_test_open(&callbacks, 4190) : NULL; + int bg_baseline = cbm_daemon_application_background_initializes_for_test(); bool initialized = app_test_initialize_profile(&callbacks, session, root, CBM_MCP_TOOL_PROFILE_ALL, NULL, NULL); - cbm_usleep(50000); - bool limit_prevented_admission = initialized && atomic_load(&fake.starts) == 0 && + bool admission_evaluated = app_wait_for_background_initializes(bg_baseline + 1); + bool limit_prevented_admission = initialized && admission_evaluated && + atomic_load(&fake.starts) == 0 && cbm_daemon_application_active_jobs(application) == 0; if (session) { @@ -2126,11 +2144,12 @@ TEST(daemon_application_auto_index_retries_transient_busy_admission) { cbm_daemon_application_runtime_callbacks(application); cbm_daemon_runtime_application_session_t *session = application ? app_test_open(&callbacks, 4191) : NULL; + int bg_baseline = cbm_daemon_application_background_initializes_for_test(); bool initialized = occupied_admitted && app_test_initialize_profile(&callbacks, session, auto_root, CBM_MCP_TOOL_PROFILE_ALL, NULL, NULL); - cbm_usleep(20000); - bool initially_deferred = initialized && atomic_load(&fake.starts) == 1; + bool admission_evaluated = app_wait_for_background_initializes(bg_baseline + 1); + bool initially_deferred = initialized && admission_evaluated && atomic_load(&fake.starts) == 1; atomic_store(&fake.allow_completion, true); if (occupied_started) { @@ -3002,11 +3021,17 @@ TEST(daemon_application_request_cancel_preserves_persistent_watch_and_session) { encoded && cbm_thread_create(&request_thread, 0, app_request_thread, &request) == 0; bool subscribed = thread_started && app_wait_for_subscribers(fixture.application, fixture.project, 1); - if (subscribed) { + /* Cancel only after the WORKER exists: subscription precedes worker + * start, and a cancel that wins that window legally aborts the job with + * no worker (and no worker-cancel op) ever invoked — that interleaving + * has its own deterministic test below. From worker start onward, cancel + * delivery to the worker is guaranteed. */ + bool worker_started = subscribed && app_wait_for_atomic_int_at_least(&fixture.fake.starts, 1); + if (worker_started) { fixture.callbacks.request_cancel(fixture.callbacks.context, fixture.session, request.request_token); } - bool request_returned = subscribed && app_wait_for_atomic_bool(&request.done, true); + bool request_returned = worker_started && app_wait_for_atomic_bool(&request.done, true); if (!request_returned) { /* Keep cleanup bounded if the RED path fails to detach the request. */ atomic_store(&fixture.fake.allow_completion, true); @@ -3038,6 +3063,7 @@ TEST(daemon_application_request_cancel_preserves_persistent_watch_and_session) { ASSERT_TRUE(encoded); ASSERT_TRUE(thread_started); ASSERT_TRUE(subscribed); + ASSERT_TRUE(worker_started); ASSERT_TRUE(request_returned); ASSERT_TRUE(thread_joined); ASSERT_EQ(request.status, CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED); @@ -3055,6 +3081,67 @@ TEST(daemon_application_request_cancel_preserves_persistent_watch_and_session) { PASS(); } +/* Deterministic sibling of the test above: with every job thread parked + * before its first pre-start cancel check, the cancel WINS the pre-start + * window by construction — the request must still report CANCELLED with the + * watch preserved, while the worker is never started and none of its ops + * are ever invoked. A loaded CI runner produced this interleaving by + * chance; here it is forced (CI is never a lottery). */ +TEST(daemon_application_cancel_before_worker_start_skips_worker) { + app_watch_race_fixture_t fixture; + bool fixture_ready = app_watch_race_fixture_init(&fixture, 3022); + char args[APP_TEST_PATH_CAP + 32]; + (void)snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"}", fixture.root); + uint8_t *tool = NULL; + uint32_t tool_length = 0; + bool encoded = + fixture_ready && app_test_tool_request("index_repository", args, &tool, &tool_length); + cbm_daemon_application_hold_job_before_start_for_test(true); + app_request_thread_t request = { + .callbacks = fixture.callbacks, + .session = fixture.session, + .request_token = UINT64_C(8301), + .request = tool, + .request_length = tool_length, + .status = CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR, + }; + atomic_init(&request.done, false); + cbm_thread_t request_thread; + bool thread_started = + encoded && cbm_thread_create(&request_thread, 0, app_request_thread, &request) == 0; + bool subscribed = + thread_started && app_wait_for_subscribers(fixture.application, fixture.project, 1); + if (subscribed) { + fixture.callbacks.request_cancel(fixture.callbacks.context, fixture.session, + request.request_token); + } + bool request_returned = subscribed && app_wait_for_atomic_bool(&request.done, true); + cbm_daemon_application_hold_job_before_start_for_test(false); + bool thread_joined = thread_started && cbm_thread_join(&request_thread) == 0; + int watch_count_after_cancel = fixture.watcher ? cbm_watcher_watch_count(fixture.watcher) : -1; + bool cleaned = app_watch_race_fixture_finish(&fixture); + int worker_starts = atomic_load(&fixture.fake.starts); + int worker_cancels = atomic_load(&fixture.fake.cancels); + int worker_destroys = atomic_load(&fixture.fake.destroys); + free(request.response); + free(tool); + + ASSERT_TRUE(fixture_ready); + ASSERT_TRUE(encoded); + ASSERT_TRUE(thread_started); + ASSERT_TRUE(subscribed); + ASSERT_TRUE(request_returned); + ASSERT_TRUE(thread_joined); + ASSERT_EQ(request.status, CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED); + ASSERT_NULL(request.response); + ASSERT_EQ(watch_count_after_cancel, 1); + ASSERT_EQ(worker_starts, 0); + ASSERT_EQ(worker_cancels, 0); + ASSERT_EQ(worker_destroys, 0); + ASSERT_TRUE(cleaned); + PASS(); +} + TEST(daemon_application_cancel_drops_watch_before_inflight_request_returns) { const char *old_cache = getenv("CBM_CACHE_DIR"); bool had_cache = old_cache != NULL; @@ -3973,10 +4060,9 @@ TEST(daemon_application_worker_lock_serializes_external_mutation) { } cbm_project_lock_lease_t *terminal_other_probe = NULL; cbm_private_file_lock_status_t terminal_other_status = - other_worker_acquired - ? app_project_lock_try_acquire_until_released(locks.probe_locks, other_project, - &terminal_other_probe) - : CBM_PRIVATE_FILE_LOCK_IO; + other_worker_acquired ? app_project_lock_try_acquire_until_released( + locks.probe_locks, other_project, &terminal_other_probe) + : CBM_PRIVATE_FILE_LOCK_IO; bool other_lock_released_at_terminal = terminal_other_status == CBM_PRIVATE_FILE_LOCK_OK && terminal_other_probe; bool terminal_other_probe_released = app_project_lock_release(&terminal_other_probe); @@ -3993,10 +4079,9 @@ TEST(daemon_application_worker_lock_serializes_external_mutation) { } cbm_project_lock_lease_t *blocked_probe = NULL; cbm_private_file_lock_status_t terminal_blocked_status = - blocked_worker_acquired - ? app_project_lock_try_acquire_until_released(locks.probe_locks, blocked_project, - &blocked_probe) - : CBM_PRIVATE_FILE_LOCK_IO; + blocked_worker_acquired ? app_project_lock_try_acquire_until_released( + locks.probe_locks, blocked_project, &blocked_probe) + : CBM_PRIVATE_FILE_LOCK_IO; bool blocked_lock_released_at_terminal = terminal_blocked_status == CBM_PRIVATE_FILE_LOCK_OK && blocked_probe; bool blocked_probe_released = app_project_lock_release(&blocked_probe); @@ -4507,7 +4592,32 @@ TEST(daemon_application_thread_start_failure_rolls_back_job_reservation) { PASS(); } -TEST(daemon_application_enforces_global_physical_job_limit) { +/* Session-request worker: issues one runtime request and records the raw + * response, so a request that legitimately BLOCKS (queued behind the + * physical job limit) can run off the test thread. */ +typedef struct { + cbm_daemon_runtime_application_callbacks_t *callbacks; + cbm_daemon_runtime_application_session_t *session; + uint8_t *payload; + uint32_t payload_length; + uint8_t *response; + uint32_t response_length; + int status; +} app_session_request_thread_t; + +static void *app_session_request_thread(void *opaque) { + app_session_request_thread_t *request = opaque; + request->status = + app_test_request(request->callbacks, request->session, request->payload, + request->payload_length, &request->response, &request->response_length); + return NULL; +} + +/* An explicit index request that hits the physical job limit QUEUES (parked, + * observable via the busy-queue-wait counter) instead of erroring, and runs + * to completion once the occupying job finishes. The non-session coordinator + * API keeps its non-blocking busy answer. */ +TEST(daemon_application_queues_explicit_index_behind_physical_job_limit) { app_fake_worker_context_t fake; app_fake_worker_context_init(&fake); cbm_daemon_application_worker_ops_t worker_ops = { @@ -4539,7 +4649,10 @@ TEST(daemon_application_enforces_global_physical_job_limit) { application && roots_ok && cbm_thread_create(&thread, 0, app_index_thread, &first) == 0; bool admitted = started && app_wait_for_subscribers(application, "cap-first", 1) && app_wait_for_atomic_int(&fake.starts, 1); + /* The programmatic coordinator API stays non-blocking: callers with their + * own retry policy (watcher, auto-index) still get the busy answer. */ int busy = admitted ? cbm_daemon_application_index(application, "cap-second", second_root) : -1; + cbm_daemon_runtime_application_callbacks_t callbacks = cbm_daemon_application_runtime_callbacks(application); cbm_daemon_runtime_application_session_t *session = app_test_open(&callbacks, 51); @@ -4553,18 +4666,49 @@ TEST(daemon_application_enforces_global_physical_job_limit) { app_test_context_request(second_root, second_root, &session_context, &session_context_length) && app_test_tool_request("index_repository", args, &tool, &tool_length); - uint8_t *response = NULL; - uint32_t response_length = 0; - bool session_busy = - session_encoded && - app_test_request(&callbacks, session, session_context, session_context_length, &response, - &response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK; - free(response); - response = NULL; - session_busy = session_busy && - app_test_request(&callbacks, session, tool, tool_length, &response, - &response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK && - response && strstr((char *)response, "physical index job limit reached"); + uint8_t *context_response = NULL; + uint32_t context_response_length = 0; + bool context_ok = session_encoded && + app_test_request(&callbacks, session, session_context, session_context_length, + &context_response, &context_response_length) == + CBM_DAEMON_RUNTIME_APPLICATION_OK; + free(context_response); + + int queue_waits_baseline = cbm_daemon_application_busy_queue_waits_for_test(); + app_session_request_thread_t queued = { + .callbacks = &callbacks, + .session = session, + .payload = tool, + .payload_length = tool_length, + .status = -1, + }; + cbm_thread_t request_thread; + bool request_started = + context_ok && + cbm_thread_create(&request_thread, 0, app_session_request_thread, &queued) == 0; + /* Positive queue signal: the request entered the busy-wait loop instead + * of erroring — and no second worker was started while parked. */ + bool queued_parked = false; + if (request_started) { + uint64_t deadline = cbm_now_ms() + APP_TEST_TIMEOUT_MS; + while (cbm_now_ms() < deadline) { + if (cbm_daemon_application_busy_queue_waits_for_test() > queue_waits_baseline) { + queued_parked = true; + break; + } + cbm_usleep(1000); + } + } + bool no_second_start_while_parked = queued_parked && atomic_load(&fake.starts) == 1; + + /* Release the slot: the first job completes, the queued request must be + * admitted and complete on its own. */ + atomic_store(&fake.allow_completion, true); + bool request_joined = request_started && cbm_thread_join(&request_thread) == 0; + bool queued_succeeded = request_joined && queued.status == CBM_DAEMON_RUNTIME_APPLICATION_OK && + queued.response && strstr((char *)queued.response, "indexed") != NULL && + strstr((char *)queued.response, "physical index job limit") == NULL; + callbacks.session_close(callbacks.context, session); bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); if (started) { @@ -4573,17 +4717,20 @@ TEST(daemon_application_enforces_global_physical_job_limit) { cbm_daemon_application_free(application); free(session_context); free(tool); - free(response); + free(queued.response); ASSERT_TRUE(roots_ok); ASSERT_TRUE(started); ASSERT_TRUE(admitted); ASSERT_EQ(busy, 1); - ASSERT_TRUE(session_busy); + ASSERT_TRUE(context_ok); + ASSERT_TRUE(request_started); + ASSERT_TRUE(queued_parked); + ASSERT_TRUE(no_second_start_while_parked); + ASSERT_TRUE(queued_succeeded); ASSERT_TRUE(stopped); - ASSERT_EQ(atomic_load(&fake.starts), 1); - ASSERT_EQ(atomic_load(&fake.cancels), 1); - ASSERT_EQ(atomic_load(&fake.destroys), 1); + ASSERT_EQ(atomic_load(&fake.starts), 2); + ASSERT_EQ(atomic_load(&fake.destroys), 2); (void)cbm_rmdir(first_root); (void)cbm_rmdir(second_root); @@ -4750,6 +4897,7 @@ SUITE(daemon_application) { RUN_TEST(daemon_application_cancels_physical_job_only_after_final_session); RUN_TEST(daemon_application_disconnect_before_request_callback_is_sticky); RUN_TEST(daemon_application_request_cancel_preserves_persistent_watch_and_session); + RUN_TEST(daemon_application_cancel_before_worker_start_skips_worker); RUN_TEST(daemon_application_cancel_drops_watch_before_inflight_request_returns); RUN_TEST(daemon_application_stale_watcher_callback_is_rejected_at_job_admission); RUN_TEST(daemon_application_final_cancel_drains_admitted_watcher_job); @@ -4766,7 +4914,7 @@ SUITE(daemon_application) { RUN_TEST(daemon_application_recovers_with_unique_per_job_quarantine_files); RUN_TEST(daemon_application_cancellation_between_recovery_attempts_stops_retry); RUN_TEST(daemon_application_thread_start_failure_rolls_back_job_reservation); - RUN_TEST(daemon_application_enforces_global_physical_job_limit); + RUN_TEST(daemon_application_queues_explicit_index_behind_physical_job_limit); RUN_TEST(daemon_application_default_limit_admits_four_and_rejects_fifth); RUN_TEST(daemon_application_free_reports_retained_live_ownership); RUN_TEST(daemon_application_rejects_clean_exit_when_process_tree_is_not_contained); diff --git a/tests/test_discover.c b/tests/test_discover.c index 35c28f150..57cbefe75 100644 --- a/tests/test_discover.c +++ b/tests/test_discover.c @@ -328,6 +328,35 @@ TEST(discover_simple) { PASS(); } +/* A directory with more immediate subdirectories than the walk stack's + * initial capacity must still be fully discovered — dotnet/runtime's + * src/tests/JIT/Regression/JitBlue holds 855 sibling dirs and made the whole + * pipeline hard-fail with files=0 (walk_push_subdir treated the fixed cap as + * a hard error instead of growing). */ +TEST(discover_wide_sibling_fanout_exceeds_initial_walk_stack) { + char *base = th_mktempdir("cbm_disc_wide"); + ASSERT(base != NULL); + + enum { WIDE_SIBLINGS = 600 }; + for (int i = 0; i < WIDE_SIBLINGS; i++) { + char rel[64]; + (void)snprintf(rel, sizeof(rel), "wide/d%03d/f.py", i); + th_write_file(TH_PATH(base, rel), "x = 1\n"); + } + + cbm_discover_opts_t opts = {0}; + cbm_file_info_t *files = NULL; + int count = 0; + + int rc = cbm_discover(base, &opts, &files, &count); + ASSERT_EQ(rc, 0); + ASSERT_EQ(count, WIDE_SIBLINGS); + + cbm_discover_free(files, count); + th_cleanup(base); + PASS(); +} + TEST(discover_bounded_count_is_allocation_free_and_limit_exact) { char *base = th_mktempdir("cbm_disc_bounded"); ASSERT(base != NULL); @@ -337,11 +366,11 @@ TEST(discover_bounded_count_is_allocation_free_and_limit_exact) { cbm_discover_opts_t opts = {.mode = CBM_MODE_FULL}; int limited_count = -1; - cbm_discover_status_t limited = cbm_discover_count_bounded( - base, &opts, 1, cbm_now_ms() + 2000, &limited_count); + cbm_discover_status_t limited = + cbm_discover_count_bounded(base, &opts, 1, cbm_now_ms() + 2000, &limited_count); int exact_count = -1; - cbm_discover_status_t exact = cbm_discover_count_bounded( - base, &opts, 2, cbm_now_ms() + 2000, &exact_count); + cbm_discover_status_t exact = + cbm_discover_count_bounded(base, &opts, 2, cbm_now_ms() + 2000, &exact_count); th_cleanup(base); ASSERT_EQ(limited, CBM_DISCOVER_LIMIT_EXCEEDED); @@ -358,8 +387,7 @@ TEST(discover_bounded_count_fails_closed_after_deadline) { cbm_discover_opts_t opts = {.mode = CBM_MODE_FULL}; int count = 99; - cbm_discover_status_t status = - cbm_discover_count_bounded(base, &opts, 100, 1, &count); + cbm_discover_status_t status = cbm_discover_count_bounded(base, &opts, 100, 1, &count); th_cleanup(base); ASSERT_EQ(status, CBM_DISCOVER_ERROR); @@ -1130,8 +1158,10 @@ TEST(discover_git_info_exclude_stacks_with_gitignore) { bool found_log = false; bool found_scratch = false; for (int i = 0; i < count; i++) { - if (strstr(files[i].rel_path, ".log")) found_log = true; - if (strstr(files[i].rel_path, "scratch")) found_scratch = true; + if (strstr(files[i].rel_path, ".log")) + found_log = true; + if (strstr(files[i].rel_path, "scratch")) + found_scratch = true; } ASSERT_FALSE(found_log); ASSERT_FALSE(found_scratch); @@ -1294,29 +1324,25 @@ TEST(discover_many_nested_gitignores_do_not_exhaust_matcher_ownership) { for (int i = 0; i < PACKAGE_COUNT; i++) { char ignore_path[1024]; char source_path[1024]; - int ignore_written = snprintf(ignore_path, sizeof(ignore_path), - "%s/package-%02d/.gitignore", base, i); - int source_written = snprintf(source_path, sizeof(source_path), - "%s/package-%02d/source.go", base, i); - fixture_ready = - fixture_ready && ignore_written > 0 && - (size_t)ignore_written < sizeof(ignore_path) && - source_written > 0 && - (size_t)source_written < sizeof(source_path) && - th_write_file(ignore_path, "ignored.tmp\n") == 0 && - th_write_file(source_path, "package fixture\n") == 0; + int ignore_written = + snprintf(ignore_path, sizeof(ignore_path), "%s/package-%02d/.gitignore", base, i); + int source_written = + snprintf(source_path, sizeof(source_path), "%s/package-%02d/source.go", base, i); + fixture_ready = fixture_ready && ignore_written > 0 && + (size_t)ignore_written < sizeof(ignore_path) && source_written > 0 && + (size_t)source_written < sizeof(source_path) && + th_write_file(ignore_path, "ignored.tmp\n") == 0 && + th_write_file(source_path, "package fixture\n") == 0; } cbm_discover_opts_t opts = {0}; cbm_file_info_t *files = NULL; int count = 0; - int discover_rc = - fixture_ready ? cbm_discover(base, &opts, &files, &count) : -1; + int discover_rc = fixture_ready ? cbm_discover(base, &opts, &files, &count) : -1; int bounded_count = -1; cbm_discover_status_t bounded_status = fixture_ready - ? cbm_discover_count_bounded(base, &opts, PACKAGE_COUNT + 1, 0, - &bounded_count) + ? cbm_discover_count_bounded(base, &opts, PACKAGE_COUNT + 1, 0, &bounded_count) : CBM_DISCOVER_ERROR; cbm_discover_free(files, count); @@ -1403,6 +1429,7 @@ SUITE(discover) { /* Integration tests (cross-platform) */ RUN_TEST(discover_simple); + RUN_TEST(discover_wide_sibling_fanout_exceeds_initial_walk_stack); RUN_TEST(discover_bounded_count_is_allocation_free_and_limit_exact); RUN_TEST(discover_bounded_count_fails_closed_after_deadline); RUN_TEST(discover_skips_git_dir); diff --git a/tests/test_httpd.c b/tests/test_httpd.c index 997499e22..5f18696e3 100644 --- a/tests/test_httpd.c +++ b/tests/test_httpd.c @@ -1566,7 +1566,9 @@ typedef struct { static void *th_http_stop_watchdog(void *opaque) { th_http_stop_watchdog_t *watchdog = opaque; - for (int elapsed_ms = 0; elapsed_ms < 1500; elapsed_ms += 10) { + /* Hang detector, not a latency assertion: sized for the slowest + * sanitizer/loaded-runner tail (see AGENTS.md, CI determinism). */ + for (int elapsed_ms = 0; elapsed_ms < 10000; elapsed_ms += 10) { if (atomic_load(&watchdog->stop_finished) || (watchdog->operation_finished && atomic_load(watchdog->operation_finished))) return NULL; @@ -1608,6 +1610,11 @@ TEST(httpd_interrupt_unblocks_nonreading_large_response_within_one_second) { cbm_httpd_t *listener = cbm_httpd_listen(0); ASSERT_NOT_NULL(listener); cbm_httpd_set_send_buffer_for_test(listener, 64 * 1024); + /* Deadline pinned far out of reach: the only remaining way the blocked + * 8 MB send can end is the interrupt, so the join itself proves + * interrupt causality — no wall-clock discrimination needed (the old + * elapsed<500ms check was a lottery under sanitizer slowdown). */ + cbm_httpd_set_send_deadline_for_test(listener, 60 * 1000); th_httpd_large_reply_t reply = {.listener = listener}; atomic_init(&reply.accepted, 0); atomic_init(&reply.finished, 0); @@ -1630,17 +1637,16 @@ TEST(httpd_interrupt_unblocks_nonreading_large_response_within_one_second) { cbm_thread_t watchdog_thread; ASSERT_EQ(cbm_thread_create(&watchdog_thread, 0, th_http_stop_watchdog, &watchdog), 0); - uint64_t started = cbm_now_ms(); cbm_httpd_interrupt(listener); ASSERT_EQ(cbm_thread_join(&reply_thread), 0); - uint64_t elapsed = cbm_now_ms() - started; atomic_store(&watchdog.stop_finished, 1); ASSERT_EQ(cbm_thread_join(&watchdog_thread), 0); (void)th_sock_shutdown(socket); th_sock_close(socket); ASSERT_TRUE(cbm_httpd_close(listener)); - ASSERT_LT(elapsed, 500); + /* The 60 s deadline cannot have fired, so the join above is the proof + * of interrupt delivery; the watchdog is purely a hang detector. */ ASSERT_EQ(atomic_load(&watchdog.watchdog_fired), 0); PASS(); } diff --git a/tests/test_parallel.c b/tests/test_parallel.c index 9706db2b7..4e48dc459 100644 --- a/tests/test_parallel.c +++ b/tests/test_parallel.c @@ -70,6 +70,35 @@ static int setup_parallel_repo(void) { fprintf(f, "package util\n\nfunc Help() {}\n"); fclose(f); + /* Java interface/implements/extends trio: the parallel resolve path must + * make the same Interface-label edge split (IMPLEMENTS vs INHERITS) as + * the sequential semantic pass, and both must emit OVERRIDE for methods + * redefined from an explicit base. Without these files the IMPLEMENTS/ + * INHERITS parity tests compare 0 == 0 and guard nothing (the demotion + * bug shipped: elasticsearch had 11k implements-as-INHERITS edges). */ + snprintf(path, sizeof(path), "%s/probe", g_par_tmpdir); + cbm_mkdir(path); + snprintf(path, sizeof(path), "%s/probe/Shape.java", g_par_tmpdir); + f = fopen(path, "w"); + if (!f) + return -1; + fprintf(f, "package probe;\npublic interface Shape {\n double area();\n}\n"); + fclose(f); + snprintf(path, sizeof(path), "%s/probe/Circle.java", g_par_tmpdir); + f = fopen(path, "w"); + if (!f) + return -1; + fprintf(f, "package probe;\npublic class Circle implements Shape {\n" + " @Override\n public double area() { return 1.0; }\n}\n"); + fclose(f); + snprintf(path, sizeof(path), "%s/probe/Base.java", g_par_tmpdir); + f = fopen(path, "w"); + if (!f) + return -1; + fprintf(f, "package probe;\npublic class Base extends Circle {\n" + " @Override\n public double area() { return 0.0; }\n}\n"); + fclose(f); + return 0; } @@ -85,6 +114,20 @@ static void teardown_parallel_repo(void) { /* ── Run sequential pipeline on files, returning gbuf ─────────────── */ +/* Free the return-type table pass_calls may have built for a harness-owned + * ctx — mirrors the production teardown in pipeline.c; the harness never + * runs it, which leaked once the fixture gained typed (Java) methods. The + * fixture has no ObjectScript, so no macro table can exist here. */ +static void harness_ctx_free_tables(cbm_pipeline_ctx_t *ctx) { + if (ctx->return_type_table) { + for (int i = 0; i < ctx->return_type_table->count; i++) { + free((void *)ctx->return_type_table->entries[i].return_type); + } + free((void *)ctx->return_type_table); + ctx->return_type_table = NULL; + } +} + static cbm_gbuf_t *run_sequential(const char *project, const char *repo_path, cbm_file_info_t *files, int file_count) { cbm_gbuf_t *gbuf = cbm_gbuf_new(project, repo_path); @@ -106,6 +149,7 @@ static cbm_gbuf_t *run_sequential(const char *project, const char *repo_path, cbm_pipeline_pass_usages(&ctx, files, file_count); cbm_pipeline_pass_semantic(&ctx, files, file_count); + harness_ctx_free_tables(&ctx); cbm_registry_free(reg); return gbuf; } @@ -178,6 +222,7 @@ static cbm_gbuf_t *run_parallel_with_extract_opts(const char *project, const cha cbm_free_result(result_cache[i]); free(result_cache); + harness_ctx_free_tables(&ctx); cbm_registry_free(reg); return gbuf; } @@ -316,6 +361,22 @@ TEST(parallel_implements_parity) { PASS(); } +/* Absolute counts on the fixture — parity alone passes vacuously at 0==0, + * which is how the parallel-path IMPLEMENTS demotion shipped unnoticed. */ +TEST(parallel_semantic_fixture_expected_counts) { + if (ensure_parity_setup() != 0) + FAIL("setup failed"); + /* Circle implements Shape; Base extends Circle — in BOTH venues. */ + ASSERT_EQ(cbm_gbuf_edge_count_by_type(g_seq_gbuf, "IMPLEMENTS"), 1); + ASSERT_EQ(cbm_gbuf_edge_count_by_type(g_par_gbuf, "IMPLEMENTS"), 1); + ASSERT_EQ(cbm_gbuf_edge_count_by_type(g_seq_gbuf, "INHERITS"), 1); + ASSERT_EQ(cbm_gbuf_edge_count_by_type(g_par_gbuf, "INHERITS"), 1); + /* Circle.area overrides Shape.area; Base.area overrides Circle.area. */ + ASSERT_EQ(cbm_gbuf_edge_count_by_type(g_seq_gbuf, "OVERRIDE"), 2); + ASSERT_EQ(cbm_gbuf_edge_count_by_type(g_par_gbuf, "OVERRIDE"), 2); + PASS(); +} + TEST(parallel_total_edges) { if (ensure_parity_setup() != 0) FAIL("setup failed"); @@ -1173,6 +1234,7 @@ SUITE(parallel) { RUN_TEST(parallel_usage_parity); RUN_TEST(parallel_inherits_parity); RUN_TEST(parallel_implements_parity); + RUN_TEST(parallel_semantic_fixture_expected_counts); RUN_TEST(parallel_total_edges); RUN_TEST(parallel_empty_files); RUN_TEST(parallel_args_json_no_overflow); diff --git a/tests/test_py_lsp_bench.c b/tests/test_py_lsp_bench.c index 67e3f9dfd..9b6bfd41b 100644 --- a/tests/test_py_lsp_bench.c +++ b/tests/test_py_lsp_bench.c @@ -258,7 +258,7 @@ TEST(pylsp_bench_resolution_ratio) { /* Time budget. ASan+UBSan instrumentation slows the parse ~5-10×, so * scale the budget when a sanitizer is active. Native: 150 ms for a * ~200-line fixture; sanitized: 1500 ms. */ -#ifdef __SANITIZE_ADDRESS__ +#if defined(CBM_SANITIZED_BUILD) || defined(__SANITIZE_ADDRESS__) ASSERT(ms < 1500.0); #else ASSERT(ms < 150.0);