@@ -489,6 +489,62 @@ TEST(cypher_exec_match_all_functions) {
489489 PASS ();
490490}
491491
492+ /* Regression: expand_pattern_rels sized its output buffer as bind_cap*10 + 1 —
493+ * room for the bounded expansion (max_new = bind_cap*10) plus a SINGLE OPTIONAL
494+ * fallback row. When one source saturates the expansion to max_new and two or
495+ * more later sources take the OPTIONAL (no-match) path, the second fallback
496+ * write ran past the allocation (ASan: heap-buffer-overflow). Query text is
497+ * agent-controlled via the MCP query tool. */
498+ TEST (cypher_exec_optional_rel_saturated_no_overflow ) {
499+ cbm_store_t * s = cbm_store_open_memory ();
500+ cbm_store_upsert_project (s , "test" , "/tmp/test" );
501+
502+ /* 1 hub + 20 leaf Function nodes → bind_cap = 21, max_new = 210,
503+ * old alloc = 211 slots. The hub is inserted first so it is expanded before
504+ * the leaves; it saturates the expansion, then each leaf adds an OPTIONAL
505+ * fallback row, pushing new_count well past the allocation. */
506+ cbm_node_t hub = {
507+ .project = "test" , .label = "Function" , .name = "hub" , .qualified_name = "test.hub" };
508+ int64_t hub_id = cbm_store_upsert_node (s , & hub );
509+ for (int i = 0 ; i < 20 ; i ++ ) {
510+ char nm [32 ];
511+ char qn [48 ];
512+ snprintf (nm , sizeof (nm ), "leaf%02d" , i );
513+ snprintf (qn , sizeof (qn ), "test.leaf%02d" , i );
514+ cbm_node_t leaf = {
515+ .project = "test" , .label = "Function" , .name = nm , .qualified_name = qn };
516+ cbm_store_upsert_node (s , & leaf );
517+ }
518+
519+ /* Give the hub 300 CALLS edges (> max_new = 210) so its expansion saturates
520+ * the buffer; targets are non-Function so they don't inflate bind_cap. */
521+ for (int i = 0 ; i < 300 ; i ++ ) {
522+ char nm [32 ];
523+ char qn [48 ];
524+ snprintf (nm , sizeof (nm ), "callee%d" , i );
525+ snprintf (qn , sizeof (qn ), "test.callee%d" , i );
526+ cbm_node_t callee = {.project = "test" , .label = "Var" , .name = nm , .qualified_name = qn };
527+ int64_t cid = cbm_store_upsert_node (s , & callee );
528+ cbm_edge_t e = {.project = "test" , .source_id = hub_id , .target_id = cid , .type = "CALLS" };
529+ cbm_store_insert_edge (s , & e );
530+ }
531+
532+ /* max_rows must be below the Function count (21) so bind_cap tracks
533+ * scan_count (21) rather than the 100000 result ceiling — the same regime a
534+ * large repo (> ceiling functions) or an agent-supplied small limit hits.
535+ * bind_cap = 21 → max_new = 210, old alloc = 211 slots. */
536+ cbm_cypher_result_t r = {0 };
537+ int rc = cbm_cypher_execute (
538+ s , "MATCH (a:Function) OPTIONAL MATCH (a)-[:CALLS]->(b) RETURN a.name" , "test" , 5 , & r );
539+ ASSERT_EQ (rc , 0 );
540+ /* hub expands (capped at max_new), each leaf keeps one unbound row. */
541+ ASSERT_GT (r .row_count , 0 );
542+
543+ cbm_cypher_result_free (& r );
544+ cbm_store_close (s );
545+ PASS ();
546+ }
547+
492548TEST (cypher_exec_where_eq ) {
493549 cbm_store_t * s = setup_cypher_store ();
494550 cbm_cypher_result_t r = {0 };
@@ -3080,6 +3136,7 @@ SUITE(cypher) {
30803136 RUN_TEST (cypher_exec_deadline_aborts_runaway_query_issue601 );
30813137 RUN_TEST (cypher_exec_deadline_allows_normal_query_issue601 );
30823138 RUN_TEST (cypher_exec_match_all_functions );
3139+ RUN_TEST (cypher_exec_optional_rel_saturated_no_overflow );
30833140 RUN_TEST (cypher_issue240_labels_function );
30843141 RUN_TEST (cypher_issue237_distinct_order_limit );
30853142 RUN_TEST (cypher_issue873_distinct_order_limit_dedupes_before_limit );
0 commit comments