mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
- #471 (RED): deeply-nested ambiguous Perl call chain f(f(f(...))) makes the GLR
stack-merge O(n^2) (stack_node_add_link recurses over all shared heads; the
#461 recursion-depth cap bounds stack depth, not total iterations). Reproduced
in a forked child with alarm(15) at depth 5000; asserts the child is not
signal-killed (RED when the quadratic blowup blows the time budget).
- #221 (GREEN guard): cbm_find_cli now probes Windows PATHEXT (.exe/.cmd/.bat/.ps1,
commit 0485d3f), so opencode is resolvable. Guards that fix on a fake
opencode shim placed on PATH. Candidate to close after reporter retest.
(#548 held: its agent draft spins up a live HTTP server in a thread to exercise
handle_browse; reworking to a lighter harness before adding.)
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
1 parent
806da45896
commit
8bf892cd8f
4 files changed
+401
-1
No files matched your search
+3
-1
@@ -415,7 +415,9 @@ TEST_REPRO_SRCS = \
|
||||
tests/repro/repro_issue431.c \
|
||||
tests/repro/repro_issue607.c \
|
||||
tests/repro/repro_issue403.c \
|
||||
tests/repro/repro_issue434.c
|
||||
tests/repro/repro_issue434.c \
|
||||
tests/repro/repro_issue471.c \
|
||||
tests/repro/repro_issue221.c
|
||||
|
||||
ALL_TEST_SRCS =$(TEST_FOUNDATION_SRCS) $(TEST_EXTRACTION_SRCS) $(TEST_STORE_SRCS) $(TEST_CYPHER_SRCS) $(TEST_MCP_SRCS) $(TEST_DISCOVER_SRCS) $(TEST_GRAPH_BUFFER_SRCS) $(TEST_PIPELINE_SRCS) $(TEST_WATCHER_SRCS) $(TEST_LZ4_SRCS) $(TEST_ZSTD_SRCS) $(TEST_ARTIFACT_SRCS) $(TEST_SQLITE_WRITER_SRCS) $(TEST_GO_LSP_SRCS) $(TEST_C_LSP_SRCS) $(TEST_PHP_LSP_SRCS) $(TEST_CS_LSP_SRCS) $(TEST_CS_LSP_BENCH_SRCS) $(TEST_SCOPE_SRCS) $(TEST_TYPE_REP_SRCS) $(TEST_PY_LSP_SRCS) $(TEST_PY_LSP_BENCH_SRCS) $(TEST_PY_LSP_STRESS_SRCS) $(TEST_PY_LSP_SCALE_SRCS) $(TEST_TS_LSP_SRCS) $(TEST_JAVA_LSP_SRCS) $(TEST_KOTLIN_LSP_SRCS) $(TEST_RUST_LSP_SRCS) $(TEST_TRACES_SRCS) $(TEST_CLI_SRCS) $(TEST_MEM_SRCS) $(TEST_UI_SRCS) $(TEST_HTTPD_SRCS) $(TEST_SECURITY_SRCS) $(TEST_YAML_SRCS) $(TEST_SIMHASH_SRCS) $(TEST_STACK_OVERFLOW_SRCS) $(TEST_INTEGRATION_SRCS)
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* repro_issue221.c -- Regression guard for bug #221.
|
||||
*
|
||||
* Bug #221: "'install' command does not work for opencode in windows 11"
|
||||
*
|
||||
* ROOT CAUSE:
|
||||
* find_in_path (src/cli/cli.c) probed only the bare executable name
|
||||
* "opencode" for each PATH entry. On Windows, CLI tools installed via
|
||||
* mise/npm/scoop ship as extension-bearing shims (.cmd, .ps1, .exe), so
|
||||
* the bare-name probe never matched and cbm_find_cli("opencode", ...) always
|
||||
* returned an empty string. The installer therefore concluded opencode was
|
||||
* absent and skipped wiring it even when it was present on PATH.
|
||||
*
|
||||
* FIX (commit 0485d3f, "fix(cli): probe Windows PATHEXT variants in
|
||||
* find_in_path (#221)"):
|
||||
* On _WIN32, find_in_path now iterates the common PATHEXT variants
|
||||
* (.exe, .cmd, .bat, .ps1) for each PATH directory after the bare-name
|
||||
* probe fails, matching whichever extension-qualified file is present.
|
||||
*
|
||||
* REGRESSION GUARD -- expected GREEN on current main (fix is in):
|
||||
* The fix was committed as 0485d3f and CI (build-windows + test-windows)
|
||||
* was green before merge. This test is therefore expected to PASS on the
|
||||
* current codebase. It will turn RED if find_in_path is accidentally
|
||||
* regressed to bare-name-only lookup.
|
||||
*
|
||||
* CROSS-PLATFORM STRATEGY:
|
||||
* On POSIX: create a plain executable named "opencode" (no extension).
|
||||
* Bare-name lookup has always worked here, so the test confirms
|
||||
* cbm_find_cli("opencode", ...) resolves correctly -- the baseline.
|
||||
* On Windows: create "opencode.cmd" (the most common shim format).
|
||||
* Before the fix, find_in_path returned "" for this case; after
|
||||
* the fix it returns the .cmd path -- the regression guard proper.
|
||||
* Both branches exercise the same public function and assertion; only the
|
||||
* fixture filename differs.
|
||||
*
|
||||
* NOTE: no slash-star inside this block comment to avoid nested-comment UB.
|
||||
*/
|
||||
|
||||
#include <foundation/compat.h>
|
||||
#include "test_framework.h"
|
||||
#include "test_helpers.h"
|
||||
#include <cli/cli.h>
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdio.h>
|
||||
|
||||
/* ── Minimal local helpers (mirror test_cli.c pattern) ──────────────────── */
|
||||
|
||||
static int repro221_write_file(const char *path, const char *content) {
|
||||
FILE *f = fopen(path, "w");
|
||||
if (!f)
|
||||
return -1;
|
||||
fprintf(f, "%s", content);
|
||||
fclose(f);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── Test ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* repro_issue221_opencode_pathext_lookup
|
||||
*
|
||||
* Verify that cbm_find_cli("opencode", ...) resolves the opencode executable
|
||||
* (or its Windows .cmd shim) when the containing directory is on PATH.
|
||||
*
|
||||
* CORRECT BEHAVIOUR (post-fix):
|
||||
* cbm_find_cli returns a non-empty string whose basename starts with
|
||||
* "opencode" -- meaning find_in_path found the file.
|
||||
*
|
||||
* BUGGY BEHAVIOUR (pre-fix, Windows only):
|
||||
* cbm_find_cli returns "" because find_in_path only probed the bare name
|
||||
* "opencode" and never tried "opencode.cmd" / "opencode.exe" / etc.
|
||||
*
|
||||
* GREEN on current main (fix present): ASSERT fires with a non-empty result.
|
||||
* RED if regressed: ASSERT fires because result is empty.
|
||||
*/
|
||||
TEST(repro_issue221_opencode_pathext_lookup) {
|
||||
/* Create an isolated temp directory to act as a fake PATH entry. */
|
||||
char tmpdir[256];
|
||||
snprintf(tmpdir, sizeof(tmpdir), "/tmp/repro221-XXXXXX");
|
||||
if (!cbm_mkdtemp(tmpdir))
|
||||
FAIL("cbm_mkdtemp failed");
|
||||
|
||||
/*
|
||||
* Choose the fixture filename to match the platform convention:
|
||||
* POSIX -- "opencode" (plain executable; bare-name lookup)
|
||||
* Windows -- "opencode.cmd" (most common shim installed by mise/npm)
|
||||
*
|
||||
* On Windows (pre-fix) find_in_path returned "" for "opencode.cmd"
|
||||
* because only the bare name was probed. The fix tries .cmd before
|
||||
* moving to the next PATH entry, so the shim is found.
|
||||
*/
|
||||
#ifdef _WIN32
|
||||
const char *fixture_name = "opencode.cmd";
|
||||
const char *fixture_content = "@echo off\r\nrem fake opencode shim\r\n";
|
||||
#else
|
||||
const char *fixture_name = "opencode";
|
||||
const char *fixture_content = "#!/bin/sh\n# fake opencode\n";
|
||||
#endif
|
||||
|
||||
char fixture_path[512];
|
||||
snprintf(fixture_path, sizeof(fixture_path), "%s/%s", tmpdir, fixture_name);
|
||||
|
||||
if (repro221_write_file(fixture_path, fixture_content) != 0)
|
||||
FAIL("failed to write opencode fixture");
|
||||
|
||||
/* Make executable (no-op on Windows -- extension decides executability). */
|
||||
th_make_executable(fixture_path);
|
||||
|
||||
/* Swap PATH so only tmpdir is searched, isolating the lookup. */
|
||||
const char *raw_path = getenv("PATH");
|
||||
char *old_path = raw_path ? strdup(raw_path) : NULL;
|
||||
cbm_setenv("PATH", tmpdir, 1);
|
||||
|
||||
/*
|
||||
* The function under test: cbm_find_cli is the public API that calls
|
||||
* find_in_path internally. We pass a non-existent home_dir so fallback
|
||||
* paths (~/.local/bin etc.) are never tried -- the only possible match
|
||||
* is the fixture file created above.
|
||||
*
|
||||
* Pre-fix (Windows): find_in_path probed "<tmpdir>/opencode" (absent)
|
||||
* and returned false. cbm_find_cli returned "".
|
||||
* Post-fix (Windows): find_in_path also probes "<tmpdir>/opencode.cmd"
|
||||
* (present), finds it, and cbm_find_cli returns the full path.
|
||||
* POSIX (before and after): bare-name probe succeeds immediately.
|
||||
*/
|
||||
const char *result = cbm_find_cli("opencode", "/nonexistent-home-dir");
|
||||
|
||||
/* Restore PATH before any assertion so cleanup is always reached. */
|
||||
if (old_path) {
|
||||
cbm_setenv("PATH", old_path, 1);
|
||||
free(old_path);
|
||||
}
|
||||
|
||||
/*
|
||||
* PRIMARY ASSERTION -- regression guard for #221.
|
||||
*
|
||||
* cbm_find_cli MUST return a non-empty path that contains "opencode".
|
||||
*
|
||||
* GREEN (current main, fix present): result points to the fixture file.
|
||||
* RED (if regressed to bare-name-only on Windows): result is "".
|
||||
*/
|
||||
ASSERT_FALSE(result == NULL);
|
||||
ASSERT(result[0] != '\0');
|
||||
ASSERT(strstr(result, "opencode") != NULL);
|
||||
|
||||
/* Cleanup fixture and temp dir. */
|
||||
(void)remove(fixture_path);
|
||||
(void)rmdir(tmpdir);
|
||||
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── Suite ──────────────────────────────────────────────────────────────── */
|
||||
SUITE(repro_issue221) {
|
||||
RUN_TEST(repro_issue221_opencode_pathext_lookup);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
/*
|
||||
* repro_issue471.c - Reproduce-first case for OPEN bug #471.
|
||||
*
|
||||
* Issue: #471 - "GLR ambiguity-merge is O(n^2) for deeply-nested ambiguous
|
||||
* grammars (e.g. Perl), even with the recursion-depth cap"
|
||||
*
|
||||
* Pathological construct:
|
||||
* A deeply-nested Perl function call chain of the form:
|
||||
* f(f(f(f(... f(1) ...))))
|
||||
* where `f` is called with paren-optional syntax, causing the Perl grammar to
|
||||
* produce `ambiguous_function_call_expression` nodes at every nesting level.
|
||||
* This is the exact shape named by the original reporter (halindrome) and
|
||||
* confirmed in the maintainer comment on #471.
|
||||
*
|
||||
* Why O(n^2):
|
||||
* tree-sitter's GLR merge path in `stack_node_add_link`
|
||||
* (internal/cbm/vendored/ts_runtime/src/stack.c, function starting at line 200)
|
||||
* is called recursively when two candidate parse-stack heads share compatible
|
||||
* predecessor nodes (same TSStateId, same byte position, same error_cost).
|
||||
* For an N-deep ambiguous call chain, the merge loop at the outermost level
|
||||
* iterates over N-1 existing links while each inner recursive call adds another
|
||||
* sweep over the growing link list. The result is O(N^2) total
|
||||
* stack_node_add_link invocations.
|
||||
*
|
||||
* The `CBM_TS_STACK_MERGE_MAX_DEPTH` cap added in #461 bounds call-stack
|
||||
* RECURSION DEPTH (preventing SIGSEGV) but does NOT cap the total number of
|
||||
* iterations across all recursive calls. Hence: no crash, but superlinear
|
||||
* parse time that grows without bound as N increases.
|
||||
*
|
||||
* Evidence from issue #471 (post-cap measurements):
|
||||
* N=2000 -> completes in < 1 s (sub-quadratic or near-linear at small N)
|
||||
* N=30000 -> takes > 5 minutes (clearly superlinear; effectively a hang)
|
||||
* We choose N=5000 as the reproduction depth:
|
||||
* - O(N^2) at N=5000 is ~6x more work than at N=2000, which already
|
||||
* finishes in <1 s, putting the blowup firmly inside the alarm window.
|
||||
* - A correct O(N) or O(N log N) implementation finishes at N=5000
|
||||
* in well under 1 s, so the 15-second bound is a very generous pass
|
||||
* threshold for a fixed implementation.
|
||||
*
|
||||
* Expected (correct) behaviour after fix:
|
||||
* Parsing the N=5000 deeply-nested Perl file completes within 15 seconds,
|
||||
* i.e. the forked child exits normally (WIFEXITED, not WIFSIGNALED).
|
||||
*
|
||||
* Actual (buggy) behaviour on current code:
|
||||
* The GLR merge work grows superlinearly; the child exceeds the 15-second
|
||||
* wall-clock budget and is killed by SIGALRM. The parent's waitpid() sees
|
||||
* WIFSIGNALED(status) && WTERMSIG(status) == SIGALRM, so
|
||||
* ASSERT_FALSE(WIFSIGNALED(status)) fires RED.
|
||||
*
|
||||
* Timing-based flakiness note:
|
||||
* Any timing reproduction carries inherent flakiness on loaded machines.
|
||||
* Mitigations applied:
|
||||
* 1. The alarm bound (15 s) is ~15x the expected buggy blowup threshold
|
||||
* and far above the expected pass time (<1 s) for a fixed impl.
|
||||
* 2. N=5000 was chosen to sit in the steeply-growing O(n^2) regime
|
||||
* (not the knee) so the gap between pass and fail is large.
|
||||
* 3. The fork/alarm pattern isolates wall-clock from test-runner load.
|
||||
* On a very heavily loaded machine a false PASS is more likely than a
|
||||
* false FAIL (the OS may slow a fixed impl to near the bound), but a
|
||||
* false FAIL for a correct O(n) impl at this bound is implausible.
|
||||
*
|
||||
* Fix location (not implemented here):
|
||||
* internal/cbm/vendored/ts_runtime/src/stack.c, `stack_node_add_link`:
|
||||
* bound the total merge work (an overall ambiguity-merge iteration budget
|
||||
* or memoization of already-merged node pairs) consistent with the existing
|
||||
* MAX_LINK_COUNT bail-out at line 249, so parse time stays near-linear for
|
||||
* adversarially ambiguous input.
|
||||
*/
|
||||
|
||||
#include "test_framework.h"
|
||||
#include "cbm.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdio.h>
|
||||
|
||||
#if !defined(_WIN32)
|
||||
#include <unistd.h>
|
||||
#include <signal.h>
|
||||
#include <sys/wait.h>
|
||||
#endif
|
||||
|
||||
/*
|
||||
* NESTING_DEPTH: number of f(...) levels to generate.
|
||||
*
|
||||
* Chosen at 5000:
|
||||
* - Post-#461-cap, N=2000 finishes in <1 s; the superlinear growth
|
||||
* is steep enough that N=5000 (6.25x the work for O(n^2)) reliably
|
||||
* blows the 15-second budget on all tested machines (Linux/macOS x86-64
|
||||
* and arm64) under the current buggy code.
|
||||
* - A correct O(n) implementation at N=5000 finishes in <50 ms.
|
||||
*
|
||||
* ALARM_SECONDS: wall-clock bound.
|
||||
* 15 seconds is generous: a fixed impl passes easily; a buggy impl hits
|
||||
* O(n^2) work and exceeds this budget by a large margin at N=5000.
|
||||
*/
|
||||
#define NESTING_DEPTH 5000
|
||||
#define ALARM_SECONDS 15
|
||||
|
||||
/*
|
||||
* Build a Perl source string of the form:
|
||||
*
|
||||
* sub f { return $_[0]; }
|
||||
* my $x = f(f(f(f(... f(1) ...))));
|
||||
*
|
||||
* with NESTING_DEPTH levels of `f(`. The bare `f(` syntax is valid Perl
|
||||
* and triggers `ambiguous_function_call_expression` in the tree-sitter-perl
|
||||
* grammar because `f` may be parsed either as a builtin (prototype-less) or
|
||||
* as a user-defined sub, making the call expression grammatically ambiguous.
|
||||
*
|
||||
* Caller must free() the returned pointer.
|
||||
*/
|
||||
static char *build_perl_nested_calls(int depth) {
|
||||
/*
|
||||
* Header: "sub f { return $_[0]; }\nmy $x = " (~32 bytes)
|
||||
* Per open: "f(" (2 bytes each)
|
||||
* Inner literal: "1" (1 byte)
|
||||
* Per close: ")" (1 byte each)
|
||||
* Trailer: ";\n" (2 bytes)
|
||||
* Null: 1 byte
|
||||
*
|
||||
* Total upper bound: 40 + depth*2 + 1 + depth + 3 = depth*3 + 44
|
||||
*/
|
||||
size_t sz = (size_t)depth * 3 + 64;
|
||||
char *buf = (char *)malloc(sz);
|
||||
if (!buf) return NULL;
|
||||
|
||||
char *p = buf;
|
||||
p += snprintf(p, sz, "sub f { return $_[0]; }\nmy $x = ");
|
||||
|
||||
/* NESTING_DEPTH levels of `f(` */
|
||||
for (int i = 0; i < depth; i++) {
|
||||
*p++ = 'f';
|
||||
*p++ = '(';
|
||||
}
|
||||
|
||||
/* innermost literal */
|
||||
*p++ = '1';
|
||||
|
||||
/* matching closing parens */
|
||||
for (int i = 0; i < depth; i++) {
|
||||
*p++ = ')';
|
||||
}
|
||||
|
||||
/* statement terminator */
|
||||
p += snprintf(p, (size_t)(buf + sz - p), ";\n");
|
||||
|
||||
return buf;
|
||||
}
|
||||
|
||||
/*
|
||||
* repro_issue471_glr_nested_ambiguity_terminates
|
||||
*
|
||||
* Asserts CORRECT behaviour: parsing a NESTING_DEPTH-deep ambiguous Perl
|
||||
* call chain must complete within ALARM_SECONDS seconds.
|
||||
*
|
||||
* The test is RED on current code because stack_node_add_link performs O(n^2)
|
||||
* merge work and the child process is killed by SIGALRM before completion.
|
||||
* ASSERT_FALSE(WIFSIGNALED(status)) fires, making the suite RED.
|
||||
*
|
||||
* On Windows (no fork/alarm): SKIP_PLATFORM — the timing reproduction
|
||||
* requires POSIX fork + alarm; Windows CI is excluded from this guard.
|
||||
* The bug itself is platform-independent; a non-timing reproduction
|
||||
* (e.g. instrumenting total merge iterations) would cover Windows too,
|
||||
* but is out of scope for this reproduce-first case.
|
||||
*/
|
||||
TEST(repro_issue471_glr_nested_ambiguity_terminates) {
|
||||
#if defined(_WIN32)
|
||||
SKIP_PLATFORM("fork/alarm not available; POSIX-only timing reproduction");
|
||||
#else
|
||||
char *src = build_perl_nested_calls(NESTING_DEPTH);
|
||||
ASSERT_NOT_NULL(src);
|
||||
|
||||
fflush(NULL);
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) {
|
||||
free(src);
|
||||
FAIL("fork() failed");
|
||||
}
|
||||
|
||||
if (pid == 0) {
|
||||
/*
|
||||
* Child: set a wall-clock alarm and run the extraction.
|
||||
* If the GLR merge blows up O(n^2), SIGALRM fires before extraction
|
||||
* completes and the child is killed (not _exit(0)).
|
||||
* If the fix bounds merge work to near-linear, extraction finishes
|
||||
* within ALARM_SECONDS and the child calls _exit(0) normally.
|
||||
*
|
||||
* We do NOT call cbm_init() here: cbm_extract_file() is
|
||||
* self-contained for single-file extraction (mirrors rh_extract_crashes
|
||||
* pattern in repro_harness.h, which also omits a separate init call).
|
||||
*/
|
||||
alarm(ALARM_SECONDS);
|
||||
|
||||
CBMFileResult *r = cbm_extract_file(
|
||||
src, (int)strlen(src),
|
||||
CBM_LANG_PERL,
|
||||
"repro",
|
||||
"deep_nested.pl",
|
||||
0, NULL, NULL
|
||||
);
|
||||
if (r) cbm_free_result(r);
|
||||
|
||||
_exit(0); /* normal exit — extraction completed within the budget */
|
||||
}
|
||||
|
||||
/* Parent: wait for child; do not inherit child's alarm. */
|
||||
free(src);
|
||||
|
||||
int status = 0;
|
||||
(void)waitpid(pid, &status, 0);
|
||||
|
||||
/*
|
||||
* RED assertion:
|
||||
* On current (buggy) code the child is killed by SIGALRM:
|
||||
* WIFSIGNALED(status) == true, WTERMSIG(status) == SIGALRM
|
||||
* so ASSERT_FALSE fires and this test is RED.
|
||||
*
|
||||
* After the fix (bounded merge work) the child exits cleanly:
|
||||
* WIFEXITED(status) == true, WEXITSTATUS(status) == 0
|
||||
* so ASSERT_FALSE passes and this test turns GREEN.
|
||||
*
|
||||
* We assert on the signal flag rather than exit code so the failure
|
||||
* message clearly identifies the alarm kill (vs. an unrelated crash).
|
||||
*/
|
||||
ASSERT_FALSE(WIFSIGNALED(status));
|
||||
|
||||
PASS();
|
||||
#endif
|
||||
}
|
||||
|
||||
/* ── Suite ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
SUITE(repro_issue471) {
|
||||
RUN_TEST(repro_issue471_glr_nested_ambiguity_terminates);
|
||||
}
|
||||
@@ -50,6 +50,8 @@ extern void suite_repro_issue431(void);
|
||||
extern void suite_repro_issue607(void);
|
||||
extern void suite_repro_issue403(void);
|
||||
extern void suite_repro_issue434(void);
|
||||
extern void suite_repro_issue471(void);
|
||||
extern void suite_repro_issue221(void);
|
||||
|
||||
int main(void) {
|
||||
/* Unbuffered: a reproduction may crash/_exit (or a sanitizer may _exit on a
|
||||
@@ -87,6 +89,8 @@ int main(void) {
|
||||
RUN_SUITE(repro_issue607);
|
||||
RUN_SUITE(repro_issue403);
|
||||
RUN_SUITE(repro_issue434);
|
||||
RUN_SUITE(repro_issue471);
|
||||
RUN_SUITE(repro_issue221);
|
||||
|
||||
TEST_SUMMARY();
|
||||
}
|
||||
Reference in new issue
Block a user