diff --git a/Makefile.cbm b/Makefile.cbm index a911b250d..6ce4f79c6 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -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) diff --git a/tests/repro/repro_issue221.c b/tests/repro/repro_issue221.c new file mode 100644 index 000000000..cb4d27fd4 --- /dev/null +++ b/tests/repro/repro_issue221.c @@ -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 +#include "test_framework.h" +#include "test_helpers.h" +#include + +#include +#include +#include + +/* ── 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 "/opencode" (absent) + * and returned false. cbm_find_cli returned "". + * Post-fix (Windows): find_in_path also probes "/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); +} diff --git a/tests/repro/repro_issue471.c b/tests/repro/repro_issue471.c new file mode 100644 index 000000000..94f48f00f --- /dev/null +++ b/tests/repro/repro_issue471.c @@ -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 +#include +#include + +#if !defined(_WIN32) +#include +#include +#include +#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); +} diff --git a/tests/repro/repro_main.c b/tests/repro/repro_main.c index 5f3abdac8..719a0b31a 100644 --- a/tests/repro/repro_main.c +++ b/tests/repro/repro_main.c @@ -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(); }