diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d348e128..3ea26d2b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,7 +48,7 @@ jobs: # pin@stable - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # pin@v4 - - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 with: path: | ~/.cargo/registry/ @@ -75,7 +75,7 @@ jobs: # pin@stable - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # pin@v4 - - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 with: path: | ~/.cargo/registry/ @@ -107,7 +107,7 @@ jobs: echo "relevant=false" >> "$GITHUB_OUTPUT" fi # pin@v4 - - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 if: steps.changed.outputs.relevant == 'true' with: dotnet-version: '10.0.x' @@ -120,7 +120,7 @@ jobs: - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 if: steps.changed.outputs.relevant == 'true' # pin@v4 - - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 if: steps.changed.outputs.relevant == 'true' with: path: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 28e599f0..3aa0eeaa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -64,7 +64,7 @@ jobs: - name: Cache cargo # pin@v4 - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 with: path: | ~/.cargo/registry @@ -81,7 +81,7 @@ jobs: - name: Setup .NET # pin@v4 - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 with: dotnet-version: '10.0.x' @@ -126,14 +126,14 @@ jobs: - name: Upload kale artifact # pin@v4 - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: ${{ matrix.artifact }} path: ${{ matrix.artifact }} - name: Upload with-csharp artifact # pin@v4 - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: ${{ matrix.artifact_csharp }} path: ${{ matrix.artifact_csharp }} @@ -165,7 +165,7 @@ jobs: - name: Cache cargo # pin@v4 - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 with: path: | ~/.cargo/registry @@ -197,7 +197,7 @@ jobs: - name: Setup .NET # pin@v4 - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 with: dotnet-version: '10.0.x' @@ -232,14 +232,14 @@ jobs: - name: Upload kale artifact # pin@v4 - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: codesearch-macos-arm64.tar.gz path: codesearch-macos-arm64.tar.gz - name: Upload with-csharp artifact # pin@v4 - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: codesearch-macos-arm64-with-csharp.tar.gz path: codesearch-macos-arm64-with-csharp.tar.gz @@ -253,14 +253,14 @@ jobs: steps: - name: Download all artifacts # pin@v4 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: path: artifacts merge-multiple: true - name: Create or update Release # pin@v2 - uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 + uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 with: files: artifacts/* generate_release_notes: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 9633ee3b..c633e57c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ more PRs land; when the release is actually tagged, the same section is finalized in place with a date — no renaming/migration step needed. --> -## [1.4.0] - 2026-09-17 +## [1.4.3] - 2026-09-17 ### Security @@ -48,6 +48,12 @@ finalized in place with a date — no renaming/migration step needed. - **Serve auto-recovers LMDB storage-format corruption with a sequential wipe + rebuild.** After the arroy 0.5→0.8 / heed 0.20→0.22 major upgrades, every repo whose on-disk database was written by the previous binary failed its symbol rebuild with `MDB_BAD_VALSIZE: Unsupported size of key/DB name/data, or wrong DUPFIXED size` (observed on all C# repos after deploy). When a symbol rebuild now fails with that error class, serve wipes the repo's DB directory — closing the LMDB envs first via the same eviction sequence `remove_repo` uses, with the same bounded retry for transient Windows lock holders — and force-reindexes it through the existing force-reindex machinery, whose store-open path recreates everything on the new formats. Recoveries are queued and processed strictly **one repo at a time**: each rebuild runs a full CPU-bound embed pass, so parallel recoveries would thrash the machine. Read-only repos are skipped with a pointer to the owning writer. This closes the gap the tantivy FTS graceful reset (above) already covered on the FTS side — the vector/symbol stores now self-heal the same upgrade boundary instead of staying red until an operator force-reindexes by hand. +### Fixed + +- **Dependency batch: criterion 0.8, serial_test 4, indicatif 0.18, colored 3 (dependabot majors) + CI actions refresh.** The four cargo majors were drop-in for lib/bins; benches and dev-test surface absorbed the criterion 0.5→0.8 and serial_test 3→4 API moves. The five GitHub Actions bumps (upload-artifact v7, download-artifact v8, cache v6, setup-dotnet v6, action-gh-release v3) are the dependabot-proposed SHA pins. dependabot itself now targets develop permanently (`target-branch` in the default-branch config): its rebases used to reset the base to master, tripping the `check-source-branch` guard. + +- **Federated chunk fetch works again — URL residue and project-scope routing (todo #153).** Two independent defects: (1) the peer URL for a `chunk_ref` fetch was built by replacing `{id` without the closing brace, so the constructed path carried a stray `}` (`/chunk/2058%7D`) that real peers answered with 400 Bad Request — axum's `{id}` parameter happily swallowed the stray brace into the captured value, which is exactly why the mock-based tests never caught it; the replacement now covers the full `{id}` placeholder, pinned by a test whose route echoes back the exact path it was hit on. (2) `get_chunk` with `project=/` plus a plain `chunk_id` died in local routing with "Unknown alias" — mounted remote projects are now routed through the same federated fetch search uses (local aliases still win a name clash), so both `chunk_ref` and `project=`+`chunk_id` forms work against remote peers. + ## [1.3.19] ### Changed diff --git a/Cargo.lock b/Cargo.lock index f2ee5e04..271e8268 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -49,6 +49,15 @@ dependencies = [ "equator", ] +[[package]] +name = "alloca" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" +dependencies = [ + "cc", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -713,7 +722,7 @@ dependencies = [ [[package]] name = "codesearch" -version = "1.4.0" +version = "1.4.3" dependencies = [ "anyhow", "arroy", @@ -733,7 +742,7 @@ dependencies = [ "heed", "hf-hub 1.0.0", "ignore", - "indicatif 0.17.11", + "indicatif", "libc", "moka", "ndarray", @@ -805,7 +814,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" dependencies = [ "lazy_static", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -842,19 +851,6 @@ dependencies = [ "static_assertions", ] -[[package]] -name = "console" -version = "0.15.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" -dependencies = [ - "encode_unicode", - "libc", - "once_cell", - "unicode-width", - "windows-sys 0.59.0", -] - [[package]] name = "console" version = "0.16.6" @@ -1002,25 +998,24 @@ dependencies = [ [[package]] name = "criterion" -version = "0.5.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" dependencies = [ + "alloca", "anes", "cast", "ciborium", "clap", "criterion-plot", - "is-terminal", - "itertools 0.10.5", + "itertools 0.13.0", "num-traits", - "once_cell", "oorandom", + "page_size", "plotters", "rayon", "regex", "serde", - "serde_derive", "serde_json", "tinytemplate", "walkdir", @@ -1028,12 +1023,12 @@ dependencies = [ [[package]] name = "criterion-plot" -version = "0.5.0" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" dependencies = [ "cast", - "itertools 0.10.5", + "itertools 0.13.0", ] [[package]] @@ -2082,7 +2077,7 @@ checksum = "aef3982638978efa195ff11b305f51f1f22f4f0a6cabee7af79b383ebee6a213" dependencies = [ "dirs 6.0.0", "http", - "indicatif 0.18.6", + "indicatif", "libc", "log", "native-tls", @@ -2501,26 +2496,13 @@ dependencies = [ "serde_core", ] -[[package]] -name = "indicatif" -version = "0.17.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" -dependencies = [ - "console 0.15.11", - "number_prefix", - "portable-atomic", - "unicode-width", - "web-time", -] - [[package]] name = "indicatif" version = "0.18.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" dependencies = [ - "console 0.16.6", + "console", "portable-atomic", "unicode-width", "unit-prefix", @@ -2595,17 +2577,6 @@ version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" -[[package]] -name = "is-terminal" -version = "0.4.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" -dependencies = [ - "hermit-abi", - "libc", - "windows-sys 0.61.2", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -2614,9 +2585,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" -version = "0.10.5" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" dependencies = [ "either", ] @@ -3410,12 +3381,6 @@ dependencies = [ "libc", ] -[[package]] -name = "number_prefix" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" - [[package]] name = "objc2" version = "0.6.4" @@ -4992,9 +4957,9 @@ dependencies = [ [[package]] name = "serial_test" -version = "3.5.0" +version = "4.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d" +checksum = "a6df5ed973ad8d834e09f824f9e9f449af6b9a3745f78dec7cc752770bd3bf11" dependencies = [ "futures-executor", "futures-util", @@ -5006,13 +4971,13 @@ dependencies = [ [[package]] name = "serial_test_derive" -version = "3.5.0" +version = "4.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" +checksum = "a22144e767da4ddd8416dbf383700542ffd8a5dc493dfecedfe1fe3ad03c98ae" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.5", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 5b8a818c..ebb8c382 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codesearch" -version = "1.4.0" +version = "1.4.3" edition = "2021" authors = ["codesearch contributors"] license = "Apache-2.0" @@ -88,7 +88,7 @@ tracing-appender = "0.2" sha2 = "0.11" uuid = { version = "1.11", features = ["v4", "serde"] } chrono = { version = "0.4", features = ["serde"] } -colored = "2.1" +colored = "3.1" indicatif = "0.17" dirs = "7" num_cpus = "1.16" diff --git a/src/federation/mod.rs b/src/federation/mod.rs index 394cdcbb..d16a56e4 100644 --- a/src/federation/mod.rs +++ b/src/federation/mod.rs @@ -402,7 +402,7 @@ impl FederationClient { ) -> Outcome { let mut url = Self::peer_url( peer, - &crate::constants::CHUNK_PATH.replace("{id", &chunk_id.to_string()), + &crate::constants::CHUNK_PATH.replace("{id}", &chunk_id.to_string()), ); // Scope the lookup: prefer a single-project scope (`project=`) // so the multi-repo peer can disambiguate the chunk_id; fall back to @@ -1285,6 +1285,51 @@ mod tests { ); } + #[tokio::test] + #[serial_test::serial] + async fn get_chunk_requests_the_exact_chunk_path_without_placeholder_residue() { + // Regression (todo #153): the URL template is `/chunk/{id}`; a + // placeholder replacement that missed the closing brace produced + // `/chunk/2058%7D`. Axum's `{id}` param happily swallowed the stray + // `}` into the captured value, so mock-based route tests passed while + // real peers answered 400 Bad Request on the mangled id. The route + // below echoes back the EXACT path it was hit on, so any residue in + // the constructed URL fails the assertion. + let seen = std::sync::Arc::new(tokio::sync::Mutex::new(String::new())); + let seen_clone = seen.clone(); + let router = axum::Router::new().route( + "/chunk/{id}", + axum::routing::get(move |uri: axum::http::Uri| { + let seen = seen_clone.clone(); + async move { + *seen.lock().await = uri.path().to_string(); + axum::Json(serde_json::json!({ + "chunk_id": 2058, + "content": "ok", + "path": "kb/x.md", + "start_line": 1, + "end_line": 2 + })) + } + }), + ); + let addr = spawn_test_server(router).await; + + let client = FederationClient::new().unwrap(); + let outcome = client + .get_chunk(&peer(format!("http://{addr}")), Some("bynder"), 2058, None) + .await; + assert!( + matches!(outcome, Outcome::Ok(_)), + "the peer must answer the clean path" + ); + assert_eq!( + *seen.lock().await, + "/chunk/2058", + "the constructed URL must carry the bare chunk id — no placeholder residue" + ); + } + #[tokio::test] #[serial_test::serial] async fn get_chunk_persistent_503_reports_cold_start_hint() { diff --git a/src/mcp/get_chunk.rs b/src/mcp/get_chunk.rs index 8f4ffaed..c32cdd59 100644 --- a/src/mcp/get_chunk.rs +++ b/src/mcp/get_chunk.rs @@ -34,6 +34,28 @@ impl CodesearchService { .await; } + // Federated mounts: `project=/` routes to the peer's own + // project, exactly like search's project-level federation — chunk_ids + // are peer-local, so the fetch reuses the chunk_ref path with a + // synthetic "/:" ref. Local aliases ALWAYS win a name + // clash: only route remotely when the name is not a local project. + if let Some(proj) = request.project.as_deref() { + let cfg = self.federation_config(); + if cfg.resolve(proj).is_none() { + if let Some(crate::db_discovery::repos::Target::RemoteProject { + peer_name, + peer: _, + remote_alias, + }) = cfg.resolve_remote_project(proj) + { + let chunk_ref = format!("{peer_name}/{remote_alias}:{}", request.chunk_id); + return self + .federated_get_chunk(&chunk_ref, request.context_lines) + .await; + } + } + } + // In multi-repo serve mode, require explicit project or group scope. // Unscoped get_chunk would fan-out over all repos, opening all DBs unnecessarily. // Consistent with search/find/explore which also require scope. diff --git a/src/serve/tests.rs b/src/serve/tests.rs index 80a4d8fe..53081376 100644 --- a/src/serve/tests.rs +++ b/src/serve/tests.rs @@ -30,6 +30,62 @@ fn rest_service_drop_does_not_touch_active_sessions() { ); } +#[tokio::test] +#[serial] +async fn get_chunk_routes_mounted_remote_projects_through_federation() { + // todo #153: `get_chunk(project="/", chunk_id=…)` must route + // through the federated fetch exactly like search's project-level + // federation, instead of dying in local routing with "Unknown alias". + // The peer URL here is unreachable, so the correctly routed answer is the + // federation failure message — "Unknown alias" means the routing did not + // happen. + let mut config = ReposConfig::default(); + config.remotes.insert( + "cloud".to_string(), + crate::db_discovery::repos::RemotePeer { + url: "http://127.0.0.1:1".to_string(), + api_key: "test-key".to_string(), + group: None, + timeout_secs: None, + }, + ); + config.remote_mounts.push("cloud/bynder".to_string()); + // Hermetic config: persist to a temp file and pass the override, so + // `reload_if_changed` reads THIS config — not the developer's real + // ~/.codesearch/repos.json (which would leak real peers into the test). + let tmp = tempfile::tempdir().unwrap(); + let config_file = tmp.path().join("repos.json"); + config.save_to(&config_file).unwrap(); + let state = std::sync::Arc::new(ServeState::new(config, Some(config_file))); + let service = crate::mcp::CodesearchService::new_for_serve(state).unwrap(); + + let _env = + crate::testing::EnvRestore::set(&[(crate::constants::REMOTE_PEER_RETRY_BACKOFF_ENV, "1")]); + let req = crate::mcp::types::GetChunkRequest { + chunk_id: 2058, + chunk_ref: None, + context_lines: None, + project: Some("cloud/bynder".to_string()), + group: None, + }; + let res = service + .get_chunk(rmcp::handler::server::wrapper::Parameters(req)) + .await + .expect("handler must not error"); + let text = match res.content.first() { + Some(rmcp::model::ContentBlock::Text(t)) => t.text.clone(), + other => panic!("expected text content, got {other:?}"), + }; + assert!( + text.contains("Could not fetch chunk from remote peer 'cloud'"), + "get_chunk must route mounted remote projects to the peer, got: {text}" + ); + assert!( + !text.contains("Unknown alias"), + "a mounted remote project is not a local alias — routing failed: {text}" + ); +} + #[test] fn tracked_session_drop_balances_active_sessions() { // A genuine MCP session increments on connect and the serve factory