fix(writer): open the graph DB wide-safe for non-ASCII cache paths

cbm_writer_open used a raw ANSI-CP fopen for the hand-rolled SQLite
writer - the one file-creating call on the dump chain without a
UTF-8-to-wide conversion. On a non-ASCII Windows profile
(C:\Users\Kovacs Janos) extract and resolve succeeded but the dump
failed with pipeline.err phase=dump: fopen could not create graph.db
under the profile-derived cache dir while cbm_mkdir_p (already wide)
had happily created the directory around it. Routed through cbm_fopen,
matching #700 (pass readers) and #973 (canonicalization); POSIX is
byte-transparent.

New Windows guard test_non_ascii_cache_dump.py indexes an ASCII repo
into a non-ASCII CBM_CACHE_DIR and requires a query_graph readback
with Function count > 0 - proving the DB was written to and reopened
from the non-ASCII cache, not merely that no error surfaced. The
existing non-ASCII guard only varied the repo path against an ASCII
cache, which is why this class went unseen.

The USERPROFILE-sourced env encoding question (Layer 2 - _environ is
ANSI-CP, not UTF-8) is deliberately not bundled; it needs verification
on a real Windows box and will be filed separately if the guard alone
does not settle it.

Closes #996

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel committed 2026-07-12 19:57:47 +02:00
1 parent db330bf3a0
commit d9adfeb688
3 files changed
+139 -1

No files matched your search

+5 -1
View File
@@ -2211,7 +2211,11 @@ cbm_db_writer_t *cbm_writer_open(const char *path) {
* -wal/-shm or a crashed session's WAL gets replayed on top of the
* new file at the next open (#897). */
cbm_remove_db_sidecars(path);
FILE *fp = fopen(path, "wb");
/* cbm_fopen, not raw fopen: the cache dir lives under the user profile,
* and an ANSI-CP fopen fails to create the DB on non-ASCII Windows
* profiles — the reported phase=dump failure (#996). Everything around
* this call (cbm_mkdir_p, sqlite3_open_v2 reopen) is already wide-safe. */
FILE *fp = cbm_fopen(path, "wb");
if (!fp) {
return NULL;
}
+2
View File
@@ -14,6 +14,7 @@
They must stay GREEN (exit 0); a RED (exit 1) means the fix
regressed and fails this runner.
* test_non_ascii_path.py guards #636/#357 (fixed by #700)
* test_non_ascii_cache_dump.py guards #996 (writer cbm_fopen)
* test_hook_augment.py guards #618 (fixed by #619)
* test_ui_drive_listing.py guards #548 (roots field)
* test_cli_non_ascii_arg.py guards #423/#20 (wide-argv main())
@@ -104,6 +105,7 @@ $env:CBM_INDEX_SUPERVISOR = "0" # in-process indexing (see .DESCRIPTION)
# (exit 2) and is skipped rather than failed.
$guards = @(
"tests\windows\test_non_ascii_path.py",
"tests\windows\test_non_ascii_cache_dump.py",
"tests\windows\test_hook_augment.py",
"tests\windows\test_ui_drive_listing.py",
"tests\windows\test_cli_non_ascii_arg.py"
+132
View File
@@ -0,0 +1,132 @@
"""Regression guard for issue #996 — dump phase must write the graph DB into a
NON-ASCII cache directory on Windows.
The existing test_non_ascii_path.py exercises non-ASCII REPO paths against an
ASCII cache, so it never covers the dump->cache write. #996's reporter (a
non-ASCII %USERPROFILE%, e.g. C:\\Users\\Kovács János) saw extract/resolve
succeed and `pipeline.err phase=dump`: cbm_writer_open used a raw ANSI-CP
fopen for the hand-rolled SQLite writer (internal/cbm/sqlite_writer.c), the
one file-creating call on the dump chain without UTF-8→wide conversion.
Fixed by routing it through cbm_fopen (same pattern as #700/#973).
This guard indexes an ASCII repo into a NON-ASCII cache dir (CBM_CACHE_DIR is
read before any USERPROFILE derivation, so it isolates the writer cleanly).
GREEN is non-vacuous: the index must succeed AND a query_graph readback must
count Function nodes > 0 — proving the DB was written to and reopened from
the non-ASCII cache, not merely that no error surfaced.
Passes on Linux/macOS either way (byte-transparent UTF-8 filesystems).
Exit code: 0 == invariant holds, 1 == regression, 2 == setup error.
Usage:
python test_non_ascii_cache_dump.py <path-to-codebase-memory-mcp[.exe]>
"""
import json
import os
import shutil
import sys
import tempfile
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from mcp_stdio import McpServer # noqa: E402
MATH_TS = (
"export function add(a: number, b: number): number { return a + b; }\n"
"export function mul(a: number, b: number): number { return add(a, a); }\n"
"export class Calc {\n"
" total: number = 0;\n"
" push(x: number): void { this.total = add(this.total, x); }\n"
"}\n"
)
# Mixed scripts in ONE segment — one shot covers the classes the sibling
# test exercises separately (the writer either converts wide or it doesn't).
NON_ASCII_CACHE_SEGMENT = "cache_café_Ωμέγα_日本語"
def graph_function_count(server, project):
resp = server.call_tool(
"query_graph",
{
"project": project,
"format": "json",
"query": "MATCH (n:Function) RETURN count(n) AS c",
},
)
text, err = McpServer.tool_text(resp)
if err or not text:
return 0
data = json.loads(text)
rows = data.get("rows") or []
if not rows or not rows[0]:
return 0
return int(rows[0][0])
def main():
if len(sys.argv) != 2:
print("usage: test_non_ascii_cache_dump.py <binary>")
return 2
binary = os.path.abspath(sys.argv[1])
if not os.path.exists(binary):
print(f"SETUP: binary not found: {binary}")
return 2
work = tempfile.mkdtemp(prefix="cbm_996_")
try:
repo = os.path.join(work, "ascii_repo")
os.makedirs(repo)
with open(os.path.join(repo, "math.ts"), "w", encoding="utf-8") as f:
f.write(MATH_TS)
cache = os.path.join(work, NON_ASCII_CACHE_SEGMENT)
os.makedirs(cache)
with McpServer(binary, cache_dir=cache) as s:
resp = s.call_tool("index_repository", {"repo_path": repo})
text, err = McpServer.tool_text(resp)
if err:
print(f"FAIL: index_repository rpc error: {err}")
return 1
text = text or ""
if '"error"' in text:
print(f"FAIL: index_repository into non-ASCII cache errored: {text[:300]}")
return 1
if "phase" in text and "dump" in text and "error" in text.lower():
print(f"FAIL: dump phase error: {text[:300]}")
return 1
# Non-vacuous readback: the DB must exist under the non-ASCII
# cache and answer queries.
project = None
try:
project = json.loads(text).get("project")
except (ValueError, AttributeError):
pass
if not project:
# TOON-shaped success output: fall back to list_projects.
lp = s.call_tool("list_projects", {})
lp_text, _ = McpServer.tool_text(lp)
lp_text = lp_text or ""
for line in lp_text.splitlines():
if "ascii_repo" in line:
project = line.split(",")[0].strip().strip('"')
break
if not project:
print("FAIL: could not determine project name after index")
return 1
count = graph_function_count(s, project)
if count < 1:
print(f"FAIL: readback from non-ASCII cache found {count} Function nodes")
return 1
print(f"OK: dump wrote and reopened graph DB under non-ASCII cache ({count} functions)")
return 0
finally:
shutil.rmtree(work, ignore_errors=True)
if __name__ == "__main__":
sys.exit(main())