Skip to content

fix(test): isolate benchmark harness daemon runtimes - #2198

Open
astandrik wants to merge 5 commits into
DeusData:mainfrom
astandrik:fix/1696-bench-runtime-isolation
Open

astandrik wants to merge 5 commits into
DeusData:mainfrom
astandrik:fix/1696-bench-runtime-isolation

Conversation

@astandrik

@astandrik astandrik commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

What does this PR do?

Part of #1696 (audit ledger), follow-up to #1691/#1695.

scripts/benchmark-index.sh and scripts/benchmark-search-graph.sh ran the product
with no runtime or cache of their own: the benchmark repository was indexed into the
operator's live store, every one-shot joined the operator's account daemon, and the
timings depended on whatever that daemon was doing.

  • Both scripts source scripts/test-runtime.sh, call cbm_test_runtime_init and
    run cbm_test_runtime_cleanup "$BINARY" from the EXIT trap.
  • Comparability: benchmark-index.sh starts the private daemon before timing, so
    index-time.txt measures the index alone, and records setup-time.txt (daemon
    activation) and total-time.txt (their sum — the figure comparable with earlier
    runs, which paid activation inside the index timing whenever no daemon was warm).
    benchmark-search-graph.sh now takes <repo-path> instead of <project-name>:
    it indexes into the private cache (untimed) and times the queries against a daemon
    it keeps warm, so no timing includes activation. No performance claim is made.
  • tests/test_benchmark_runtime_isolation_contract.sh: environment-probe fixture
    for both scripts plus the presence of the three timing files, the keep handoff
    (kept only after a successful index) and the refused-index contract (non-zero
    exit, cause surfaced, no root, no handoff). Fails on main with FAIL: benchmark-index exposed the caller CBM_RUNTIME_DIR to a product process; passes
    with this change. Wired as Step 0i2 in scripts/test.sh.

docs/EVALUATION_PLAN.md §7 runs benchmark-index.sh and then answers graph
questions from its own MCP session, which assumed the index stayed in the live store.
The harness therefore has an explicit, opt-in handoff: with CBM_BENCH_KEEP_RUNTIME=1
a run whose index succeeded stops its daemon, leaves the run-private root in place and
records the paths that reach it in <results>/<lang>/runtime-root.txt (sourceable);
ownership of that root, including its removal, passes to the caller. A failed or
refused index exits non-zero after the timing files are written, keeps nothing (no
root, no handoff — a stale one from an earlier run in the same results directory
included) and names its cause under --- index stderr ---. §7 sets the flag, sources
the file before the graph session, stops the daemon and removes the root in step 8;
§13 no longer clears ~/.cache/codebase-memory-mcp/*.db. Never on by default.

Checklist

  • Every commit is signed off (git commit -s)
  • Tests pass locally (make -f Makefile.cbm test) — full C suite (ASan/UBSan,
    sequential) on Linux/gcc 13.3 at f913f0e: 8114 passed, 8 skipped, exit 0; scripts/test.sh
    default (143/143 suites) and --tsan (1180 passed, 8 skipped) on the merge result
    with main e783f73
  • Lint passes (make -f Makefile.cbm lint-ci) — clang-format 20.1.8 + cppcheck
    2.20.0 (the CI toolchain) at f913f0e, clean
  • New behavior is covered by a test (reproduce-first for bug fixes)

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Pull request overview

This PR updates the benchmark harness scripts to run against a harness-owned daemon runtime and cache, preventing benchmark runs from contaminating or depending on the operator’s live store/daemon state, and adds a contract test to enforce that isolation.

Changes:

  • Add a benchmark runtime isolation contract test that verifies both harnesses don’t leak caller CBM_* dirs into product processes and that benchmark-index.sh writes new timing artifacts.
  • Update benchmark-index.sh to initialize/cleanup a private runtime, start a private daemon before timing, and emit setup-time.txt and total-time.txt.
  • Update benchmark-search-graph.sh to accept <repo-path>, index into a private cache (untimed), and run queries against a warm private daemon.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

File Description
tests/test_benchmark_runtime_isolation_contract.sh Adds an isolation contract test + timing file presence checks for benchmark harnesses.
scripts/test.sh Wires the new contract test into the shell test runner.
scripts/benchmark-search-graph.sh Switches to repo-path input and makes indexing/queries use a harness-owned runtime/daemon.
scripts/benchmark-index.sh Runs index benchmark under harness-owned runtime/cache, starts private daemon before timing, and writes setup/total timing files.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread scripts/benchmark-search-graph.sh Outdated
echo "private daemon did not start" >&2
exit 1
fi
INDEX_JSON=$("$BINARY" cli index_repository "{\"repo_path\":\"$REPO\",\"mode\":\"full\"}" 2>/dev/null || echo '{}')

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taken, in both scripts (8f0ff31): REPO_JSON=$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$REPO"), the spelling scripts/soak-test.sh already uses for the same reason.

benchmark-index.sh carried the identical unescaped interpolation (pre-existing, not flagged); fixing one file and leaving its twin looked worse than the small extra diff.

Fail-before is in the contract now: with a repository path containing " and \, the old request came out as {"repo_path":"/private/tmp/.../re"po\dir","mode":"full"}, which is not JSON. The test asserts the recorded request parses and that its repo_path is the resolved repository. POSIX-only case — NTFS rejects both characters in a path component.

Comment thread scripts/benchmark-index.sh Outdated
# alone. setup-time.txt keeps the activation cost attributable and
# total-time.txt is their sum — the figure comparable with earlier runs, which
# paid activation inside the index timing whenever no daemon was already warm.
SETUP_START_MS=$(python3 -c "import time; print(int(time.time()*1000))")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taken (8f0ff31). All three readings had to move together — they are subtracted from each other, so mixing clocks was not an option — through one helper, bench_now_ms, using time.monotonic_ns() // 1000000. Its reference point is fixed per boot on every platform this runs on, so the three readings stay comparable even though each comes from its own python3 process.

Comment thread scripts/benchmark-index.sh Outdated
fi

# Index via CLI and capture timing
START_MS=$(python3 -c "import time; print(int(time.time()*1000))")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taken (8f0ff31). All three readings had to move together — they are subtracted from each other, so mixing clocks was not an option — through one helper, bench_now_ms, using time.monotonic_ns() // 1000000. Its reference point is fixed per boot on every platform this runs on, so the three readings stay comparable even though each comes from its own python3 process.

Comment thread scripts/benchmark-search-graph.sh Outdated
Comment on lines 31 to 43
INDEX_JSON=$("$BINARY" cli index_repository "{\"repo_path\":\"$REPO\",\"mode\":\"full\"}" 2>/dev/null || echo '{}')
PROJECT=$(echo "$INDEX_JSON" | python3 -c "
import json, sys
d = json.load(sys.stdin)
if 'content' in d:
d = json.loads(d['content'][0]['text'])
print(d.get('project', ''))
" 2>/dev/null || echo "")
if [ -z "$PROJECT" ]; then
echo "index of $REPO did not report a project" >&2
exit 1
fi

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taken (8f0ff31). Both the index call and the parse now write stderr to a run-private temp file (removed by the same EXIT trap), and the failure path prints that stderr plus the first 500 bytes of the response under the existing "did not report a project" line. Covered in the contract: the fixture refuses the index with a stderr line, and the test requires both the section header and the cause to reach the output — it fails against the previous head of this PR.

As of 07ee617 benchmark-index.sh keeps the index stderr the same way: the maintainer's round asked for a refused index to exit non-zero, and a failure that names only its symptom would have been half of it.

@github-actions

Copy link
Copy Markdown

Thanks for opening this — it has been seen, and it is queued.

This note is automated, but it is not a brush-off: it exists so you know where your PR stands instead of having to guess from silence.

Current review status: working through a backlog. 0.9.1-rc.1 is out, so the release freeze that held reviews is over — but it left a large queue of open pull requests behind it, and we are reading through them oldest-first. The background is in discussion #1144.

What that means for this PR, concretely:

  • It will not be closed for inactivity. No stale bot touches pull requests here.
  • It may still sit a while before a human reads it. That is on us, not on you.
  • Older PRs are read first, so a recent one is not being skipped — it is behind a queue.

Things that will genuinely speed it up whenever review does happen:

  • Keep it rebased on main — the tree is moving quickly right now, and a conflicting branch cannot be reviewed as the diff you intended.
  • Get CI green, or say which failures you believe are pre-existing.
  • Keep the change to one claim. Bundled features and refactors get split before they get merged, which costs you a round trip.
  • Every commit needs a sign-off (git commit -s) — CI enforces DCO.

If this fixes a bug, a reproduction we can run is worth more than a description of the symptom.

Thanks for contributing, and sorry in advance for the wait.

@DeusData DeusData added bug Something isn't working stability/performance Server crashes, OOM, hangs, high CPU/memory priority/high Needs near-term maintainer attention; high-impact bug, regression, safety issue, or release blocker. labels Sep 19, 2026
@DeusData

Copy link
Copy Markdown
Owner

Thank you for the benchmark isolation work. The evaluation plan has a later graph phase that expects the indexed data to remain available, so cleanup and retained-runtime ownership need an explicit handoff before this is integrated. We need more time to review that lifecycle contract and keep the reported total-time comparison meaningful; no benchmark redesign is being accepted here.

scripts/benchmark-index.sh and scripts/benchmark-search-graph.sh ran the
product with no runtime or cache of their own: the benchmark repository
was indexed into the operator's live store and every one-shot joined the
operator's account daemon. Source scripts/test-runtime.sh in both, start
the private daemon before timing, record setup-time.txt and
total-time.txt beside index-time.txt, and index from a repository path
instead of querying a project in the live store.

Add tests/test_benchmark_runtime_isolation_contract.sh, which fails
before this change, and wire it into scripts/test.sh.

Part of DeusData#1696.

Signed-off-by: Anton Standrik <astandrik@yandex-team.ru>
Review follow-up for the benchmark harness isolation.

- Both benchmark scripts build the index request with a JSON-escaped
  repository path (python3 json.dumps, the spelling soak-test.sh already
  uses): a path containing a quote or a backslash produced a payload the
  server could not parse.
- benchmark-index.sh reads its three timestamps from one helper backed by
  time.monotonic_ns(); an NTP step mid-run no longer skews or negates a
  figure whose purpose is comparison across runs.
- benchmark-search-graph.sh keeps the stderr of the index call and of the
  response parse in a run-private temp file (removed by the EXIT trap) and
  prints it, plus the first 500 bytes of the response, under the existing
  "did not report a project" line.
- The contract test now refuses the index from its fixture and requires the
  cause to reach the output, and (POSIX only) drives a repository path with
  a quote and a backslash and requires the recorded request to parse as
  JSON with the resolved path. Both assertions fail against the previous
  head of this branch.

Signed-off-by: Anton Standrik <astandrik@yandex-team.ru>
The evaluation plan (docs/EVALUATION_PLAN.md §7) indexes a language with
benchmark-index.sh and then answers graph questions against that index
from its own MCP session. With a run-private runtime (DeusData#1696) the index
was gone before that session could start, so the handoff is now explicit
and opt-in.

With CBM_BENCH_KEEP_RUNTIME set, a SUCCESSFUL run stops its daemon,
leaves the private root in place and records the paths that reach it in
<results>/<lang>/runtime-root.txt (sourceable: CBM_BENCH_RUNTIME_ROOT,
CBM_RUNTIME_DIR, CBM_CACHE_DIR). Ownership of that root, including its
removal, passes to the caller. A failed run cleans up regardless: there
is no index worth keeping and nothing may leak. Nothing changes in the
default path or in the three timing files.

§7's skeleton sets the flag, sources the file before the graph session
and removes the root in step 8 instead of the live-store *.db files.

The contract test drives a kept run and requires the root to survive,
the handoff file to be sourceable, and its paths to be the ones the
product processes actually used; the unflagged run still asserts the
root is gone. The kept-run assertions fail against the previous head.

Signed-off-by: Anton Standrik <astandrik@yandex-team.ru>
@astandrik
astandrik force-pushed the fix/1696-bench-runtime-isolation branch from b5b69af to 9574c31 Compare September 21, 2026 09:50

@DeusData DeusData left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the three follow-up commits — this answers the handoff question exactly the way I hoped: opt-in only, the daemon stopped before the root is handed over, a sourceable runtime-root.txt so the graph session never has to guess paths, and ownership stated in one sentence. I verified it on the merge result with today's main (e783f73d): merge clean; the contract test passes as Step 0i2 in 1.2 s; with either benchmark script alone restored to main it fails on the right assertion (benchmark-index exposed the caller CBM_RUNTIME_DIR to a product process, and the search-graph twin), so it binds to both fixes; bash -n and shellcheck -x -P scripts clean on every touched script; no delay added anywhere; and the caller's runtime and cache never reach a product process, with zero cbmrt.* roots left behind after every run. The scripts/test.sh hunk is one purely additive step, no environment or ordering change, and I have noted its scope on our side.

One thing to close before it merges, and it is in the contract you wrote rather than in the isolation: retention keys on the script's exit status, but an index failure is swallowed. bench_finish keeps the root when $? -eq 0, yet the index call is … || echo '{"error":"index failed"}', so a refused index still exits 0. With CBM_BENCH_KEEP_RUNTIME=1 that keeps the root and writes runtime-root.txt for an index that does not exist — reproduced here: exit 0, 00-index.json = {"error":"index failed"}, root present, handoff written. That contradicts the comment above bench_finish ("a failed run cleans up regardless: there is no index worth keeping") and it is the case §7 would hit hardest, because the graph session would then run against an empty store and every question would look like a miss. The contract test enshrines exactly this path: the keep run uses the probe fixture, which refuses cli index_repository, so the assertions at the keep step check that a root is kept after a failed index.

What I would ask for, one small round:

  1. Make retention depend on the index having succeeded, not on the exit status — either require a non-empty project from the index in bench_finish, or let an index failure exit non-zero after the timing files are written (my preference: the second, so the caller's loop notices too).
  2. In the contract test, let the keep-run fixture answer cli index_repository with a minimal envelope (e.g. {"project":"probe"}) so the keep assertions cover the successful-index handoff, and add one assertion that CBM_BENCH_KEEP_RUNTIME=1 with a refused index leaves no root and no handoff file.
  3. Two doc lines if you are in there anyway: §7 step 8 removes a root whose daemon the graph session (steps 4–7) may still have running — a daemon stop under the kept CBM_RUNTIME_DIR belongs before the rm -rf; and §13 Reproducibility still clears ~/.cache/codebase-memory-mcp/*.db and calls the harness unflagged, which no longer describes what the harness does.

Incidental and not yours to fix: run_case in benchmark-search-graph.sh uses date +%s%3N, which BSD date on macOS does not support; the contract test never reaches it, so CI is unaffected, and I have recorded it on our side. The Windows guard failure on your run is the daemon endpoint cold-start race (#2275), not this diff; I will rerun it once that has landed. After this round the harness change is the one we want.

bench_finish keeps the private root on a zero exit status, but the index
call swallowed its failure (`|| echo '{"error":"index failed"}'`), so a
refused index exited 0 and, with CBM_BENCH_KEEP_RUNTIME set, kept an
empty root and wrote runtime-root.txt for an index that does not exist.

benchmark-index.sh now records the CLI's exit status, keeps 00-index.json
valid JSON on failure ({"error":"index failed","exit":N}), and exits 1
once every per-run file — the three timing files included — is written,
when the index failed or reported no project (the search-graph twin's own
test). bench_finish then cleans up as its comment always promised, and
the caller's loop notices. The index call keeps its stderr in a
run-private temp file, the twin's shape, and the failure prints it under
`--- index stderr ---` with the first 500 bytes of the response: for a
one-shot CLI, stderr is the only channel a refusal is reported on. A
stale runtime-root.txt from an earlier run in the same results directory
is removed at start, so a failed run leaves no handoff at all.

The contract test's fixture answers `cli index_repository` with a
minimal envelope when CBM_BENCH_PROBE_INDEX_OK is set, so the keep run
covers the successful-index handoff and asserts project.txt; the
unflagged run asserts a non-zero exit and the surfaced cause after a
refused index; a new keep run with a refused index asserts a non-zero
exit, the timing files, no surviving root and no handoff, a pre-seeded
stale one included. Against the previous head the test fails on
"benchmark-index exited 0 after a refused index".

Signed-off-by: Anton Standrik <astandrik@yandex-team.ru>
§7 step 8 removed the kept runtime root while the graph session of
steps 4-7 may still have a daemon running under it; stop that daemon
under the sourced CBM_RUNTIME_DIR first. §13 Reproducibility still
cleared ~/.cache/codebase-memory-mcp/*.db and called the harness
unflagged, which no longer describes what the harness does: it indexes
into a run-private root, and only CBM_BENCH_KEEP_RUNTIME=1 hands that
root, via runtime-root.txt, to the graph phase.

Signed-off-by: Anton Standrik <astandrik@yandex-team.ru>
@astandrik

Copy link
Copy Markdown
Contributor Author

Thank you — reproduced exactly as you describe, and the retention contract now keys on the index, not on
the exit status alone.

benchmark-index.sh (07ee617): the index call records the CLI's exit status; once every per-run file is
written — the three timing files included — the run exits 1 when the CLI failed or reported no project
(the search-graph twin's own test), so bench_finish sees rc=1 and cleans up as its comment promised, and
the caller's loop notices. 00-index.json stays valid JSON on failure ({"error":"index failed","exit":N}).
The call also keeps its stderr in a run-private temp file, the twin's shape, and the failure prints it under
--- index stderr --- with the first 500 bytes of the response: for a one-shot cli the refusal is reported
on stderr only, and with 2>/dev/null the operator was left with the symptom. One extra line: a stale
runtime-root.txt from an earlier run in the same results directory is removed at start, since §7 reuses
/tmp/eval-results/<lang> and sources that file unconditionally — a failed re-run must not hand the graph
session the previous run's paths.

Contract test: the fixture answers cli index_repository with {"project":"probe"} when
CBM_BENCH_PROBE_INDEX_OK is set, so the keep run now covers the successful-index handoff (and asserts
project.txt); the unflagged run asserts a non-zero exit and the surfaced cause; a new keep run with a
refused index asserts a non-zero exit, the timing files, no surviving root and no handoff — a pre-seeded
stale one included. Against 9574c31 it fails on benchmark-index exited 0 after a refused index.

Docs (f913f0e): §7 step 8 stops the daemon under the sourced CBM_RUNTIME_DIR before rm -rf;
§13 step 2 no longer clears ~/.cache/…/*.db, calls the harness with CBM_BENCH_KEEP_RUNTIME=1 and points
at §7 for the handoff and removal.

Verified with a real binary built from this head (Linux, gcc 13.3) and with the release binary on macOS:
default run — exit 0, root gone; keep — exit 0, runtime-root.txt sourceable, cli list_projects under it
sees the index, a daemon started under it stops with the §7 step-8 recipe and the root goes away, no process
left; keep + refused index (CBM_ALLOWED_ROOT outside the repo) — exit 1, timing files present, no handoff,
no root, the refusal in the output; the same into a reused results directory — the stale handoff is gone.
bash -n and shellcheck -x -P scripts clean; merge with today's main (e783f73) is clean, and on that merge
result scripts/test.sh (default lane, 143/143 suites) and scripts/test.sh --tsan (1180 passed, 8 skipped, no
TSan reports) both pass end to end on Linux/gcc, Step 0i2 included. On this head, make -f Makefile.cbm lint-ci
with the CI toolchain (clang-format 20.1.8, cppcheck 2.20.0) is clean, and CI is green across the matrix
(https://github.com/DeusData/codebase-memory-mcp/actions/runs/35730933863), the Windows guards included this time.

Not touched, as you noted: date +%s%3N in run_case. One thing I saw while at it and left alone: both
harnesses still pass raw JSON to cli index_repository, which the CLI now warns is deprecated (--repo-path
is the flag form) — happy to switch both in a follow-up if you want it.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working priority/high Needs near-term maintainer attention; high-impact bug, regression, safety issue, or release blocker. stability/performance Server crashes, OOM, hangs, high CPU/memory

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants