The rebase onto main brought the Bash-enabled PreToolUse matcher
(Grep|Glob|Bash), which the exec-form tests still counted as Grep|Glob.
The Windows lifecycle test seeded the .cmd shell spelling as the converged
identity; the shell-free registration converges every owned shell spelling
onto one exec-form entry per matcher instead. The test now asserts that,
plus their removal on uninstall, and hands cbm_install_agent_configs the
'/'-spelled binary path cbm_cmd_install registers (cbm_mkdtemp returns a
native '\' temp root on Windows, which uninstall never derives).
Smoke 8aq proved Claude's gate registration by grepping the shim name out
of settings.json; the exec form carries no shim name, so it now checks the
binary-plus-'hook-augment' PreToolUse hook. 9d's shim-name marker would
otherwise pass vacuously after uninstall; the 'hook-augment' argument is an
owned marker there too.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Step 0o (MCPB bundle contract) and Step 0q (release candidate derivation)
hand the composition gate a stub compiled straight from a .c file with the
compiler's defaults. That stub exists to exercise packaging, format detection
and the needle scans; it is not linked with -z now and never ships. A1d
measured eager binding on it anyway, so on a toolchain that binds lazily by
default (test-diag: clang-22) Step 0o died deterministically with
A1d-bind-now stage/codebase-memory-mcp: GOT section(s) .got.plt fall
outside PT_GNU_RELRO [15824,16384) — they stay WRITABLE after relocation
The property A1d protects is a property of the release link (-z now in
Makefile.cbm), so the assertion is scoped to it: a caller that feeds the gate
a packaging fixture declares so with CBM_COMPOSITION_FIXTURE=1 and A1d is
reported n/a — printed on every run as a named exemption, never a silent
skip. Every other assertion still runs on the fixture unchanged, and release
derivation never sets the variable.
Verified in the Linux arm64 container (Ubuntu 24.04, gcc 13.3): a stub
linked -z lazy reproduces the CI failure text on the gate without the
variable and reports n/a with it; both contracts pass under a CC wrapper
that links lazily (the shape that was red), and unchanged on macOS.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
When a suite leader has already exited, taskkill /T cannot walk its tree,
so run-test-wave.py proves cleanup by asking Windows whether anything is
still parented to the dead PID. That probe was timed with --kill-grace,
which the parallel gate passes as 1s. A cold powershell.exe + CIM start
routinely costs more than that on a runner, so TimeoutExpired -- a fact
about interpreter start-up, not about the process tree -- was read as
"assume the worst" and failed an already-clean shard with exit 2.
Give the probe its own stable-state budget (15s) plus one retry, keeping
the fail-closed semantics: an exhausted or failed probe still counts as
"cannot prove absence". The refusal now names which of the two happened,
an unfinished probe or a counted set of live descendants, instead of
reporting both as a leaked tree.
The contract test pins this structurally -- no sleeps, no timing
thresholds: the probe must be bounded by the module constant rather than
by the kill-grace argument, must retry a timed-out probe, must never read
an unusable probe as absence, and must report the two failures
distinctly. Its Windows leader-exit case now waits on the scheduler's
refusal rather than on a deadline shorter than the new budget.
Refs #1741
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
lock_registry_absolute_deadline_survives_repeated_wakes has failed on the
test-unix (macos-15-intel) leg of three unrelated pull requests in one
week -- #1342 (08-28), #1811 (09-02) and #1819 (09-03, run 33799475629,
job 100823626965) -- always with the same signature:
FAIL tests/test_lock_registry.c:1153: ASSERT(tail_queued)
7648 passed / 1 failed
The registry is not racy. cbm_lock_registry_acquire enqueues the waiter
synchronously under the registry mutex before any wait, so waiter_count
and the attempting count are exact. The defect is in the fixture: it
raced two wall-clock windows against each other, both anchored to a
timestamp the observer thread took before the tail thread had even been
scheduled.
deadline_start = cbm_now_ms();
tail.deadline_ms = deadline_start + 200; /* the tail's acquire deadline */
queue_deadline = deadline_start + 100; /* the observer's budget */
Because both windows start before the tail runs, a loaded runner breaks
the fixture two different ways:
* the tail is scheduled inside its deadline but after the observer's
100 ms budget has expired -- the queued state existed and was simply
no longer being looked at; or
* the tail is scheduled more than 200 ms late, in which case its
deadline has already passed when it finally calls acquire, the
pre-registration deadline check in lock_registry_acquire_internal
returns BUSY immediately, and the tail never enqueues at all -- the
asserted state can then never occur, however long the observer waits.
lock_registry is in the parallel wave of run-tests-parallel.sh, not the
serial tail, so on a small CI runner it competes with a full wave of
sanitized suites -- exactly the scheduling delay both paths need.
Widening the observer's budget would only paper over the first path, and
the state it waits for is transient by construction: it exists only
between the tail's enqueue and the tail's own deadline. So the fixture is
rebuilt to observe states that cannot evaporate.
* The tail anchors its absolute deadline itself, in its own thread,
immediately before the acquire it bounds. Scheduling delay can no
longer consume the deadline before the call starts, so the enqueue is
unconditional, and elapsed is measured from the tail's own anchor --
it now times the registry instead of timing the scheduler.
* Enqueue is exposed as a monotonic counter,
cbm_lock_registry_waiter_enqueue_count_for_test, next to the existing
test_condition_wait_calls counter it is modelled on. Because the
count only ever grows, the observer reads it once after the tail has
returned rather than trying to catch a live queue depth: the polling
loop, and with it the window, is gone.
* The fixture's remaining 500 ms and 600 ms budgets become the file's
LOCK_REGISTRY_TEST_TIMEOUT_MS backstop, and each loop exits on the
state it waits for instead of on the clock, so the backstop only
fires when the product is actually broken.
The contract is unchanged: the tail must still return at its absolute
deadline (150 <= elapsed < 350 ms for a 200 ms deadline) despite ~40
unrelated cancel broadcasts, still with BUSY and no lease, with the head
still holding the attempt. The broadcast loop now starts immediately
after the tail is released, so it overlaps the tail's wait at least as
much as it did before.
Verification, all on macOS arm64 with the sanitized runner:
* The mechanism was reproduced locally by delaying only the tail thread
after its start gate, with production untouched. A 120 ms delay (the
state exists, outside the observer's budget) and a 250 ms delay (the
tail never enqueues) each produced exactly one failure,
ASSERT(tail_queued) -- the CI signature.
* After the rebuild the same injections are green at 120, 250, 400 and
900 ms: an arbitrary scheduling delay no longer decides the verdict.
* lock_registry 30/30 green plain and 30/30 green under four CPU hogs;
16/16 inside the saturated 18-job parallel wave.
* private_file_lock, lock_registry, daemon, project_lock and the
daemon_* suites: 222 passed, 1 skipped.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
The test-msan lane dies whenever the buildx layer cache misses and the
builder's resolver hiccups. PR-CI runs 33816056229 and 33818108315 both
failed in "Build MSan image (cached layers)" with:
#7 26.77 Could not resolve 'apt.llvm.org'
#7 27.34 W: Failed to fetch http://apt.llvm.org/noble/dists/\
llvm-toolchain-noble-22/InRelease Could not resolve 'apt.llvm.org'
ERROR: failed to build: failed to solve: process "/bin/sh -c apt-get \
update && ..." did not complete successfully
The wget of the signing key from that SAME host had succeeded seconds
earlier in the same layer, so this is a transient DNS failure inside
buildkit, not a wrong URL or a dead mirror. 2 of the last 15 PR runs.
Every network-touching step in Dockerfile.msan now runs under a small
POSIX-sh helper written into the image before the first layer that needs
it: /usr/local/bin/retry makes 5 attempts with growing backoff
(5s/10s/20s/40s) and still exits non-zero once they are exhausted, so a
genuine breakage keeps failing the build instead of being swallowed.
Wrapped: both apt-get update/install pairs (also given
-o Acquire::Retries=3), the wget of the LLVM signing key, and the
llvm-project and zlib clones. Two shapes needed care:
- the key is fetched with `wget -O <file>` instead of `-qO- > <file>`,
because a redirect is opened once for all attempts and a partial write
from a failed attempt would be prepended to a later successful one;
- each clone clears its destination first, or a half-finished clone makes
every later attempt fail on "destination path already exists".
The pinned base digest, the pinned llvmorg-22.1.0 / v1.3.1 tags,
--no-install-recommends and the layer structure are unchanged; on a
healthy network the image is byte-for-byte the same work as before.
Verified on Colima:
- `docker buildx build --check -f test-infrastructure/Dockerfile.msan
test-infrastructure/` -> "Check complete, no warnings found."
- scratch image = FROM + helper + the real apt.llvm.org layer, built
--no-cache: layer completes, clang-22/llvm-22 installed.
- scratch image where the wrapped command fails twice then succeeds:
attempts 1 and 2 logged with 5s/10s backoff, attempt 3 succeeds,
build exit 0.
- scratch image where the wrapped command always fails: 5 attempts,
"retry: giving up after 5 attempts", retry_exit=1, build exit 1.
The full MSan image was deliberately not built locally (it compiles
libc++ with MSan); CI builds it on the branch.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
A participant admitted to the version cohort holds the cohort lifetime lock
SH until its lease is released at exit. An internal daemon that has just
lost its last stdio client keeps that lock through its teardown, so a local
CLI (`config get`, `index_status`, ...) arriving in that few-hundred-ms
window met a holder whose cache root differed and was refused immediately
with "active account daemon uses a different cache directory" — a pure
lifecycle race, previously masked by the slow scalar self-hash and exposed
as soon as the hash got fast (#1767 pr-smoke on macOS).
cbm_version_cohort_acquire now retries a CONFLICT until the caller's finite
deadline_ms, holding no guard between attempts, exactly as host.c already
waits out the same handoff for the daemon claim marker. Every production
caller passes a finite deadline; UINT64_MAX keeps failing fast so a genuine
conflict against a long-lived peer is never waited on indefinitely.
Tests (deterministic, no timing assertions on transient windows): one proves
the retry runs until the deadline and a UINT64_MAX caller still fails
immediately; one proves a waiter that met the mismatched holder is admitted
the moment the holder releases. Both fail with the retry removed.
Fixes#2046
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
The 8al-node probe imports the generated Pi extension in bare Node with an
empty node_modules. That was a sound stand-in for the Pi host only while
the extension imported nothing but node: builtins; the first package
import (`typebox`, Pi's schema library for tool parameters — a real Pi
dependency, verified against pi-coding-agent 0.84.4 on #1809) can never
resolve there, so the probe fails on a correct extension and would pass a
wrong one just as readily.
Give the probe what the host gives the extension: a Proxy-backed
`typebox` stub in the probe's node_modules (any Type.X(...) returns a
descriptor, so the probe never tracks which helpers the emitter uses — it
only exercises the tool lifecycle), plus an explicit allowlist: every
import specifier in the generated file must be a node: builtin or a
package on PI_HOST_PACKAGES, otherwise the probe fails naming the
specifier. That turns the guarantee the empty node_modules gave by
accident (no surprise dependency) into an asserted contract.
Verified locally by running only the 8al-node block against extensions
generated by two binaries:
main@5802ccdd extension, old probe: OK; new probe: OK
#1809 extension, old probe: ERR_MODULE_NOT_FOUND typebox (the CI red)
#1809 extension, new probe: OK
#1809 extension + an injected 'lodash' import, new probe:
FAIL 8al-node: ... packages the Pi host does not provide: lodash
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
The test-lsan-macos leg installs the unversioned `llvm` formula, which
tracks Homebrew's current stable. That moved from 22.1.8 to 23.1.0 today,
so the leg's compiler now depends on which bottle the runner image draws:
run 33745916176 (PR #1811) poured 23.1.0 and died at compile on a new
Clang 23 diagnostic (-Wunused-but-set-global under -Werror); the run an
hour earlier poured 22.1.8 and built fine. Same code, two verdicts.
Pin the formula (and the `brew --prefix` lookup) to llvm@22 — the version
every green run of this leg has used so far. Moving to a newer major
becomes a deliberate edit of these two lines instead of an accident of
the runner image.
The diagnostic itself is fixed independently in #2027 so main compiles
under both majors; this change is about determinism of the leg, not the
warning.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
`g_mcp_command_path_probe_counter` is only ever read inside
`cbm_json_mcp_probe_command_path`, which is compiled for Windows alone.
On POSIX test builds the setter stored a pointer nothing read, and Clang
23 (Homebrew LLVM 23.1.0, which `brew install llvm` now pours on the
macOS LSan leg) flags that as `-Wunused-but-set-global`; with `-Werror`
the whole test-runner build dies at `src/cli/cli.c:1768:21`.
Keep the counter where a probe can happen (Windows) and make the POSIX
setter an explicit no-op with the reason spelled out. The three tests
that install a counter keep asserting it stays at zero on every platform,
which on POSIX is exactly what "no classifier" means.
Verified in the failing arena: `origin/main` `src/cli/cli.c` fails under
clang 23.1.1 with the CI diagnostic; with this change it compiles clean,
as do all 105 built `src` TUs and all 155 test + extraction TUs under the
same flags. `cli` suite: 292 passed with Homebrew LLVM 22.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Every Windows guard shared ONE daemon. test_daemon_stability.py said each
section "runs under its OWN cache directory: daemon coordination is
cache-scoped" -- it is not. The rendezvous key is FNV-1a over a compile-time
domain string (service.c: cbm_daemon_rendezvous_key) under a machine-global
parent (win_default_runtime_parent -> SHGetFolderPathW(CSIDL_LOCAL_APPDATA),
which ignores the LOCALAPPDATA variable). So the guard's own `daemon stop`
and kill -9 sections, the sibling guards, and anything else on the runner
all met the same daemon, and a daemon wedged anywhere failed every check
after it. That is the shape of this week's test-windows-guards red on
#1874: lifecycle's `daemon start` connected, the UI-config handshake never
answered, and stability / hook_augment / ui_drive / cli_non_ascii went red
in a row with an orphan daemon killed at cleanup -- on a diff and a base that
were each green elsewhere.
CBM_RUNTIME_DIR is the only relocation hook (bootstrap.c, resolved in
cbm_daemon_bootstrap_endpoint_new for every product endpoint). Each stability
section now gets a private runtime parent next to its cache, created before
the binary runs -- the endpoint validates the parent and never creates it --
and the daemon inherits it from the client that spawns it. Proven on macOS:
a daemon on the default rendezvous keeps its pid across the whole guard
(three stops and a kill -9 inside it).
The reporting was hiding the cause as well:
* every SETUP FAIL printed nothing at all, and every RED printed a 300-400
character HEAD slice, which is the allocator preamble, never the error.
Excerpts now show the tail (1500 chars); setup paths print the daemon's
full output.
* a subprocess timeout raised on a parallel-client worker thread was lost
and the client reported as "rc=none" with no output; it is now kept as a
failed result with its partial output. A section-level timeout is a
named RED with the command and output, not a bare traceback.
* the MCP session's stderr went to DEVNULL; it now goes to a file that is
printed when initialize never answers -- and that case is a RED against a
running daemon, not a "setup" skip.
* exit 2 was documented and never produced. Only `daemon start` failing
raises it now (test_daemon_lifecycle.py owns that surface as a
regression, so the board loses nothing); main() runs every section and
names all of the failing ones.
* test_hook_augment.py / test_cli_non_ascii_arg.py print full stdout AND
stderr on their index-setup failures instead of 300 chars of stdout.
On POSIX (local iteration only) the namespace lives under /tmp: sun_path
caps the socket at 104 bytes on macOS and TMPDIR there is ~50 bytes deep.
Out of scope, noted: test_daemon_lifecycle.py, test_non_ascii_path.py and
test_ui_drive_listing.py still use the machine-global rendezvous; a too-long
CBM_RUNTIME_DIR on POSIX is refused (ipc.c sun_path check) with no
validation detail.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>