From be8b5ca1bb160f9631b7f992b1dcf6752d1502d7 Mon Sep 17 00:00:00 2001 From: Taylor Date: Tue, 20 Oct 2026 17:46:18 -0700 Subject: [PATCH 1/3] diag: time Windows persist retries in daemon_server_test (do not merge) Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 8 +++----- src/engine/daemon_server.cpp | 10 ++++++++++ tests/smoke/daemon_server_test.cpp | 3 +++ 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cfada9b..c673462 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,15 +14,13 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest] - preset: [default, release, sanitizers] + os: [windows-latest] + preset: [windows-release] # Windows x64: MSVC + Ninja with the static-md vcpkg triplet. No # sanitizers row: the preset's ASan/UBSan pair is GCC/Clang-only. include: - os: windows-latest preset: windows-default - - os: windows-latest - preset: windows-release steps: # Fixtures and goldens are compared byte for byte; Git for Windows @@ -79,7 +77,7 @@ jobs: run: cmake --build --preset ${{ matrix.preset }} - name: Test - run: ctest --preset ${{ matrix.preset }} + run: ctest --preset ${{ matrix.preset }} -R cgraph_daemon_server_test --repeat until-fail:25 --output-on-failure # The windows-x64 release asset, packaged exactly as release-binaries.yml # packages it, so a PR proves the asset builds without cutting a release. diff --git a/src/engine/daemon_server.cpp b/src/engine/daemon_server.cpp index 89cf711..574bc6a 100644 --- a/src/engine/daemon_server.cpp +++ b/src/engine/daemon_server.cpp @@ -941,9 +941,19 @@ int run_daemon_server(const std::filesystem::path& root, DaemonServerOptions opt bool persisted_incremental = false; { const std::unique_lock lock(graph_mutex, std::try_to_lock); + const bool diag_due = lifecycle.graph_dirty && + DaemonClock::now() - lifecycle.dirty_since >= lifecycle_config.persist_interval; + const auto diag_ms = std::chrono::duration_cast( + DaemonClock::now().time_since_epoch()).count(); + if (diag_due && !lock.owns_lock()) { + std::cerr << "DIAG " << diag_ms << " persist skipped: graph_mutex busy\n"; + } if (lock.owns_lock()) { persisted_incremental = persist_if_due(deterministic_graph, lifecycle, lifecycle_config, DaemonClock::now()); + if (diag_due) { + std::cerr << "DIAG " << diag_ms << " persist attempt -> " << persisted_incremental << '\n'; + } if (persisted_incremental) { persist_manifest(); } diff --git a/tests/smoke/daemon_server_test.cpp b/tests/smoke/daemon_server_test.cpp index 4bf5588..1e6e790 100644 --- a/tests/smoke/daemon_server_test.cpp +++ b/tests/smoke/daemon_server_test.cpp @@ -420,6 +420,9 @@ int main() { std::this_thread::sleep_for(std::chrono::milliseconds(20)); } } + std::cerr << "DIAG " << std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count() + << " test persisted-wait done: " << persisted << '\n'; expect(ok, persisted, "incremental state re-persisted to graph.json"); // Wire hardening 1 (DoS): a 4-byte header declaring a ~4 GB body must be From 719b3661a511a01d018c1763cd2a9c13b87cc882 Mon Sep 17 00:00:00 2001 From: Taylor Date: Tue, 20 Oct 2026 18:06:23 -0700 Subject: [PATCH 2/3] diag: name graph.json holders via Restart Manager; non-throwing exists (do not merge) Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 +- src/engine/atomic_write.cpp | 42 ++++++++++++++++++++++++++++++ src/engine/file_cache.cpp | 7 +++-- src/engine/incremental_update.cpp | 8 ++++-- src/engine/semantic_chunk_plan.cpp | 3 ++- 5 files changed, 56 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c673462..fd1134f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,7 +77,7 @@ jobs: run: cmake --build --preset ${{ matrix.preset }} - name: Test - run: ctest --preset ${{ matrix.preset }} -R cgraph_daemon_server_test --repeat until-fail:25 --output-on-failure + run: ctest --preset ${{ matrix.preset }} -R cgraph_daemon_server_test --repeat until-fail:40 --timeout 180 --output-on-failure # The windows-x64 release asset, packaged exactly as release-binaries.yml # packages it, so a PR proves the asset builds without cutting a release. diff --git a/src/engine/atomic_write.cpp b/src/engine/atomic_write.cpp index 7285458..8e17be3 100644 --- a/src/engine/atomic_write.cpp +++ b/src/engine/atomic_write.cpp @@ -20,7 +20,11 @@ #endif #include #include +#include +#include +#include #include +#pragma comment(lib, "Rstrtmgr.lib") #else #include #endif @@ -48,7 +52,40 @@ using FileStatus = struct ::_stat64; // replace is retried until it lets go, within a bounded window. constexpr auto kReplaceRetryWindow = std::chrono::seconds(10); +// DIAG: name the processes Restart Manager says hold `path` open. +void diag_print_holders(const std::filesystem::path& path, DWORD code) { + DWORD session = 0; + WCHAR key[CCH_RM_SESSION_KEY + 1] = {}; + std::cerr << "DIAG replace denied (code " << code << ") on " << path.string() << "; holders:"; + if (::RmStartSession(&session, 0, key) != ERROR_SUCCESS) { + std::cerr << " \n"; + return; + } + const std::wstring wide = path.wstring(); + LPCWSTR files[] = {wide.c_str()}; + if (::RmRegisterResources(session, 1, files, 0, nullptr, 0, nullptr) == ERROR_SUCCESS) { + UINT needed = 0; + UINT count = 16; + RM_PROCESS_INFO info[16] = {}; + DWORD reasons = 0; + const DWORD rc = ::RmGetList(session, &needed, &count, info, &reasons); + if (rc != ERROR_SUCCESS && rc != ERROR_MORE_DATA) { + std::cerr << " "; + } + for (UINT i = 0; i < count && i < 16; ++i) { + const std::wstring name = info[i].strAppName; + std::cerr << " [pid " << info[i].Process.dwProcessId << " " << std::string(name.begin(), name.end()) + << (info[i].Process.dwProcessId == ::GetCurrentProcessId() ? " (self)" : "") << "]"; + } + std::cerr << " needed=" << needed; + } + ::RmEndSession(session); + std::cerr << '\n'; +} + [[nodiscard]] std::error_code replace_file(const std::filesystem::path& from, const std::filesystem::path& to) { + bool diag_printed = false; + const auto diag_start = std::chrono::steady_clock::now(); const auto deadline = std::chrono::steady_clock::now() + kReplaceRetryWindow; auto pause = std::chrono::milliseconds(1); for (;;) { @@ -62,7 +99,12 @@ constexpr auto kReplaceRetryWindow = std::chrono::seconds(10); const bool held_open = code == ERROR_SHARING_VIOLATION || (code == ERROR_ACCESS_DENIED && target != INVALID_FILE_ATTRIBUTES && (target & FILE_ATTRIBUTE_DIRECTORY) == 0); + if (held_open && !diag_printed && std::chrono::steady_clock::now() - diag_start > std::chrono::milliseconds(500)) { + diag_printed = true; + diag_print_holders(to, code); + } if (!held_open || std::chrono::steady_clock::now() >= deadline) { + diag_print_holders(to, code); return {static_cast(code), std::system_category()}; } std::this_thread::sleep_for(pause); diff --git a/src/engine/file_cache.cpp b/src/engine/file_cache.cpp index 5a9cbc7..3945c7c 100644 --- a/src/engine/file_cache.cpp +++ b/src/engine/file_cache.cpp @@ -271,13 +271,16 @@ FileCacheClassification classify_cached_file( const std::filesystem::path& path, std::optional previous, CacheValidation mode) { - if (!std::filesystem::exists(path)) { + // Non-throwing like the stat calls below: Windows reports a file that an + // editor is replacing as "Access is denied", and the caller (the daemon's + // enrichment walk) must see one more transient miss, not an exception. + std::error_code error; + if (!std::filesystem::exists(path, error)) { return FileCacheClassification{.state = CacheState::Deleted}; } FileCacheEntry current; current.path = path; - std::error_code error; current.size = std::filesystem::file_size(path, error); if (error) { return FileCacheClassification{.state = CacheState::Deleted}; diff --git a/src/engine/incremental_update.cpp b/src/engine/incremental_update.cpp index a59aedf..bbe7887 100644 --- a/src/engine/incremental_update.cpp +++ b/src/engine/incremental_update.cpp @@ -327,13 +327,17 @@ IncrementalUpdateResult apply_incremental_code_updates( } const auto language = detect_language(event.path); - if (language == DetectedLanguage::Unknown || !std::filesystem::exists(event.path)) { + // Non-throwing: a file mid-save reads as "Access is denied" on Windows. It + // counts as absent for this pass; the watcher reports it again once written. + std::error_code exists_error; + const bool present = std::filesystem::exists(event.path, exists_error); + if (language == DetectedLanguage::Unknown || !present) { if (language == DetectedLanguage::Unknown && index.files.contains(key)) { applied = true; changed_sources.push_back(key); continue; } - if (!std::filesystem::exists(event.path) && index.files.contains(key)) { + if (!present && index.files.contains(key)) { applied = true; changed_sources.push_back(key); continue; diff --git a/src/engine/semantic_chunk_plan.cpp b/src/engine/semantic_chunk_plan.cpp index 72cab79..b70e8e4 100644 --- a/src/engine/semantic_chunk_plan.cpp +++ b/src/engine/semantic_chunk_plan.cpp @@ -85,9 +85,10 @@ namespace { } [[nodiscard]] bool is_valid_cached_record(const SemanticCacheRecord& record, std::string_view current_hash) { + std::error_code error; // unreadable counts as missing: the record is re-planned, never thrown past return record.state == SemanticCacheState::Valid && record.content_hash == current_hash && - std::filesystem::exists(record.fragment_path) && + std::filesystem::exists(record.fragment_path, error) && !record.fragment_hash.empty() && sha256_file_hex(record.fragment_path) == record.fragment_hash; } From 865eab3aff8f56e9550df44a0a4b216f8006cc00 Mon Sep 17 00:00:00 2001 From: Taylor Date: Tue, 20 Oct 2026 18:26:14 -0700 Subject: [PATCH 3/3] diag: share-delete test reader (do not merge) Co-Authored-By: Claude Opus 5.5 --- tests/smoke/daemon_server_test.cpp | 49 ++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/tests/smoke/daemon_server_test.cpp b/tests/smoke/daemon_server_test.cpp index 1e6e790..5efeb9e 100644 --- a/tests/smoke/daemon_server_test.cpp +++ b/tests/smoke/daemon_server_test.cpp @@ -23,6 +23,14 @@ #include #include #include +#else +#ifndef NOMINMAX +#define NOMINMAX +#endif +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include #endif namespace { @@ -47,6 +55,33 @@ void write_file(const std::filesystem::path& path, std::string contents) { std::ofstream(path, std::ios::binary) << contents; } +// Reads a file the daemon may replace at any moment ("" if it cannot be +// opened). On Windows std::ifstream opens without FILE_SHARE_DELETE, which +// makes the daemon's MoveFileExW replace fail with access denied; a test that +// polls graph.json every few milliseconds can starve the replace for longer +// than its retry window. Sharing delete lets the replace through while the +// read keeps the old file's bytes. +std::string read_shared(const std::filesystem::path& path) { +#ifdef _WIN32 + const HANDLE file = ::CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + if (file == INVALID_HANDLE_VALUE) { + return {}; + } + std::string contents; + std::array buffer{}; + DWORD read = 0; + while (::ReadFile(file, buffer.data(), static_cast(buffer.size()), &read, nullptr) && read > 0) { + contents.append(buffer.data(), read); + } + ::CloseHandle(file); + return contents; +#else + std::ifstream input(path, std::ios::binary); + return {std::istreambuf_iterator(input), std::istreambuf_iterator()}; +#endif +} + // Records a failed expectation with a name, so a CI flake says WHICH step // failed instead of a bare non-zero exit. void expect(bool& ok, bool condition, const char* what) { @@ -413,9 +448,7 @@ int main() { // once the persist interval elapses (no update op, no shutdown). bool persisted = false; for (int attempt = 0; attempt < 600 && !persisted; ++attempt) { - std::ifstream input(root / "cgraph-out" / "graph.json"); - const std::string contents((std::istreambuf_iterator(input)), std::istreambuf_iterator()); - persisted = contents.find("gamma") != std::string::npos; + persisted = read_shared(root / "cgraph-out" / "graph.json").find("gamma") != std::string::npos; if (!persisted) { std::this_thread::sleep_for(std::chrono::milliseconds(20)); } @@ -945,8 +978,7 @@ int main() { expect(ok, rc == 0, exited); }; const auto persisted_graph_has_overlay = [&]() { - std::ifstream input(graph_path, std::ios::binary); - const auto graph = nlohmann::json::parse(input, nullptr, false); + const auto graph = nlohmann::json::parse(read_shared(graph_path), nullptr, false); if (!graph.is_object()) { return true; } @@ -1065,8 +1097,7 @@ int main() { "overlay-events: live document deletion removed the invalid overlay"); bool deleted_stat_evicted = false; for (int attempt = 0; attempt < 500 && !deleted_stat_evicted; ++attempt) { - std::ifstream input(stat_index_path, std::ios::binary); - const auto stat_json = nlohmann::json::parse(input, nullptr, false); + const auto stat_json = nlohmann::json::parse(read_shared(stat_index_path), nullptr, false); if (stat_json.is_object()) { deleted_stat_evicted = true; for (const auto& entry : stat_json.value("entries", nlohmann::json::array())) { @@ -1177,8 +1208,7 @@ int main() { cov_server.join(); expect(ok, cov_rc == 0, "coverage: daemon exited cleanly"); - std::ifstream persisted(cov_root / "cgraph-out" / "graph.json", std::ios::binary); - const auto persisted_graph = nlohmann::json::parse(persisted, nullptr, false); + const auto persisted_graph = nlohmann::json::parse(read_shared(cov_root / "cgraph-out" / "graph.json"), nullptr, false); bool overlay_in_persisted = !persisted_graph.is_object(); bool code_in_persisted = false; for (const auto& node : persisted_graph.value("nodes", nlohmann::json::array())) { @@ -1193,7 +1223,6 @@ int main() { expect(ok, !overlay_in_persisted && code_in_persisted, "coverage: graph.json stayed deterministic-only across the refused update"); - persisted.close(); // Windows cannot delete a file that is still open fs::remove_all(cov_root); }