Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions src/engine/file_cache.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -271,13 +271,17 @@ FileCacheClassification classify_cached_file(
const std::filesystem::path& path,
std::optional<FileCacheEntry> 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};
Expand Down
8 changes: 6 additions & 2 deletions src/engine/incremental_update.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion src/engine/semantic_chunk_plan.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
41 changes: 31 additions & 10 deletions tests/smoke/daemon_server_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<char, 64 * 1024> buffer{};
DWORD read = 0;
while (::ReadFile(file, buffer.data(), static_cast<DWORD>(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<char>(input), std::istreambuf_iterator<char>()};
#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) {
Expand Down Expand Up @@ -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<char>(input)), std::istreambuf_iterator<char>());
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));
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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())) {
Expand Down Expand Up @@ -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())) {
Expand All @@ -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);
}

Expand Down
24 changes: 24 additions & 0 deletions tests/smoke/file_cache_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
}

#if defined(__APPLE__) || defined(__unix__)
[[nodiscard]] std::uint64_t max_resident_bytes(const rusage& usage) {

Check warning on line 42 in tests/smoke/file_cache_test.cpp

View workflow job for this annotation

GitHub Actions / macos-latest / sanitizers

unused function 'max_resident_bytes' [-Wunused-function]
#if defined(__APPLE__)
return static_cast<std::uint64_t>(usage.ru_maxrss);
#else
Expand Down Expand Up @@ -192,6 +192,30 @@
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");
Expand Down
46 changes: 46 additions & 0 deletions tests/smoke/incremental_update_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
#include <unordered_set>
#include <vector>

#if defined(__APPLE__) || defined(__unix__)
#include <unistd.h>
#endif

namespace {

void write_file(const std::filesystem::path& path, std::string contents) {
Expand Down Expand Up @@ -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;
Expand Down
Loading