diff --git a/internal/cbm/lsp/ts_lsp.c b/internal/cbm/lsp/ts_lsp.c index 0072aa999..0aff01c17 100644 --- a/internal/cbm/lsp/ts_lsp.c +++ b/internal/cbm/lsp/ts_lsp.c @@ -58,6 +58,10 @@ void cbm_ts_full_registry_builds_reset(void) { * constructs it does not model. -1 = unlimited (no entry point armed it). */ static _Thread_local long g_ts_type_budget = -1; static _Thread_local bool g_ts_type_budget_warned; +#ifdef CBM_ENABLE_TEST_SEAMS +static _Thread_local int g_ts_max_member_lookup_steps; +static _Thread_local int g_ts_max_method_lookup_steps; +#endif static void ts_type_budget_reset(size_t source_len) { const char *e = getenv("CBM_TS_TYPE_BUDGET"); @@ -169,6 +173,12 @@ long cbm_ts_lsp_test_budget_remaining(void) { bool cbm_ts_lsp_test_budget_warned(void) { return g_ts_type_budget_warned; } +int cbm_ts_lsp_test_max_member_lookup_steps(void) { + return g_ts_max_member_lookup_steps; +} +int cbm_ts_lsp_test_max_method_lookup_steps(void) { + return g_ts_max_method_lookup_steps; +} #endif #define TS_LSP_MAX_EVAL_DEPTH 64 @@ -189,6 +199,10 @@ static const CBMType *type_of_identifier(TSLSPContext *ctx, const char *name); static const CBMType *lookup_member_type(TSLSPContext *ctx, const CBMType *recv, const char *name); static const CBMRegisteredFunc *lookup_method(TSLSPContext *ctx, const CBMType *recv, const char *method_name); +typedef struct TSLookupState TSLookupState; +static const CBMRegisteredFunc *lookup_method_at(TSLSPContext *ctx, const CBMType *recv, + const char *method_name, int depth, + TSLookupState *state); static char *node_text(TSLSPContext *ctx, TSNode node); // Collect a node's children into an arena array via a single O(n) cursor pass. @@ -1559,28 +1573,77 @@ static const char *builtin_wrapper_class(const char *builtin_name) { } // Look up a property `name` on a receiver type. Returns the property type or UNKNOWN. -static const CBMType *lookup_member_type_inner(TSLSPContext *ctx, const CBMType *recv, - const char *name); - #define TS_LSP_MAX_MEMBER_DEPTH 64 +#define TS_LSP_INLINE_VISITED_TYPES 16 + +typedef struct { + const char *qn; + int depth; +} TSVisitedType; + +struct TSLookupState { + TSVisitedType inline_visited[TS_LSP_INLINE_VISITED_TYPES]; + TSVisitedType *visited; + int visited_count; + int visited_capacity; +#ifdef CBM_ENABLE_TEST_SEAMS + int steps; +#endif +}; + +/* A named type needs another visit only when reached along a shallower path. + * Grow the visited table rather than silently dropping entries at a fixed cap: + * the depth bound then limits each named type to at most 64 admitted visits. */ +static bool ts_lookup_visit_named(CBMArena *arena, TSLookupState *state, const char *qn, + int depth) { + for (int i = 0; i < state->visited_count; i++) { + if (strcmp(state->visited[i].qn, qn) == 0) { + if (state->visited[i].depth <= depth) + return false; + state->visited[i].depth = depth; + return true; + } + } + if (!state->visited) { + state->visited = state->inline_visited; + state->visited_capacity = TS_LSP_INLINE_VISITED_TYPES; + } else if (state->visited_count == state->visited_capacity) { + int capacity = state->visited_capacity * 2; + TSVisitedType *larger = (TSVisitedType *)cbm_arena_alloc( + arena, (size_t)capacity * sizeof(*larger)); + if (!larger) { + fprintf(stderr, "[tslsp] lookup visited allocation failed\n"); + return false; + } + memcpy(larger, state->visited, (size_t)state->visited_count * sizeof(*larger)); + state->visited = larger; + state->visited_capacity = capacity; + } + state->visited[state->visited_count++] = (TSVisitedType){.qn = qn, .depth = depth}; + return true; +} -/* Depth-guarded entry: member lookup recurses through wrapper classes, union - * members and registered-type expansion; cyclic type graphs in real-world TS - * (microsoft/TypeScript reallyLargeFile.ts) recursed without bound — SIGBUS - * stack overflow under endless lookup_member_type frames. Past the cap the - * member resolves as unknown — graceful degradation, not a crash. */ +static const CBMType *lookup_member_type_at(TSLSPContext *ctx, const CBMType *recv, + const char *name, int depth, TSLookupState *state); + +/* Depth and shallower-only named-type visits bound work on cyclic inheritance. */ static const CBMType *lookup_member_type(TSLSPContext *ctx, const CBMType *recv, const char *name) { - if (!ctx || ctx->member_depth >= TS_LSP_MAX_MEMBER_DEPTH) - return cbm_type_unknown(); - ctx->member_depth++; - const CBMType *r = lookup_member_type_inner(ctx, recv, name); - ctx->member_depth--; - return r; + TSLookupState state = {0}; + const CBMType *result = lookup_member_type_at(ctx, recv, name, 0, &state); +#ifdef CBM_ENABLE_TEST_SEAMS + if (state.steps > g_ts_max_member_lookup_steps) + g_ts_max_member_lookup_steps = state.steps; +#endif + return result; } -static const CBMType *lookup_member_type_inner(TSLSPContext *ctx, const CBMType *recv, - const char *name) { - if (!ctx || !recv || !name) +static const CBMType *lookup_member_type_at(TSLSPContext *ctx, const CBMType *recv, + const char *name, int depth, TSLookupState *state) { +#ifdef CBM_ENABLE_TEST_SEAMS + if (state) + state->steps++; +#endif + if (!ctx || !recv || !name || !state || depth >= TS_LSP_MAX_MEMBER_DEPTH) return cbm_type_unknown(); const CBMType *base = simplify_type(ctx, recv); if (!base) @@ -1594,7 +1657,7 @@ static const CBMType *lookup_member_type_inner(TSLSPContext *ctx, const CBMType const char *wrap = builtin_wrapper_class(base->data.builtin.name); if (wrap) { const CBMType *wrapped = cbm_type_named(ctx->arena, wrap); - return lookup_member_type(ctx, wrapped, name); + return lookup_member_type_at(ctx, wrapped, name, depth + 1, state); } return cbm_type_unknown(); } @@ -1649,7 +1712,10 @@ static const CBMType *lookup_member_type_inner(TSLSPContext *ctx, const CBMType // first hit instead of building a union of results.) if (base->data.union_type.members) { for (int i = 0; i < base->data.union_type.count; i++) { - const CBMType *m = lookup_member_type(ctx, base->data.union_type.members[i], name); + if (depth + 1 >= TS_LSP_MAX_MEMBER_DEPTH) + break; + const CBMType *m = lookup_member_type_at( + ctx, base->data.union_type.members[i], name, depth + 1, state); if (!cbm_type_is_unknown(m)) return m; } @@ -1660,7 +1726,10 @@ static const CBMType *lookup_member_type_inner(TSLSPContext *ctx, const CBMType if (base->kind == CBM_TYPE_INTERSECTION) { if (base->data.union_type.members) { for (int i = 0; i < base->data.union_type.count; i++) { - const CBMType *m = lookup_member_type(ctx, base->data.union_type.members[i], name); + if (depth + 1 >= TS_LSP_MAX_MEMBER_DEPTH) + break; + const CBMType *m = lookup_member_type_at( + ctx, base->data.union_type.members[i], name, depth + 1, state); if (!cbm_type_is_unknown(m)) return m; } @@ -1672,6 +1741,10 @@ static const CBMType *lookup_member_type_inner(TSLSPContext *ctx, const CBMType return cbm_type_unknown(); const char *recv_qn = base->data.named.qualified_name; + if (!recv_qn) + return cbm_type_unknown(); + if (!ts_lookup_visit_named(ctx->arena, state, recv_qn, depth)) + return cbm_type_unknown(); const CBMRegisteredType *rt = cbm_registry_resolve_alias(ctx->registry, recv_qn); if (!rt) { rt = cbm_registry_lookup_type(ctx->registry, recv_qn); @@ -1701,8 +1774,10 @@ static const CBMType *lookup_member_type_inner(TSLSPContext *ctx, const CBMType // Walk extends/implements. if (rt->embedded_types) { for (int i = 0; rt->embedded_types[i]; i++) { + if (depth + 1 >= TS_LSP_MAX_MEMBER_DEPTH) + break; const CBMType *parent = cbm_type_named(ctx->arena, rt->embedded_types[i]); - const CBMType *m = lookup_member_type(ctx, parent, name); + const CBMType *m = lookup_member_type_at(ctx, parent, name, depth + 1, state); if (!cbm_type_is_unknown(m)) return m; } @@ -1788,7 +1863,24 @@ static const CBMType *eval_indexed_access(TSLSPContext *ctx, const CBMType *obj, // Look up a method on a receiver type — returns the registered func. static const CBMRegisteredFunc *lookup_method(TSLSPContext *ctx, const CBMType *recv, const char *method_name) { - if (!ctx || !recv || !method_name) + TSLookupState state = {0}; + const CBMRegisteredFunc *result = lookup_method_at(ctx, recv, method_name, 0, &state); +#ifdef CBM_ENABLE_TEST_SEAMS + if (state.steps > g_ts_max_method_lookup_steps) + g_ts_max_method_lookup_steps = state.steps; +#endif + return result; +} + +/* Depth and shallower-only named-type visits bound cyclic inheritance work. */ +static const CBMRegisteredFunc *lookup_method_at(TSLSPContext *ctx, const CBMType *recv, + const char *method_name, int depth, + TSLookupState *state) { +#ifdef CBM_ENABLE_TEST_SEAMS + if (state) + state->steps++; +#endif + if (!ctx || !recv || !method_name || !state || depth >= TS_LSP_MAX_MEMBER_DEPTH) return NULL; const CBMType *base = simplify_type(ctx, recv); if (!base) @@ -1802,7 +1894,7 @@ static const CBMRegisteredFunc *lookup_method(TSLSPContext *ctx, const CBMType * const char *wrap = builtin_wrapper_class(base->data.builtin.name); if (wrap) { const CBMType *wrapped = cbm_type_named(ctx->arena, wrap); - return lookup_method(ctx, wrapped, method_name); + return lookup_method_at(ctx, wrapped, method_name, depth + 1, state); } return NULL; } @@ -1831,8 +1923,10 @@ static const CBMRegisteredFunc *lookup_method(TSLSPContext *ctx, const CBMType * if (base->kind == CBM_TYPE_UNION || base->kind == CBM_TYPE_INTERSECTION) { if (base->data.union_type.members) { for (int i = 0; i < base->data.union_type.count; i++) { - const CBMRegisteredFunc *f = - lookup_method(ctx, base->data.union_type.members[i], method_name); + if (depth + 1 >= TS_LSP_MAX_MEMBER_DEPTH) + break; + const CBMRegisteredFunc *f = lookup_method_at( + ctx, base->data.union_type.members[i], method_name, depth + 1, state); if (f) return f; } @@ -1843,6 +1937,11 @@ static const CBMRegisteredFunc *lookup_method(TSLSPContext *ctx, const CBMType * if (base->kind != CBM_TYPE_NAMED) return NULL; const char *recv_qn = base->data.named.qualified_name; + if (!recv_qn) + return NULL; + + if (!ts_lookup_visit_named(ctx->arena, state, recv_qn, depth)) + return NULL; const CBMRegisteredFunc *f = cbm_registry_lookup_method_aliased(ctx->registry, recv_qn, method_name); @@ -1853,8 +1952,11 @@ static const CBMRegisteredFunc *lookup_method(TSLSPContext *ctx, const CBMType * const CBMRegisteredType *rt = cbm_registry_lookup_type(ctx->registry, recv_qn); if (rt && rt->embedded_types) { for (int i = 0; rt->embedded_types[i]; i++) { + if (depth + 1 >= TS_LSP_MAX_MEMBER_DEPTH) + break; const CBMType *parent = cbm_type_named(ctx->arena, rt->embedded_types[i]); - const CBMRegisteredFunc *pf = lookup_method(ctx, parent, method_name); + const CBMRegisteredFunc *pf = + lookup_method_at(ctx, parent, method_name, depth + 1, state); if (pf) return pf; } @@ -3791,6 +3893,10 @@ void ts_lsp_init(TSLSPContext *ctx, CBMArena *arena, const char *source, int sou bool jsx_mode, bool dts_mode, CBMResolvedCallArray *out) { if (!ctx) return; +#ifdef CBM_ENABLE_TEST_SEAMS + g_ts_max_member_lookup_steps = 0; + g_ts_max_method_lookup_steps = 0; +#endif memset(ctx, 0, sizeof(TSLSPContext)); ctx->arena = arena; ctx->source = source; diff --git a/internal/cbm/lsp/ts_lsp.h b/internal/cbm/lsp/ts_lsp.h index c34ceb4ad..1096bea90 100644 --- a/internal/cbm/lsp/ts_lsp.h +++ b/internal/cbm/lsp/ts_lsp.h @@ -65,10 +65,6 @@ typedef struct { // first completed eval; see TsEvalMemo in ts_lsp.c). Kills the exponential // re-evaluation of shared subexpressions under overload resolution. struct TsEvalMemo *eval_memo; - // Recursion guard for lookup_member_type: cyclic type graphs (mutually - // recursive unions/wrappers across registered types) otherwise recurse - // without bound — stack overflow on real repos. - int member_depth; } TSLSPContext; #ifdef CBM_ENABLE_TEST_SEAMS @@ -76,6 +72,10 @@ typedef struct { // for the calling thread, valid after a cbm_run_ts_lsp on the same thread. long cbm_ts_lsp_test_budget_remaining(void); bool cbm_ts_lsp_test_budget_warned(void); +// Maximum recursive calls in one member/method lookup during the last TS LSP run, +// including attempts rejected by the depth or visited-type guard. +int cbm_ts_lsp_test_max_member_lookup_steps(void); +int cbm_ts_lsp_test_max_method_lookup_steps(void); #endif // --- Initialization --- diff --git a/tests/test_stack_overflow.c b/tests/test_stack_overflow.c index 9b646296c..942ee8e93 100644 --- a/tests/test_stack_overflow.c +++ b/tests/test_stack_overflow.c @@ -12,7 +12,9 @@ #include "test_framework.h" #include "cbm.h" #include "lang_specs.h" /* cbm_ts_language — direct-parse GLR cap regression (#913) */ +#include "lsp/ts_lsp.h" +#include #include #include #include @@ -399,11 +401,11 @@ TEST(cpp_large_templated_header_no_crash_issue424) { * java_resolve_calls_in_node frames (bind_lambda_args), bitcoin → SIGSEGV * under deep c_resolve_calls_in_node frames (cbm_type_substitute via * c_adl_resolve), microsoft/TypeScript → SIGBUS under an unbounded - * lookup_member_type cycle. The walks now carry depth guards; these - * reproductions fork a child so a regression cannot kill the test runner - * (the TS cyclic-type shape is only reachable with a real cross-file - * registry, so that one is verified at the real-repo tier; the synthetic - * cyclic fixture here guards the in-file path). + * lookup_member_type cycle. The walks now carry depth guards. Non-TS crash + * fixtures fork on POSIX and run in-process on Windows. TypeScript cycle work + * runs in-process on both platforms to assert test-only lookup counters. + * The synthetic single-file fixtures here guard the local inheritance path; + * real-repo scale extraction is checked separately. * ═══════════════════════════════════════════════════════════════════ */ #if !defined(_WIN32) @@ -411,9 +413,8 @@ TEST(cpp_large_templated_header_no_crash_issue424) { #include #endif -/* Run cbm_extract_file in a forked child; true if the child died by signal. - * Mirrors tests/test_lang_contract.c. On Windows run in-process (a genuine - * crash there aborts the runner — hard, visible failure). */ +/* Run cbm_extract_file in a forked child; true if the child died by signal or + * exited non-zero. On Windows run in-process (a genuine crash aborts the runner). */ static bool so_extract_crashes(const char *content, CBMLanguage lang, const char *relpath) { #if defined(_WIN32) CBMFileResult *r = @@ -426,7 +427,7 @@ static bool so_extract_crashes(const char *content, CBMLanguage lang, const char fflush(NULL); pid_t pid = fork(); if (pid < 0) { - return false; + return true; } if (pid == 0) { CBMFileResult *r = @@ -437,8 +438,18 @@ static bool so_extract_crashes(const char *content, CBMLanguage lang, const char _exit(0); } int status = 0; - (void)waitpid(pid, &status, 0); - return WIFSIGNALED(status); + pid_t waited; + do { + waited = waitpid(pid, &status, 0); + } while (waited < 0 && errno == EINTR); + if (waited != pid) { + (void)kill(pid, SIGKILL); + do { + waited = waitpid(pid, &status, 0); + } while (waited < 0 && errno == EINTR); + return true; + } + return WIFSIGNALED(status) || (WIFEXITED(status) && WEXITSTATUS(status) != 0); #endif } @@ -670,9 +681,104 @@ TEST(lsp_ts_cyclic_types_no_crash) { "interface D extends C { d: number; }\n" "declare const a: A;\n" "declare const c: C;\n" - "function useIt(p: C) { return p.missing_member; }\n" + /* Both lookup paths must traverse the inheritance cycle. */ + "function useIt(p: C) { p.missing(); return p.missing_member; }\n" "const y = c.also_missing;\n"; - ASSERT_FALSE(so_extract_crashes(src, CBM_LANG_TYPESCRIPT, "cycle.ts")); + CBMFileResult *r = extract(src, CBM_LANG_TYPESCRIPT, "test", "cycle.ts"); + ASSERT_NOT_NULL(r); + /* C and D each have one parent: <= 64 admitted visits per type, plus + * one attempted (possibly rejected) parent visit per admitted visit. */ + ASSERT_GT(cbm_ts_lsp_test_max_member_lookup_steps(), 0); + ASSERT_GT(cbm_ts_lsp_test_max_method_lookup_steps(), 0); + ASSERT_LTE(cbm_ts_lsp_test_max_member_lookup_steps(), 2 * 64 * 2); + ASSERT_LTE(cbm_ts_lsp_test_max_method_lookup_steps(), 2 * 64 * 2); + cbm_free_result(r); + PASS(); +} + +TEST(lsp_ts_branching_cycle_has_bounded_work) { + const char *src = "interface A extends B, C {}\n" + "interface B extends A, C {}\n" + "interface C extends A, B {}\n" + "function useIt(value: A) { value.missing(); return value.missing_member; }\n"; + CBMFileResult *r = extract(src, CBM_LANG_TYPESCRIPT, "test", "branching-cycle.ts"); + ASSERT_NOT_NULL(r); + /* A, B, C each have two parents: <= 64 admitted visits per type, plus + * two attempted (possibly rejected) parent visits per admitted visit. */ + ASSERT_GT(cbm_ts_lsp_test_max_member_lookup_steps(), 0); + ASSERT_GT(cbm_ts_lsp_test_max_method_lookup_steps(), 0); + ASSERT_LTE(cbm_ts_lsp_test_max_member_lookup_steps(), 3 * 64 * 3); + ASSERT_LTE(cbm_ts_lsp_test_max_method_lookup_steps(), 3 * 64 * 3); + cbm_free_result(r); + PASS(); +} + +TEST(lsp_ts_shallower_revisit_preserves_inherited_method) { + enum { CHAIN = 62, CAP = 16384 }; + char *src = malloc(CAP); + ASSERT_NOT_NULL(src); + size_t used = (size_t)snprintf(src, CAP, + "interface Service { ping(): void; }\n" + "interface Target { inherited(): void; inheritedField: Service; }\n" + "interface Shared extends Target {}\n" + "interface Root extends D0, Shared {}\n"); + for (int i = 0; i < CHAIN; i++) { + const char *parent = i + 1 < CHAIN ? "D" : "Shared"; + int wrote = i + 1 < CHAIN + ? snprintf(src + used, CAP - used, "interface D%d extends %s%d {}\n", i, + parent, i + 1) + : snprintf(src + used, CAP - used, "interface D%d extends Shared {}\n", i); + ASSERT_TRUE(wrote > 0 && (size_t)wrote < CAP - used); + used += (size_t)wrote; + } + int wrote = snprintf( + src + used, CAP - used, + "function useIt(value: Root) { value.inherited(); value.inheritedField.ping(); }\n"); + ASSERT_TRUE(wrote > 0 && (size_t)wrote < CAP - used); + + CBMFileResult *r = extract(src, CBM_LANG_TYPESCRIPT, "test", "shallower.ts"); + free(src); + ASSERT_NOT_NULL(r); + int inherited_method_found = 0; + int inherited_field_method_found = 0; + for (int i = 0; i < r->resolved_calls.count; i++) { + const CBMResolvedCall *resolved = &r->resolved_calls.items[i]; + if (resolved->confidence > 0 && resolved->caller_qn && + strstr(resolved->caller_qn, "useIt") && resolved->callee_qn && + strstr(resolved->callee_qn, "inherited")) { + inherited_method_found = 1; + } + if (resolved->confidence > 0 && resolved->caller_qn && + strstr(resolved->caller_qn, "useIt") && resolved->callee_qn && + strstr(resolved->callee_qn, "ping")) { + inherited_field_method_found = 1; + } + } + cbm_free_result(r); + ASSERT_TRUE(inherited_method_found); + ASSERT_TRUE(inherited_field_method_found); + PASS(); +} + +TEST(lsp_ts_inherited_method_lookup) { + const char *src = "interface Base { inherited(): void; }\n" + "interface Child extends Base {}\n" + "function useIt(c: Child) { c.inherited(); }\n"; + CBMFileResult *r = extract(src, CBM_LANG_TYPESCRIPT, "test", "inherited.ts"); + ASSERT_NOT_NULL(r); + + int found = 0; + for (int i = 0; i < r->resolved_calls.count; i++) { + const CBMResolvedCall *resolved = &r->resolved_calls.items[i]; + if (resolved->confidence > 0 && resolved->caller_qn && + strstr(resolved->caller_qn, "useIt") && resolved->callee_qn && + strstr(resolved->callee_qn, "inherited")) { + found = 1; + break; + } + } + cbm_free_result(r); + ASSERT_TRUE(found); PASS(); } @@ -777,9 +883,8 @@ TEST(lsp_kotlin_deep_nesting_no_crash) { /* Split into three sub-suites so parallel/sharded runs are not serialized * behind one ~4-minute suite (it was the wall-clock critical path: every - * other suite finished underneath it). Pure re-registration — the 20 - * RUN_TEST entries are exactly the ones the single suite carried; the - * before/after test-count parity is asserted in the shard runner. */ + * other suite finished underneath it). New regressions stay in the matching + * shard; the shard runner asserts aggregate test-count parity. */ SUITE(stack_overflow_a) { cbm_init(); @@ -799,6 +904,9 @@ SUITE(stack_overflow_b) { RUN_TEST(perl_glr_deep_parse_recursion_capped); RUN_TEST(lsp_ts_cyclic_types_no_crash); + RUN_TEST(lsp_ts_branching_cycle_has_bounded_work); + RUN_TEST(lsp_ts_shallower_revisit_preserves_inherited_method); + RUN_TEST(lsp_ts_inherited_method_lookup); RUN_TEST(lsp_python_deep_nesting_no_crash); RUN_TEST(lsp_go_deep_nesting_no_crash); RUN_TEST(lsp_php_deep_nesting_no_crash);