Skip to content
Closed
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
8 changes: 3 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: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.
Expand Down
42 changes: 42 additions & 0 deletions src/engine/atomic_write.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@
#endif
#include <windows.h>
#include <io.h>
#include <restartmanager.h>
#include <iostream>
#include <string>
#include <thread>
#pragma comment(lib, "Rstrtmgr.lib")
#else
#include <unistd.h>
#endif
Expand Down Expand Up @@ -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 << " <RmStartSession failed>\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 << " <RmGetList " << rc << ">";
}
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 (;;) {
Expand All @@ -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<int>(code), std::system_category()};
}
std::this_thread::sleep_for(pause);
Expand Down
10 changes: 10 additions & 0 deletions src/engine/daemon_server.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -941,9 +941,19 @@ int run_daemon_server(const std::filesystem::path& root, DaemonServerOptions opt
bool persisted_incremental = false;
{
const std::unique_lock<std::mutex> 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<std::chrono::milliseconds>(
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();
}
Expand Down
7 changes: 5 additions & 2 deletions src/engine/file_cache.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -271,13 +271,16 @@ FileCacheClassification classify_cached_file(
const std::filesystem::path& path,
std::optional<FileCacheEntry> 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};
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
52 changes: 42 additions & 10 deletions tests/smoke/daemon_server_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@
#include <fcntl.h>
#include <sys/stat.h>
#include <unistd.h>
#else
#ifndef NOMINMAX
#define NOMINMAX
#endif
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
#endif

namespace {
Expand All @@ -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<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,13 +448,14 @@ 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));
}
}
std::cerr << "DIAG " << std::chrono::duration_cast<std::chrono::milliseconds>(
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
Expand Down Expand Up @@ -942,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;
}
Expand Down Expand Up @@ -1062,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())) {
Expand Down Expand Up @@ -1174,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())) {
Expand All @@ -1190,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);
}

Expand Down
Loading