@@ -489,6 +489,131 @@ TEST(cypher_exec_match_all_functions) {
489489 PASS ();
490490}
491491
492+ /* Regression: expand_pattern_rels sized its OPTIONAL-expansion output buffer as
493+ * bind_cap*10 + 1 — room for the bounded expansion (max_new = bind_cap*10) plus
494+ * a SINGLE OPTIONAL fallback row. When one source saturated the expansion to
495+ * max_new and two or more later sources took the OPTIONAL (no-match) path, the
496+ * second fallback write ran past the allocation (ASan: heap-buffer-overflow).
497+ *
498+ * The fix holds the OPTIONAL fallback to the SAME global ceiling (max_new) the
499+ * expansion helpers already respect, so the hop truncates at the ceiling instead
500+ * of growing the buffer. Defined behavior at the ceiling: the query still
501+ * succeeds (rc == 0) with a bounded result — it does not overflow or error.
502+ * Query text is agent-controlled via the MCP query tool. */
503+ TEST (cypher_exec_optional_rel_ceiling_truncates_no_overflow ) {
504+ cbm_store_t * s = cbm_store_open_memory ();
505+ cbm_store_upsert_project (s , "test" , "/tmp/test" );
506+
507+ /* 1 hub + 20 leaf Function nodes → bind_cap = 21, max_new = 210. The hub is
508+ * inserted first so it is expanded before the leaves; it saturates the
509+ * expansion to the ceiling, then each leaf reaches the OPTIONAL fallback —
510+ * under the old alloc (211 slots) the 2nd such write overflowed. */
511+ cbm_node_t hub = {
512+ .project = "test" , .label = "Function" , .name = "hub" , .qualified_name = "test.hub" };
513+ int64_t hub_id = cbm_store_upsert_node (s , & hub );
514+ for (int i = 0 ; i < 20 ; i ++ ) {
515+ char nm [32 ];
516+ char qn [48 ];
517+ snprintf (nm , sizeof (nm ), "leaf%02d" , i );
518+ snprintf (qn , sizeof (qn ), "test.leaf%02d" , i );
519+ cbm_node_t leaf = {
520+ .project = "test" , .label = "Function" , .name = nm , .qualified_name = qn };
521+ cbm_store_upsert_node (s , & leaf );
522+ }
523+
524+ /* Give the hub 300 CALLS edges (> max_new = 210) so its expansion saturates
525+ * the ceiling; targets are non-Function so they don't inflate bind_cap. */
526+ for (int i = 0 ; i < 300 ; i ++ ) {
527+ char nm [32 ];
528+ char qn [48 ];
529+ snprintf (nm , sizeof (nm ), "callee%d" , i );
530+ snprintf (qn , sizeof (qn ), "test.callee%d" , i );
531+ cbm_node_t callee = {.project = "test" , .label = "Var" , .name = nm , .qualified_name = qn };
532+ int64_t cid = cbm_store_upsert_node (s , & callee );
533+ cbm_edge_t e = {.project = "test" , .source_id = hub_id , .target_id = cid , .type = "CALLS" };
534+ cbm_store_insert_edge (s , & e );
535+ }
536+
537+ /* max_rows below the Function count (21) so bind_cap tracks scan_count (21)
538+ * rather than the 100000 result ceiling — the same regime a large repo
539+ * (> ceiling functions) or an agent-supplied small limit hits. */
540+ cbm_cypher_result_t r = {0 };
541+ int rc = cbm_cypher_execute (
542+ s , "MATCH (a:Function) OPTIONAL MATCH (a)-[:CALLS]->(b) RETURN a.name" , "test" , 5 , & r );
543+ /* Defined ceiling behavior: bounded success, no overflow (ASan proves the
544+ * latter), result truncated to the LIMIT rather than crashing. */
545+ ASSERT_EQ (rc , 0 );
546+ ASSERT_GT (r .row_count , 0 );
547+ ASSERT_TRUE (r .row_count <= 5 );
548+
549+ cbm_cypher_result_free (& r );
550+ cbm_store_close (s );
551+ PASS ();
552+ }
553+
554+ /* Companion to the truncation regression: when the expansion does NOT saturate
555+ * the ceiling, every leaf's OPTIONAL fallback row must survive with its target
556+ * unbound. A `row_count > 0` check is too weak — it can pass on hub rows alone —
557+ * so this asserts a specific leaf appears with an empty b.name, and that a real
558+ * expanded hub row is present too. */
559+ TEST (cypher_exec_optional_rel_leaf_fallback_survives ) {
560+ cbm_store_t * s = cbm_store_open_memory ();
561+ cbm_store_upsert_project (s , "test" , "/tmp/test" );
562+
563+ /* 1 hub (2 CALLS edges) + 3 leaves (no edges). Ceiling is huge (default
564+ * max_rows → bind_cap = 100000), so nothing truncates. */
565+ cbm_node_t hub = {
566+ .project = "test" , .label = "Function" , .name = "hub" , .qualified_name = "test.hub" };
567+ int64_t hub_id = cbm_store_upsert_node (s , & hub );
568+ for (int i = 0 ; i < 3 ; i ++ ) {
569+ char nm [32 ];
570+ char qn [48 ];
571+ snprintf (nm , sizeof (nm ), "leaf%d" , i );
572+ snprintf (qn , sizeof (qn ), "test.leaf%d" , i );
573+ cbm_node_t leaf = {
574+ .project = "test" , .label = "Function" , .name = nm , .qualified_name = qn };
575+ cbm_store_upsert_node (s , & leaf );
576+ }
577+ for (int i = 0 ; i < 2 ; i ++ ) {
578+ char nm [32 ];
579+ char qn [48 ];
580+ snprintf (nm , sizeof (nm ), "callee%d" , i );
581+ snprintf (qn , sizeof (qn ), "test.callee%d" , i );
582+ cbm_node_t callee = {.project = "test" , .label = "Var" , .name = nm , .qualified_name = qn };
583+ int64_t cid = cbm_store_upsert_node (s , & callee );
584+ cbm_edge_t e = {.project = "test" , .source_id = hub_id , .target_id = cid , .type = "CALLS" };
585+ cbm_store_insert_edge (s , & e );
586+ }
587+
588+ cbm_cypher_result_t r = {0 };
589+ int rc = cbm_cypher_execute (
590+ s , "MATCH (a:Function) OPTIONAL MATCH (a)-[:CALLS]->(b) RETURN a.name, b.name" , "test" , 0 ,
591+ & r );
592+ ASSERT_EQ (rc , 0 );
593+ ASSERT_EQ (r .col_count , 2 );
594+
595+ /* Scan for a leaf fallback row (a.name = "leaf0", b.name unbound = "") and a
596+ * real expanded hub row (a.name = "hub", b.name non-empty). */
597+ bool leaf_fallback = false;
598+ bool hub_expanded = false;
599+ for (int i = 0 ; i < r .row_count ; i ++ ) {
600+ const char * a = r .rows [i ][0 ];
601+ const char * b = r .rows [i ][1 ];
602+ if (strcmp (a , "leaf0" ) == 0 && b [0 ] == '\0' ) {
603+ leaf_fallback = true;
604+ }
605+ if (strcmp (a , "hub" ) == 0 && b [0 ] != '\0' ) {
606+ hub_expanded = true;
607+ }
608+ }
609+ ASSERT_TRUE (leaf_fallback ); /* the OPTIONAL no-match row survived */
610+ ASSERT_TRUE (hub_expanded ); /* the expansion still produced bound rows */
611+
612+ cbm_cypher_result_free (& r );
613+ cbm_store_close (s );
614+ PASS ();
615+ }
616+
492617TEST (cypher_exec_where_eq ) {
493618 cbm_store_t * s = setup_cypher_store ();
494619 cbm_cypher_result_t r = {0 };
@@ -3080,6 +3205,8 @@ SUITE(cypher) {
30803205 RUN_TEST (cypher_exec_deadline_aborts_runaway_query_issue601 );
30813206 RUN_TEST (cypher_exec_deadline_allows_normal_query_issue601 );
30823207 RUN_TEST (cypher_exec_match_all_functions );
3208+ RUN_TEST (cypher_exec_optional_rel_ceiling_truncates_no_overflow );
3209+ RUN_TEST (cypher_exec_optional_rel_leaf_fallback_survives );
30833210 RUN_TEST (cypher_issue240_labels_function );
30843211 RUN_TEST (cypher_issue237_distinct_order_limit );
30853212 RUN_TEST (cypher_issue873_distinct_order_limit_dedupes_before_limit );
0 commit comments