Skip to content

Commit 63ec1a7

Browse files
authored
Merge pull request #930 from DeusData/fix/ts-scale-parallel
fix: TypeScript-at-scale indexing cascade + parallel-only crash recovery
2 parents ce23ce7 + 7b8b703 commit 63ec1a7

16 files changed

Lines changed: 957 additions & 272 deletions

internal/cbm/cbm.c

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -533,25 +533,41 @@ static int count_params_from_signature(const char *sig) {
533533
* or spins forever (an external-scanner infinite loop the quiet-timeout kills).
534534
* This gives an honest guard — green iff the supervisor actually contains a real
535535
* fault — instead of a fixture that may stop faulting once a root cause is fixed. */
536-
/* Crash-supervisor per-file marker (Stage 3c skip-and-continue). In the
537-
* supervisor's single-threaded recovery re-run, cbm_extract_file records the
538-
* file it is ABOUT to process here (CBM_INDEX_MARKER_FILE) before touching it,
539-
* so that if this file hard-crashes the worker the parent can read the marker
540-
* back and learn the EXACT crasher to quarantine. Only meaningful single-
541-
* threaded (parallel would race the marker); the env var is set solely by the
542-
* supervisor during recovery, so it is a no-op on every normal run. */
543-
static void cbm_index_write_marker(const char *rel_path) {
536+
/* Crash-supervisor per-file marker JOURNAL (Stage 3c skip-and-continue,
537+
* parallel-safe). Recovery re-runs are PARALLEL (there are no sequential
538+
* production runs), so a single overwrite-style marker would race across
539+
* workers and — worse — go stale during non-extract phases, blaming
540+
* whatever file was extracted LAST (that mis-quarantined four innocent
541+
* ms-typescript fixtures, one 15-minute retry at a time). Instead every
542+
* worker APPENDS one short line per event: "S <rel_path>" when it STARTS
543+
* work on a file, "D <rel_path>" when it finishes it. A single short
544+
* append of one line is atomic in practice on every target platform, and
545+
* the parent discards a torn final line by design. The parent's suspect
546+
* set after a crash/hang = files with an S but no D — exactly the
547+
* in-flight set; a file is only quarantined after appearing in the
548+
* suspect set of TWO CONSECUTIVE failed runs, so a stale or merely
549+
* unlucky in-flight file is never quarantined alone. The env var is set
550+
* solely by the supervisor during recovery — a no-op on normal runs. */
551+
static void cbm_index_mark(const char *rel_path, char event) {
544552
const char *mf = getenv("CBM_INDEX_MARKER_FILE");
545553
if (!mf || !mf[0] || !rel_path || !rel_path[0]) {
546554
return;
547555
}
548-
FILE *f = cbm_fopen(mf, "wb");
556+
FILE *f = cbm_fopen(mf, "ab");
549557
if (f) {
550-
(void)fputs(rel_path, f);
558+
(void)fprintf(f, "%c %s\n", event, rel_path);
551559
(void)fclose(f);
552560
}
553561
}
554562

563+
void cbm_index_mark_start(const char *rel_path) {
564+
cbm_index_mark(rel_path, 'S');
565+
}
566+
567+
void cbm_index_mark_done(const char *rel_path) {
568+
cbm_index_mark(rel_path, 'D');
569+
}
570+
555571
/* ── Crash-quarantine set (Stage 3c skip-and-continue) ──────────────────────
556572
* After a crash the supervisor re-runs the worker single-threaded, passing
557573
* CBM_INDEX_QUARANTINE_FILE — a newline-delimited list of repo-relative paths
@@ -667,9 +683,29 @@ static void cbm_test_fault_inject(const char *rel_path) {
667683
}
668684
}
669685

686+
static CBMFileResult *cbm_extract_file_impl(const char *source, int source_len,
687+
CBMLanguage language, const char *project,
688+
const char *rel_path, int64_t timeout_micros,
689+
const char **extra_defines, const char **include_paths);
690+
691+
/* Public entry: run the extraction and journal completion. The DONE mark on
692+
* every ordinary return (including error/timeout results) tells the crash
693+
* supervisor this file did NOT kill the worker — only a file whose S has no
694+
* D is a crash/hang suspect. */
670695
CBMFileResult *cbm_extract_file(const char *source, int source_len, CBMLanguage language,
671696
const char *project, const char *rel_path, int64_t timeout_micros,
672697
const char **extra_defines, const char **include_paths) {
698+
CBMFileResult *r = cbm_extract_file_impl(source, source_len, language, project, rel_path,
699+
timeout_micros, extra_defines, include_paths);
700+
cbm_index_mark_done(rel_path);
701+
return r;
702+
}
703+
704+
static CBMFileResult *cbm_extract_file_impl(const char *source, int source_len,
705+
CBMLanguage language, const char *project,
706+
const char *rel_path, int64_t timeout_micros,
707+
const char **extra_defines,
708+
const char **include_paths) {
673709
// Allocate result on heap (arena inside for all string data)
674710
enum { SINGLE = 1 };
675711
CBMFileResult *result = (CBMFileResult *)calloc(SINGLE, sizeof(CBMFileResult));
@@ -691,7 +727,7 @@ CBMFileResult *cbm_extract_file(const char *source, int source_len, CBMLanguage
691727
return result;
692728
}
693729

694-
cbm_index_write_marker(rel_path);
730+
cbm_index_mark_start(rel_path);
695731
cbm_test_fault_inject(rel_path);
696732

697733
// Get language spec

internal/cbm/cbm.h

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,15 @@ bool cbm_index_is_quarantined(const char *rel_path);
532532
// extract loops to report the skip's phase in skipped[] (falls back to "crash").
533533
const char *cbm_index_quarantine_phase(const char *rel_path);
534534

535+
// Crash-supervisor marker journal (parallel-safe): appends "S <rel_path>" /
536+
// "D <rel_path>" to CBM_INDEX_MARKER_FILE. Files with an S but no D form the
537+
// parent's crash/hang suspect set. No-ops when the env var is unset.
538+
// cbm_extract_file journals its own start/done; long-running per-file phases
539+
// (cross-LSP resolve) call these around their per-file work so a hang there
540+
// is attributed to the RIGHT file instead of a stale extraction marker.
541+
void cbm_index_mark_start(const char *rel_path);
542+
void cbm_index_mark_done(const char *rel_path);
543+
535544
// Extract all data from one file. Caller must call cbm_free_result().
536545
// source must remain valid for the duration of the call.
537546
// timeout_micros: per-file parse timeout in microseconds (0 = no timeout).

internal/cbm/extract_defs.c

Lines changed: 65 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5691,6 +5691,58 @@ static void wd_push(wd_stack_t *s, TSNode node, const char *enclosing_qn) {
56915691
s->data[s->top++] = (walk_defs_frame_t){node, enclosing_qn};
56925692
}
56935693

5694+
/* Push all children of `node` in REVERSE order (so they pop in source order)
5695+
* using a LINEAR traversal. Index-based ts_node_child(i) is O(i) per call in
5696+
* tree-sitter, so a reverse index loop is O(n^2) per node — a file whose
5697+
* root has ~580k flat siblings (ms-typescript's reallyLargeFile.ts fourslash
5698+
* fixture) needed ~1.7e11 child-iterator steps and hung extraction for
5699+
* hours; the supervisor then killed the silent worker as a hang and
5700+
* quarantined innocent files off the stale marker. Collect the children
5701+
* FORWARD with a TSTreeCursor (O(1) amortized per step) into a scratch
5702+
* buffer, then push in reverse. Small nodes keep the direct index loop —
5703+
* no cursor/buffer overhead on the overwhelmingly common case. */
5704+
enum { WD_CURSOR_MIN_CHILDREN = 64 };
5705+
5706+
/* Collect all `cc` children of `node` linearly via a TSTreeCursor into a
5707+
* malloc'd array (caller frees). Returns NULL for small nodes and on OOM —
5708+
* the caller then uses indexed ts_node_child access, which is fine (and
5709+
* cheaper) at small child counts and merely quadratic-but-correct on OOM. */
5710+
static TSNode *wd_collect_children(TSNode node, uint32_t cc) {
5711+
if (cc < WD_CURSOR_MIN_CHILDREN) {
5712+
return NULL;
5713+
}
5714+
TSNode *buf = (TSNode *)malloc((size_t)cc * sizeof(TSNode));
5715+
if (!buf) {
5716+
return NULL;
5717+
}
5718+
TSTreeCursor cur = ts_tree_cursor_new(node);
5719+
uint32_t got = 0;
5720+
if (ts_tree_cursor_goto_first_child(&cur)) {
5721+
do {
5722+
buf[got++] = ts_tree_cursor_current_node(&cur);
5723+
} while (got < cc && ts_tree_cursor_goto_next_sibling(&cur));
5724+
}
5725+
ts_tree_cursor_delete(&cur);
5726+
if (got != cc) {
5727+
/* Defensive: cursor and child_count disagree — fall back to indexed. */
5728+
free(buf);
5729+
return NULL;
5730+
}
5731+
return buf;
5732+
}
5733+
5734+
static void wd_push_children_reverse(wd_stack_t *s, TSNode node, const char *enclosing_qn) {
5735+
uint32_t cc = ts_node_child_count(node);
5736+
if (cc == 0) {
5737+
return;
5738+
}
5739+
TSNode *kids = wd_collect_children(node, cc);
5740+
for (int i = (int)cc - SKIP_CHAR; i >= 0; i--) {
5741+
wd_push(s, kids ? kids[i] : ts_node_child(node, (uint32_t)i), enclosing_qn);
5742+
}
5743+
free(kids);
5744+
}
5745+
56945746
// Push nested class nodes from a class body container onto the defs stack.
56955747
// Iteratively walks into wrapper nodes (field_declaration, template_declaration).
56965748
static void push_nested_class_nodes(TSNode body, const CBMLangSpec *spec, wd_stack_t *s,
@@ -5702,8 +5754,10 @@ static void push_nested_class_nodes(TSNode body, const CBMLangSpec *spec, wd_sta
57025754
while (nc_stack.count > 0) {
57035755
TSNode cur = ts_nstack_pop(&nc_stack);
57045756
uint32_t nc = ts_node_child_count(cur);
5757+
/* Linear child access for wide class bodies (see wd_collect_children). */
5758+
TSNode *kids = wd_collect_children(cur, nc);
57055759
for (int i = (int)nc - SKIP_CHAR; i >= 0; i--) {
5706-
TSNode child = ts_node_child(cur, (uint32_t)i);
5760+
TSNode child = kids ? kids[i] : ts_node_child(cur, (uint32_t)i);
57075761
if (cbm_kind_in_set(child, spec->class_node_types)) {
57085762
wd_push(s, child, enclosing_qn);
57095763
} else {
@@ -5714,6 +5768,7 @@ static void push_nested_class_nodes(TSNode body, const CBMLangSpec *spec, wd_sta
57145768
}
57155769
}
57165770
}
5771+
free(kids);
57175772
}
57185773
}
57195774

@@ -6220,10 +6275,7 @@ static void walk_defs(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec,
62206275
// resolve a null name and, for grammars where the kind has a `name`
62216276
// field, double-mint). Push children so nested tags/defs are still
62226277
// traversed, then skip the generic func path.
6223-
uint32_t cc = ts_node_child_count(node);
6224-
for (int i = (int)cc - SKIP_CHAR; i >= 0; i--) {
6225-
wd_push(&s, ts_node_child(node, (uint32_t)i), frame.enclosing_class_qn);
6226-
}
6278+
wd_push_children_reverse(&s, node, frame.enclosing_class_qn);
62276279
continue;
62286280
}
62296281

@@ -6234,10 +6286,7 @@ static void walk_defs(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec,
62346286
// generic extract_func_def below would double-mint a def whose name
62356287
// still carries the quotes. Push children so nested defines are still
62366288
// traversed, then skip the generic func path.
6237-
uint32_t cc = ts_node_child_count(node);
6238-
for (int i = (int)cc - SKIP_CHAR; i >= 0; i--) {
6239-
wd_push(&s, ts_node_child(node, (uint32_t)i), frame.enclosing_class_qn);
6240-
}
6289+
wd_push_children_reverse(&s, node, frame.enclosing_class_qn);
62416290
continue;
62426291
}
62436292

@@ -6262,10 +6311,7 @@ static void walk_defs(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec,
62626311
(strcmp(kind, "export_item") == 0 || strcmp(kind, "import_item") == 0) &&
62636312
ts_node_is_null(
62646313
find_first_descendant_by_kind(node, "func_type", CBM_DESCENDANT_MAX_DEPTH))) {
6265-
uint32_t cc = ts_node_child_count(node);
6266-
for (int i = (int)cc - SKIP_CHAR; i >= 0; i--) {
6267-
wd_push(&s, ts_node_child(node, (uint32_t)i), frame.enclosing_class_qn);
6268-
}
6314+
wd_push_children_reverse(&s, node, frame.enclosing_class_qn);
62696315
continue;
62706316
}
62716317

@@ -6303,10 +6349,7 @@ static void walk_defs(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec,
63036349
* the class/func paths on the namespace node itself. */
63046350
if (is_namespace_scope_kind(ctx->language, kind)) {
63056351
const char *new_enclosing = compute_class_qn(ctx, node, frame.enclosing_class_qn);
6306-
uint32_t nsc = ts_node_child_count(node);
6307-
for (int i = (int)nsc - SKIP_CHAR; i >= 0; i--) {
6308-
wd_push(&s, ts_node_child(node, (uint32_t)i), new_enclosing);
6309-
}
6352+
wd_push_children_reverse(&s, node, new_enclosing);
63106353
continue;
63116354
}
63126355

@@ -6317,10 +6360,11 @@ static void walk_defs(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec,
63176360
continue;
63186361
}
63196362

6320-
uint32_t count = ts_node_child_count(node);
6321-
for (int i = (int)count - SKIP_CHAR; i >= 0; i--) {
6322-
wd_push(&s, ts_node_child(node, (uint32_t)i), frame.enclosing_class_qn);
6323-
}
6363+
/* Default descent — THE hot loop: a file whose root has hundreds of
6364+
* thousands of flat siblings (580k comment lines in ms-typescript's
6365+
* reallyLargeFile.ts) lands here every visit, so linear child
6366+
* collection is mandatory (see wd_push_children_reverse). */
6367+
wd_push_children_reverse(&s, node, frame.enclosing_class_qn);
63246368
}
63256369
free(s.data);
63266370
}

internal/cbm/lsp/ts_lsp.c

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,49 @@
2727
*/
2828

2929
#include "ts_lsp.h"
30+
#include <stdatomic.h>
3031
#include <stdio.h>
3132
#include <stdlib.h>
3233
#include <string.h>
3334

35+
/* TEST HOOK: full per-file cross-registry builds (stdlib + every cross-file
36+
* def registered + finalized, per file). This is the sequential-mode quadratic
37+
* that ground an 81k-file TS corpus for hours — the shared-registry dispatch
38+
* must keep it at ZERO; any nonzero count means a resolve path regressed to
39+
* the per-file build. */
40+
static _Atomic long g_ts_full_reg_builds;
41+
long cbm_ts_full_registry_builds(void) {
42+
return atomic_load(&g_ts_full_reg_builds);
43+
}
44+
void cbm_ts_full_registry_builds_reset(void) {
45+
atomic_store(&g_ts_full_reg_builds, 0);
46+
}
47+
48+
/* Dynamic work budget for parse_ts_type_text (thread-local, reset at every
49+
* per-file/per-build entry point). The type-text parser is self-recursive
50+
* over string SLICES and re-derives lengths per call, so an adversarial or
51+
* generated type text can cost far more than its bytes suggest. Instead of
52+
* a fixed depth cap, the total work SCALES WITH THE INPUT: budget =
53+
* 1M + 64 x source_len units, each call charging 1 + slice_len/16 — i.e.
54+
* total scanned bytes are bounded at ~1024x the source size, generous
55+
* enough that no real-world file ever hits it. CBM_TS_TYPE_BUDGET (absolute
56+
* units) overrides. On exhaustion: WARN once per window, then return
57+
* UNKNOWN — the same graceful degradation the parser already uses for
58+
* constructs it does not model. -1 = unlimited (no entry point armed it). */
59+
static _Thread_local long g_ts_type_budget = -1;
60+
static _Thread_local bool g_ts_type_budget_warned;
61+
62+
static void ts_type_budget_reset(size_t source_len) {
63+
const char *e = getenv("CBM_TS_TYPE_BUDGET");
64+
if (e && e[0]) {
65+
long v = atol(e);
66+
g_ts_type_budget = (v > 0) ? v : -1;
67+
} else {
68+
g_ts_type_budget = 1000000 + (long)source_len * 64;
69+
}
70+
g_ts_type_budget_warned = false;
71+
}
72+
3473
#define TS_LSP_MAX_EVAL_DEPTH 64
3574
#define TS_LSP_FIELD_LEN(s) ((uint32_t)(sizeof(s) - 1))
3675

@@ -156,6 +195,22 @@ static const CBMType *parse_ts_type_text(CBMArena *arena, const char *text, cons
156195
if (len == 0)
157196
return cbm_type_unknown();
158197

198+
/* Dynamic budget (see g_ts_type_budget): charge proportional to this
199+
* slice; on exhaustion degrade to UNKNOWN instead of grinding. */
200+
if (g_ts_type_budget >= 0) {
201+
g_ts_type_budget -= 1 + (long)(len / 16);
202+
if (g_ts_type_budget < 0) {
203+
if (!g_ts_type_budget_warned) {
204+
g_ts_type_budget_warned = true;
205+
fprintf(stderr,
206+
" [tslsp] type-text parse budget exhausted (module %s); "
207+
"returning unknown\n",
208+
module_qn ? module_qn : "?");
209+
}
210+
return cbm_type_unknown();
211+
}
212+
}
213+
159214
// Function type `(params) => returnType` (TS function type literal).
160215
if (text[0] == '(') {
161216
int depth = 0;
@@ -4784,6 +4839,7 @@ void cbm_run_ts_lsp(CBMArena *arena, CBMFileResult *result, const char *source,
47844839
TSNode root, bool js_mode, bool jsx_mode, bool dts_mode) {
47854840
if (!arena || !result || !source || ts_node_is_null(root))
47864841
return;
4842+
ts_type_budget_reset((size_t)source_len);
47874843

47884844
// Diagnostic / benchmarking knob: setting `CBM_LSP_DISABLED=1` skips the resolver.
47894845
// This is used by the baseline-vs-LSP comparison tests to measure how many calls
@@ -4977,6 +5033,8 @@ CBMTypeRegistry *cbm_ts_build_cross_registry(CBMArena *arena, CBMLSPDef *defs, i
49775033
return NULL;
49785034
cbm_registry_init(reg, arena);
49795035
cbm_ts_stdlib_register(reg, arena);
5036+
/* Budget scales with the def volume this build parses type texts for. */
5037+
ts_type_budget_reset((size_t)(def_count > 0 ? def_count : 1) * 1024);
49805038
for (int i = 0; i < def_count; i++) {
49815039
CBMLSPDef *d = &defs[i];
49825040
if (d->lang != CBM_LANG_JAVASCRIPT && d->lang != CBM_LANG_TYPESCRIPT &&
@@ -5006,6 +5064,8 @@ void cbm_run_ts_lsp_cross_with_registry(CBMArena *arena, const char *source, int
50065064
if (!arena || !out || !reg)
50075065
return;
50085066

5067+
ts_type_budget_reset((size_t)source_len);
5068+
50095069
/* Per-file overlay: register only the file's own-module defs so the
50105070
* AST passes can refine them. Imports/stdlib resolve via fallback. */
50115071
CBMTypeRegistry overlay;
@@ -5075,6 +5135,8 @@ void cbm_run_ts_lsp_cross(CBMArena *arena, const char *source, int source_len,
50755135
if (!arena || !out)
50765136
return;
50775137

5138+
atomic_fetch_add(&g_ts_full_reg_builds, 1);
5139+
ts_type_budget_reset((size_t)source_len);
50785140
CBMTypeRegistry reg;
50795141
cbm_registry_init(&reg, arena);
50805142
cbm_ts_stdlib_register(&reg, arena);

internal/cbm/lsp/ts_lsp.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,11 @@ void cbm_run_ts_lsp_cross_with_registry(CBMArena *arena, const char *source, int
123123
// will replace this in v1.3.
124124
void cbm_ts_stdlib_register(CBMTypeRegistry *reg, CBMArena *arena);
125125

126+
// TEST HOOKS: count of full per-file cross-registry builds (the quadratic the
127+
// shared-registry dispatch eliminates; must stay 0 on the shared path).
128+
long cbm_ts_full_registry_builds(void);
129+
void cbm_ts_full_registry_builds_reset(void);
130+
126131
// --- Batch cross-file LSP ---
127132

128133
// Per-file input for batch TS LSP processing.

src/mcp/index_supervisor.c

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,16 @@ int cbm_index_supervisor_spawn_count(void) {
5353
return g_spawn_count;
5454
}
5555

56+
/* Test hook: counts SINGLE-THREADED spawns. Production recovery is parallel-
57+
* only (there are no sequential production runs); this must stay ZERO on
58+
* every supervised path — any nonzero count means a recovery/probe regressed
59+
* to the sequential crawl that ground an 81k-file TS corpus for hours. */
60+
static int g_spawn_st_count = 0;
61+
62+
int cbm_index_supervisor_spawn_st_count(void) {
63+
return g_spawn_st_count;
64+
}
65+
5666
/* #845: opt-in host mark — see the header. Set once from the real binary's
5767
* main(); embedders never set it, so should_wrap() stays false for them. */
5868
static bool g_host_marked = false;
@@ -137,6 +147,9 @@ static void worker_tmp_path(char *out, size_t out_sz, int pid, const char *suffi
137147
int cbm_index_spawn_worker(const char *args_json, bool single_thread, const char *marker_file,
138148
const char *quarantine_file, cbm_index_worker_result_t *result) {
139149
g_spawn_count++; /* test hook (#845) — see cbm_index_supervisor_spawn_count */
150+
if (single_thread) {
151+
g_spawn_st_count++; /* test hook — must stay 0: recovery is parallel-only */
152+
}
140153
result->outcome = CBM_PROC_SPAWN_FAILED;
141154
result->exit_code = -1;
142155
result->term_signal = 0;

0 commit comments

Comments
 (0)