Skip to content

Commit bb21edb

Browse files
authored
Merge branch 'main' into fix/1692-csharp-multi-attribute-decorates
2 parents b35f731 + 745794d commit bb21edb

5 files changed

Lines changed: 169 additions & 6 deletions

File tree

‎scripts/run-test-wave.py‎

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import signal
1818
import subprocess
1919
import sys
20+
import tempfile
2021
import time
2122
from dataclasses import dataclass
2223

@@ -105,6 +106,31 @@ def append_log(path: pathlib.Path, message: str) -> None:
105106
stream.write("\n")
106107

107108

109+
def publish_barrier_file(path: pathlib.Path, text: str) -> None:
110+
"""Publish a barrier file so a poller sees either no file or its content.
111+
112+
Path.write_text creates and truncates before it writes, so a reader that
113+
polls for existence and then parses the content can observe the zero-byte
114+
window in between. Write next to the destination and rename into place.
115+
"""
116+
with tempfile.NamedTemporaryFile(
117+
mode="w",
118+
encoding="utf-8",
119+
newline="\n",
120+
dir=path.parent,
121+
prefix=f".{path.name}.",
122+
delete=False,
123+
) as temporary:
124+
temporary.write(text)
125+
temporary.flush()
126+
temporary_path = pathlib.Path(temporary.name)
127+
try:
128+
os.replace(temporary_path, path)
129+
except BaseException:
130+
temporary_path.unlink(missing_ok=True)
131+
raise
132+
133+
108134
def start_suite(
109135
suite: str,
110136
runner_command: list[str],
@@ -288,12 +314,12 @@ def wait_for_test_pre_terminate_barrier(
288314
ready = barrier_dir / f"{active.name}.ready"
289315
leader_exited = barrier_dir / f"{active.name}.leader-exited"
290316
release = barrier_dir / f"{active.name}.release"
291-
ready.write_text(f"{active.process.pid}\n", encoding="utf-8")
317+
publish_barrier_file(ready, f"{active.process.pid}\n")
292318
deadline = time.monotonic() + 10
293319
while not release.exists():
294320
returncode = active.process.poll()
295321
if returncode is not None and not leader_exited.exists():
296-
leader_exited.write_text(f"{returncode}\n", encoding="utf-8")
322+
publish_barrier_file(leader_exited, f"{returncode}\n")
297323
if time.monotonic() >= deadline:
298324
raise RuntimeError(
299325
f"test pre-terminate barrier for {active.name!r} was not released"
@@ -312,7 +338,7 @@ def wait_for_test_post_exit_barrier(
312338
return
313339
ready = barrier_dir / f"{suite}.ready"
314340
release = barrier_dir / f"{suite}.release"
315-
ready.write_text("child exited; result intentionally not recorded\n", encoding="utf-8")
341+
publish_barrier_file(ready, "child exited; result intentionally not recorded\n")
316342
deadline = time.monotonic() + 10
317343
while not release.exists():
318344
if time.monotonic() >= deadline:

‎src/discover/discover.c‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -758,14 +758,18 @@ static int wide_stat(const char *path, struct stat *st) {
758758
/* Stat a path, skipping symlinks (POSIX) and junctions / reparse points
759759
* (Windows). Returns 0 on success, -1 to skip. Skipping reparse points keeps
760760
* discovery from walking through a junction that points outside the project
761-
* root, mirroring the POSIX S_ISLNK skip. */
762-
static int safe_stat(const char *abs_path, struct stat *st) {
761+
* root, mirroring the POSIX S_ISLNK skip. *is_symlink reports whether the
762+
* skip (if any) was specifically the symlink/reparse-point check, as
763+
* opposed to some other stat failure (permissions, a race with a delete). */
764+
static int safe_stat(const char *abs_path, struct stat *st, bool *is_symlink) {
765+
*is_symlink = false;
763766
#ifdef _WIN32
764767
wchar_t *wpath = cbm_path_to_wide(abs_path);
765768
if (wpath) {
766769
DWORD attr = GetFileAttributesW(wpath);
767770
free(wpath);
768771
if (attr != INVALID_FILE_ATTRIBUTES && (attr & FILE_ATTRIBUTE_REPARSE_POINT)) {
772+
*is_symlink = true;
769773
return CBM_NOT_FOUND;
770774
}
771775
}
@@ -775,6 +779,7 @@ static int safe_stat(const char *abs_path, struct stat *st) {
775779
return CBM_NOT_FOUND;
776780
}
777781
if (S_ISLNK(st->st_mode)) {
782+
*is_symlink = true;
778783
return CBM_NOT_FOUND;
779784
}
780785
return 0;
@@ -891,9 +896,15 @@ static void walk_dir_process_entry(cbm_dirent_t *entry, const walk_frame_t *fram
891896
}
892897

893898
struct stat st;
894-
if (safe_stat(abs_path, &st) != 0) {
899+
bool is_symlink = false;
900+
if (safe_stat(abs_path, &st, &is_symlink) != 0) {
895901
if (out->count_only) {
896902
out->failed = true;
903+
} else if (is_symlink) {
904+
/* Deliberately not indexed (#963): record so callers can
905+
* surface it, matching the directory-exclusion and file
906+
* skip_reason paths a few lines below. */
907+
file_list_add_ignored(out, rel_path, "symlink");
897908
}
898909
return;
899910
}

‎tests/test_discover.c‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -909,6 +909,46 @@ TEST(discover_symlink_skipped) {
909909
#endif
910910
}
911911

912+
/* #1815: a symlink skip must be as visible as the other two skip paths in
913+
* the same walk (gitignore/cbmignore/skip-list files, excluded dirs): a
914+
* subtree dropped because it's a symlink must not look identical to a
915+
* complete index. */
916+
TEST(discover_symlink_skip_is_reported) {
917+
#ifdef _WIN32
918+
SKIP_PLATFORM("Windows: symlinks need admin / symlink() unavailable");
919+
#else
920+
char *base = th_mktempdir("cbm_disc_sym_rep");
921+
ASSERT(base != NULL);
922+
923+
th_write_file(TH_PATH(base, "real.go"), "package main\n");
924+
char real_path[512], link_path[512];
925+
snprintf(real_path, sizeof(real_path), "%s/real.go", base);
926+
snprintf(link_path, sizeof(link_path), "%s/link.go", base);
927+
symlink(real_path, link_path);
928+
929+
cbm_discover_opts_t opts = {0};
930+
cbm_file_info_t *files = NULL;
931+
int count = 0;
932+
cbm_ignored_file_t *ignored = NULL;
933+
int ignored_count = 0;
934+
int ignored_total = 0;
935+
936+
int rc = cbm_discover_ex2(base, &opts, &files, &count, NULL, NULL, &ignored, &ignored_count,
937+
&ignored_total);
938+
ASSERT_EQ(rc, 0);
939+
ASSERT_EQ(count, 1); /* real.go only, same as discover_symlink_skipped */
940+
ASSERT_EQ(ignored_total, 1);
941+
ASSERT_EQ(ignored_count, 1);
942+
ASSERT_STR_EQ(ignored[0].rel_path, "link.go");
943+
ASSERT_STR_EQ(ignored[0].reason, "symlink");
944+
945+
cbm_discover_free(files, count);
946+
cbm_discover_free_ignored(ignored, ignored_count);
947+
th_cleanup(base);
948+
PASS();
949+
#endif
950+
}
951+
912952
TEST(discover_new_ignore_patterns) {
913953
char *base = th_mktempdir("cbm_disc_newign");
914954
ASSERT(base != NULL);
@@ -1785,6 +1825,7 @@ SUITE(discover) {
17851825
RUN_TEST(discover_cbmignore);
17861826
RUN_TEST(discover_cbmignore_stacks);
17871827
RUN_TEST(discover_symlink_skipped);
1828+
RUN_TEST(discover_symlink_skip_is_reported);
17881829
RUN_TEST(discover_new_ignore_patterns);
17891830
RUN_TEST(discover_generic_dirs_full_mode);
17901831
RUN_TEST(discover_generic_dirs_fast_mode);

‎tests/test_mcp.c‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10237,6 +10237,73 @@ TEST(search_code_path_filter_prefilter_keeps_matches) {
1023710237
PASS();
1023810238
}
1023910239

10240+
/* #2011: a grep record longer than the read buffer used to be split across two
10241+
* fgets calls, and the continuation was parsed as a fresh file:line:content
10242+
* record — inventing a file path out of matched content and a line number of 0.
10243+
* A single >2 KiB line containing colons reproduces it: the repository holds
10244+
* two matches, and the split used to report three. */
10245+
TEST(search_code_long_line_does_not_invent_matches) {
10246+
char tmp[512];
10247+
snprintf(tmp, sizeof(tmp), "/tmp/cbm_srch_longline_XXXXXX");
10248+
ASSERT_TRUE(cbm_mkdtemp(tmp) != NULL);
10249+
10250+
char big_path[768], normal_path[768];
10251+
snprintf(big_path, sizeof(big_path), "%s/big.js", tmp);
10252+
snprintf(normal_path, sizeof(normal_path), "%s/normal.js", tmp);
10253+
10254+
/* One line well over the 2 KiB read buffer, full of colons, with the
10255+
* needle at the very end so the match lands past the split point. */
10256+
FILE *fp = fopen(big_path, "w");
10257+
ASSERT_NOT_NULL(fp);
10258+
fprintf(fp, "var CFG=({");
10259+
for (int i = 0; i < 260; i++) {
10260+
fprintf(fp, "k%d:\"v%d\",", i, i);
10261+
}
10262+
fprintf(fp, "NEEDLEmarker:1});\n");
10263+
fclose(fp);
10264+
10265+
fp = fopen(normal_path, "w");
10266+
ASSERT_NOT_NULL(fp);
10267+
fprintf(fp, "const NEEDLEmarker = 42;\n");
10268+
fclose(fp);
10269+
10270+
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
10271+
ASSERT_NOT_NULL(srv);
10272+
const char *proj = "longline-search";
10273+
cbm_mcp_server_set_project(srv, proj);
10274+
cbm_store_upsert_project(cbm_mcp_server_store(srv), proj, tmp);
10275+
10276+
char *resp = cbm_mcp_server_handle(
10277+
srv, "{\"jsonrpc\":\"2.0\",\"id\":96,\"method\":\"tools/call\","
10278+
"\"params\":{\"name\":\"search_code\","
10279+
"\"arguments\":{\"pattern\":\"NEEDLEmarker\",\"project\":\"longline-search\"}}}");
10280+
ASSERT_NOT_NULL(resp);
10281+
char *inner = extract_text_content(resp);
10282+
ASSERT_NOT_NULL(inner);
10283+
10284+
/* Exactly the two real matches — the split must not produce a third. */
10285+
int grep_matches = -1;
10286+
const char *g = strstr(inner, "\"total_grep_matches\":");
10287+
if (g) {
10288+
sscanf(g, "\"total_grep_matches\":%d", &grep_matches);
10289+
} else if ((g = strstr(inner, "total_grep_matches: ")) != NULL) {
10290+
sscanf(g, "total_grep_matches: %d", &grep_matches);
10291+
}
10292+
ASSERT_EQ(grep_matches, 2);
10293+
10294+
/* Every reported line number belongs to a real line: the fabricated row
10295+
* carried line 0, which no grep -n record can produce. */
10296+
ASSERT_TRUE(strstr(inner, "\"line\":0") == NULL);
10297+
10298+
free(inner);
10299+
free(resp);
10300+
cbm_mcp_server_free(srv);
10301+
unlink(big_path);
10302+
unlink(normal_path);
10303+
rmdir(tmp);
10304+
PASS();
10305+
}
10306+
1024010307
/* PR #756 (distilled): path_filter matching ZERO indexed files. With the
1024110308
* prefilter the scoped filelist has 0 records, and handle_search_code now
1024210309
* skips the grep subprocess entirely (xargs on an empty filelist is
@@ -20044,6 +20111,7 @@ SUITE(mcp) {
2004420111
RUN_TEST(search_code_scoped_path_with_cjk_root_issue903);
2004520112
#endif
2004620113
RUN_TEST(search_code_path_filter_prefilter_keeps_matches);
20114+
RUN_TEST(search_code_long_line_does_not_invent_matches);
2004720115
RUN_TEST(search_code_path_filter_matches_nothing);
2004820116
RUN_TEST(search_code_file_pattern_prefilter_boundaries);
2004920117
RUN_TEST(search_code_windows_scope_prefilter_removes_pipeline_filter);

‎tests/test_parallel_harness_contract.sh‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,23 @@ if [[ "$probe_sites" == *kill_grace* ]]; then
4545
exit 1
4646
fi
4747

48+
# Barrier files must be published atomically. Path.write_text creates and
49+
# truncates before it writes, so a poller that saw `<suite>.ready` appear and
50+
# then parsed the leader pid could read the zero-byte window and fail on
51+
# int("") -- a scheduler-side race surfacing as a harness flake. Asserted
52+
# structurally: no barrier file is written in place, and the scheduler renames
53+
# a same-directory temp file onto the destination instead.
54+
barrier_writes=$(grep -nE '^[[:space:]]*(ready|leader_exited)\.write_text\(' "$scheduler" || true)
55+
if [ -n "$barrier_writes" ]; then
56+
echo "FAIL: scheduler barrier files are written in place (non-atomic):" >&2
57+
echo "$barrier_writes" >&2
58+
exit 1
59+
fi
60+
if ! grep -Fq 'os.replace(' "$scheduler"; then
61+
echo "FAIL: scheduler does not rename barrier files into place" >&2
62+
exit 1
63+
fi
64+
4865
python3 - "$scheduler" <<'PROBE'
4966
from __future__ import annotations
5067

0 commit comments

Comments
 (0)