diff --git a/src/engine/file_cache.cpp b/src/engine/file_cache.cpp index 5a9cbc7..437acd2 100644 --- a/src/engine/file_cache.cpp +++ b/src/engine/file_cache.cpp @@ -271,13 +271,17 @@ FileCacheClassification classify_cached_file( const std::filesystem::path& path, std::optional previous, CacheValidation mode) { - if (!std::filesystem::exists(path)) { - return FileCacheClassification{.state = CacheState::Deleted}; + // Non-throwing: Windows reports a file that an editor is replacing as + // "Access is denied". A check that fails is Unreadable, not Deleted, so a + // caller holding a verified entry keeps it (a rescan must not drop the + // file's nodes) and the daemon's enrichment walk sees no exception. + std::error_code error; + if (!std::filesystem::exists(path, error)) { + return FileCacheClassification{.state = error ? CacheState::Unreadable : 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; } diff --git a/tests/smoke/daemon_server_test.cpp b/tests/smoke/daemon_server_test.cpp index 4bf5588..b3a1029 100644 --- a/tests/smoke/daemon_server_test.cpp +++ b/tests/smoke/daemon_server_test.cpp @@ -47,6 +47,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 +440,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)); } @@ -942,8 +967,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; } @@ -1062,8 +1086,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())) { @@ -1174,8 +1197,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())) { @@ -1190,7 +1212,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); } diff --git a/tests/smoke/file_cache_test.cpp b/tests/smoke/file_cache_test.cpp index 25b430f..dfacc74 100644 --- a/tests/smoke/file_cache_test.cpp +++ b/tests/smoke/file_cache_test.cpp @@ -192,6 +192,30 @@ int main() { return 1; } +#if defined(__APPLE__) || defined(__unix__) + // A path whose existence cannot be checked classifies as Unreadable (so a + // verified entry is kept, not dropped as Deleted) instead of throwing out of + // the daemon's enrichment walk. Windows reports a file an + // editor is replacing as "Access is denied"; here an unsearchable parent + // makes the same check fail with EACCES. Root bypasses directory permissions. + if (::geteuid() != 0) { + const auto locked_dir = root / "locked"; + write_file(locked_dir / "doc.md", "# doc\n"); + std::filesystem::permissions(locked_dir, std::filesystem::perms::none, std::filesystem::perm_options::replace); + bool threw = false; + cgraph::FileCacheClassification locked; + try { + locked = cgraph::classify_cached_file(locked_dir / "doc.md", std::nullopt); + } catch (const std::exception&) { + threw = true; + } + std::filesystem::permissions(locked_dir, std::filesystem::perms::owner_all, std::filesystem::perm_options::replace); + if (threw || locked.state != cgraph::CacheState::Unreadable || locked.current.has_value()) { + return 1; + } + } +#endif + // === Content validation mode === write_file(path, "print('hello')\n"); diff --git a/tests/smoke/incremental_update_test.cpp b/tests/smoke/incremental_update_test.cpp index 8081ec2..68d86f5 100644 --- a/tests/smoke/incremental_update_test.cpp +++ b/tests/smoke/incremental_update_test.cpp @@ -14,6 +14,10 @@ #include #include +#if defined(__APPLE__) || defined(__unix__) +#include +#endif + namespace { void write_file(const std::filesystem::path& path, std::string contents) { @@ -107,6 +111,48 @@ int main() { } const auto created_root = graph->content_root; +#if defined(__APPLE__) || defined(__unix__) + // A watcher event for an indexed file whose existence cannot be checked + // must not throw out of the serve loop, and must not drop the file's nodes: + // Windows reports a file mid-save as "Access is denied", and an unsearchable + // parent fails the same check with EACCES here. Root bypasses directory + // permissions. The file is removed again so the index matches created_root. + if (::geteuid() != 0) { + const auto locked_dir = root / "locked"; + const auto delta = locked_dir / "delta.py"; + write_file(delta, "class Delta:\n pass\n"); + cgraph::FileWatchEvent delta_created[] = { + {.path = delta, .change = cgraph::FileWatchChange::Created, .kind = cgraph::WatchedFileKind::Code}, + }; + (void)cgraph::apply_incremental_code_updates(state, index, delta_created); + const bool indexed = has_node_label(*cgraph::read_graph_snapshot(state), "Delta"); + std::filesystem::permissions(locked_dir, std::filesystem::perms::none, std::filesystem::perm_options::replace); + cgraph::FileWatchEvent locked_event[] = { + {.path = delta, .change = cgraph::FileWatchChange::Modified, .kind = cgraph::WatchedFileKind::Code}, + }; + bool threw = false; + try { + (void)cgraph::apply_incremental_code_updates(state, index, locked_event); + } catch (const std::exception&) { + threw = true; + } + std::filesystem::permissions(locked_dir, std::filesystem::perms::owner_all, std::filesystem::perm_options::replace); + const bool kept = has_node_label(*cgraph::read_graph_snapshot(state), "Delta"); + std::filesystem::remove_all(locked_dir); + cgraph::FileWatchEvent delta_deleted[] = { + {.path = delta, .change = cgraph::FileWatchChange::Deleted, .kind = cgraph::WatchedFileKind::Code}, + }; + (void)cgraph::apply_incremental_code_updates(state, index, delta_deleted); + graph = cgraph::read_graph_snapshot(state); + if (!indexed || threw || !kept || has_node_label(*graph, "Delta") || + graph->content_root.sha256 != created_root.sha256) { + std::cerr << "FAIL: an unreadable indexed file threw or lost its nodes (indexed=" << indexed + << " threw=" << threw << " kept=" << kept << ")\n"; + return 1; + } + } +#endif + // A source that becomes unreadable after a verified publish must not replace // either the live snapshot or its incremental index with unverified state. const auto verified_snapshot = graph;