Files
Martin Vogel 73f4a432e4 test(windows): isolate the daemon guards' rendezvous and stop hiding their failures
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>
2026-09-03 14:56:42 +02:00

154 lines
7.1 KiB
Python

"""GREEN regression guard — `cli index_repository` honors a non-ASCII repo_path.
Guards the CLI-argv fix for issue #636 / #423 / #20 on native Windows.
The documented entrypoint `codebase-memory-mcp cli index_repository '<json>'`
receives its JSON argument through argv. main() used to take only the narrow
`int main(int argc, char **argv)` (src/main.c), so on Windows the C runtime handed
it argv in the active ANSI code page: a repo_path containing non-ASCII characters
was mangled (or, when yyjson rejected the now-invalid UTF-8, the whole argument was
discarded), and the command failed with "repo_path is required" / "Pipeline failed"
instead of indexing the real directory.
Fixed: on Windows main() now rebuilds argv from the wide command line
(GetCommandLineW + CommandLineToArgvW) and converts each element to UTF-8, so a
non-ASCII repo path survives. This test asserts that fix stays in place — it was RED
before it and is GREEN after. (It is inherently green on Linux/macOS, where argv is
already UTF-8 bytes.)
The directory itself is created with the Windows wide API (Python uses
CreateFileW/_wmkdir under the hood), so it genuinely exists on disk; only the
argv path delivery was lossy.
Exit code: 0 == honored (green), 1 == rejected/mangled (red), 2 == setup error.
Usage:
python test_cli_non_ascii_arg.py <path-to-codebase-memory-mcp[.exe]>
"""
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
MATH_TS = (
"export function add(a: number, b: number): number { return a + b; }\n"
"export class Calc { total = 0; push(x: number): void { this.total = "
"add(this.total, x); } }\n"
)
def make_fixture(root):
src = os.path.join(root, "src")
os.makedirs(src, exist_ok=True)
with open(os.path.join(src, "math.ts"), "wb") as f:
f.write(MATH_TS.encode("utf-8"))
def main():
if len(sys.argv) < 2:
print("usage: python test_cli_non_ascii_arg.py <binary>")
return 2
binary = os.path.abspath(sys.argv[1])
if not os.path.exists(binary):
print("FAIL: binary not found: %s" % binary)
return 2
work = tempfile.mkdtemp(prefix="cbm_win_cliarg_")
try:
# Non-ASCII repo directory (created via the OS wide API → really exists).
repo = os.path.join(work, "café_日本語_repo")
make_fixture(repo)
cache = os.path.join(work, "cache")
os.makedirs(cache, exist_ok=True)
# Sanity: an ASCII control path must index through the CLI, proving the
# CLI path itself works and isolating the failure to argv encoding.
ascii_repo = os.path.join(work, "ascii_repo")
make_fixture(ascii_repo)
# The control is setup, not the surface under test: a cold runner can
# lose the first one-shot to coordination-daemon startup latency
# (#1952). One retry against a fresh cache separates that from a real
# CLI indexing failure.
ctrl_out = ""
ctrl_err = ""
for attempt in ("cache_ascii", "cache_ascii_retry"):
env = dict(os.environ)
env["CBM_CACHE_DIR"] = os.path.join(work, attempt)
ctrl = subprocess.run(
[binary, "cli", "index_repository",
json.dumps({"repo_path": ascii_repo})],
capture_output=True, timeout=120, env=env)
ctrl_out = (ctrl.stdout or b"").decode("utf-8", "replace")
ctrl_err = (ctrl.stderr or b"").decode("utf-8", "replace")
if '"nodes"' in ctrl_out:
break
# Full stdout AND stderr: the reason lives on stderr (daemon
# spawn/handshake diagnostics), and a head slice of stdout only
# ever showed the allocator preamble.
print("SETUP: ASCII control attempt %r did not index via CLI (rc=%s):\n"
"stdout:\n%s\nstderr:\n%s"
% (attempt, ctrl.returncode, ctrl_out, ctrl_err))
time.sleep(2)
if '"nodes"' not in ctrl_out:
print("SETUP FAIL: ASCII control did not index via CLI:\nstdout:\n%s\nstderr:\n%s"
% (ctrl_out, ctrl_err))
return 2
env2 = dict(os.environ)
env2["CBM_CACHE_DIR"] = cache
# Exercise the DEFAULT (supervisor-enabled) path, not in-process. The non-ASCII
# repo path must survive BOTH the argv read (main() wide command line) AND the
# supervisor -> worker spawn (CreateProcessW). The suite runner sets
# CBM_INDEX_SUPERVISOR=0 for determinism across the other guards; forcing it OFF
# here would run in-process and mask the spawn-boundary half of #423/#20, so we
# drop the override and let the supervisor wrap the worker as it does for users.
env2.pop("CBM_INDEX_SUPERVISOR", None)
arg = json.dumps({"repo_path": repo}, ensure_ascii=False)
p = subprocess.run([binary, "cli", "index_repository", arg],
capture_output=True, timeout=120, env=env2)
out = (p.stdout or b"").decode("utf-8", "replace")
err = (p.stderr or b"").decode("utf-8", "replace")
honored = '"nodes"' in out and '"nodes":0' not in out.replace(" ", "")
print("ASCII control: indexed OK")
print("non-ASCII argv (supervised): rc=%d" % p.returncode)
print(" stdout: %s" % out[:200].replace("\n", " "))
print(" stderr: %s" % err[-200:].replace("\n", " "))
# #973 variant: the reporter's exact shape — Traditional-Chinese dir,
# FLAG-form --repo-path + --mode fast, supervised. This adds coverage
# of the flag->JSON converter and the repo-path canonicalization,
# which used ANSI _access/_fullpath (locale-dependent — corrupted CJK
# paths on CJK-codepage systems) before the cbm_canonical_path fix.
cjk_repo = os.path.join(work, "\u96f7\u9054\u6e2c\u8a66")
make_fixture(cjk_repo)
env3 = dict(os.environ)
env3["CBM_CACHE_DIR"] = os.path.join(work, "cache_cjk")
env3.pop("CBM_INDEX_SUPERVISOR", None)
p2 = subprocess.run([binary, "cli", "index_repository",
"--repo-path", cjk_repo, "--mode", "fast"],
capture_output=True, timeout=120, env=env3)
out2 = (p2.stdout or b"").decode("utf-8", "replace")
cjk_ok = '"nodes"' in out2 and '"nodes":0' not in out2.replace(" ", "")
print("CJK flag-form (--repo-path, supervised, fast): rc=%d" % p2.returncode)
print(" stdout: %s" % out2[:200].replace("\n", " "))
if not cjk_ok:
print("\nRED: CJK flag-form repo_path was not indexed (#973 — "
"canonicalization or spawn boundary mangled the path).")
return 1
if honored:
print("\nGREEN: CLI honored the non-ASCII repo_path (argv + worker spawn).")
return 0
print("\nRED: CLI did not index the non-ASCII repo_path — the path was mangled "
"either in the argv read (narrow main()) or re-mangled at the "
"supervisor->worker CreateProcess boundary (ANSI code page).")
return 1
finally:
shutil.rmtree(work, ignore_errors=True)
if __name__ == "__main__":
sys.exit(main())