From 7ed558c1e7b378621e059825dc2e814e1de08641 Mon Sep 17 00:00:00 2001 From: ykiko Date: Sun, 29 Mar 2026 14:38:15 +0800 Subject: [PATCH] feat: add CompileGraph for pull-based module dependency compilation (#375) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Add `CompileGraph`, a pull-based async scheduler for C++20 module compilation. When a file is compiled that imports modules, the graph automatically resolves, builds, and caches PCM dependencies in the correct order before the main compile proceeds. ## Design ### Data model Each compilation unit (`CompileUnit`) tracks: - `dependencies` / `dependents` — forward and reverse dependency edges - `dirty` / `compiling` — current state flags - `generation` — monotonic counter incremented by `update()`, used for ABA-safe stale detection - `source` + `completion` — cancellation token source and completion event for cooperative async ### `compile(path_id)` — pull-based compilation Lazily resolves dependencies (via `resolve_fn`) on first access, then recursively compiles all transitive deps before dispatching the unit itself: - **Concurrent**: sibling deps compiled in parallel via `when_all` - **Dedup**: diamond dependencies (A->B->D, A->C->D) — the second branch waits on the first via `completion.wait()` instead of re-compiling - **Cycle detection**: per-branch `ancestors` set (passed by value) catches direct cycles; `has_wait_cycle()` BFS catches cross-branch cycles (e.g. `1->{2,3}, 2->3, 3->2`) that would deadlock at `completion.wait()` - **Cancellation**: all `co_await` wrapped with `with_token()`, so `update()` can cancel in-flight compilations immediately - **Generation check**: captures generation counter before `co_await`; if `update()` bumped it during dispatch, the result is discarded (unit stays dirty) ### `update(path_id)` — cascade invalidation BFS along `dependents` edges to mark the entire reverse-transitive closure as dirty. For the source node, clears `resolved` and dependency edges so they are re-scanned on next compile. Cancels any in-flight compilations via `source->cancel()`. ## Test plan 22 unit tests covering: - [x] No deps, single dep, chain, diamond (compile ordering + dedup) - [x] Update invalidation, cascade through chains and diamonds - [x] Re-resolution after update (deps can change) - [x] Stale back-edge cleanup - [x] Direct cycle detection (A->B->A) - [x] Cross-branch cycle detection (when_all deadlock case) - [x] Self-loop - [x] Dispatch failure propagation - [x] cancel_all + recompile - [x] Update during in-flight compile (cancellation + generation check) - [x] CI green on Linux, macOS, Windows --------- Co-authored-by: Claude Opus 4.6 --- CMakeLists.txt | 1 + src/server/compile_graph.cpp | 244 +++++++++ src/server/compile_graph.h | 81 +++ tests/unit/server/compile_graph_tests.cpp | 621 ++++++++++++++++++++++ 4 files changed, 947 insertions(+) create mode 100644 src/server/compile_graph.cpp create mode 100644 src/server/compile_graph.h create mode 100644 tests/unit/server/compile_graph_tests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 7798cfe9..08b2b533 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -172,6 +172,7 @@ add_library(clice-core STATIC "${PROJECT_SOURCE_DIR}/src/server/stateless_worker.cpp" "${PROJECT_SOURCE_DIR}/src/server/stateful_worker.cpp" "${PROJECT_SOURCE_DIR}/src/server/worker_pool.cpp" + "${PROJECT_SOURCE_DIR}/src/server/compile_graph.cpp" "${PROJECT_SOURCE_DIR}/src/server/master_server.cpp" "${PROJECT_SOURCE_DIR}/src/server/config.cpp" ) diff --git a/src/server/compile_graph.cpp b/src/server/compile_graph.cpp new file mode 100644 index 00000000..4a565e94 --- /dev/null +++ b/src/server/compile_graph.cpp @@ -0,0 +1,244 @@ +#include "server/compile_graph.h" + +#include + +#include "llvm/ADT/DenseSet.h" + +namespace clice { + +CompileGraph::CompileGraph(dispatch_fn dispatch, resolve_fn resolve) : + dispatch(std::move(dispatch)), resolve(std::move(resolve)) {} + +void CompileGraph::ensure_resolved(std::uint32_t path_id) { + auto& unit = units[path_id]; + if(unit.resolved) { + return; + } + + unit.path_id = path_id; + unit.resolved = true; + unit.dependencies = resolve(path_id); + + // Copy deps locally — the loop below may insert into `units`, + // which can rehash the DenseMap and invalidate the `unit` reference. + auto deps = units[path_id].dependencies; + + // Back-populate dependents. + for(auto dep_id: deps) { + auto& dep = units[dep_id]; + dep.path_id = dep_id; + dep.dependents.push_back(path_id); + } +} + +et::task CompileGraph::compile(std::uint32_t path_id) { + llvm::DenseSet ancestors; + co_return co_await compile_impl(path_id, ancestors); +} + +et::task CompileGraph::compile_impl(std::uint32_t path_id, + llvm::DenseSet ancestors) { + ensure_resolved(path_id); + + // Cycle detection: if this unit is already in the compile chain, bail out. + if(!ancestors.insert(path_id).second) { + co_return false; + } + + // Re-lookup after ensure_resolved may have mutated the map. + auto it = units.find(path_id); + + // Already clean. + if(!it->second.dirty) { + co_return true; + } + + // Another task is already compiling this unit — wait for it, + // but first check that waiting won't deadlock (cross-branch cycle). + if(it->second.compiling) { + if(has_wait_cycle(path_id, ancestors)) { + co_return false; + } + auto& completion = *it->second.completion; + co_await completion.wait(); + co_return !units.find(path_id)->second.dirty; + } + + // Begin compilation. + it->second.compiling = true; + it->second.completion = std::make_unique(); + + // Copy deps and capture generation before co_await (DenseMap iterator safety). + auto deps = it->second.dependencies; + auto gen = it->second.generation; + auto token = it->second.source->token(); + + // Compile all dependencies concurrently. + // Deadlocks from cross-branch cycles (e.g. 1->{2,3}, 2->3, 3->2) are + // prevented by has_wait_cycle() checking before completion.wait(). + if(!deps.empty()) { + std::vector> dep_tasks; + dep_tasks.reserve(deps.size()); + for(auto dep_id: deps) { + dep_tasks.push_back(et::with_token(compile_impl(dep_id, ancestors), token)); + } + + auto results = co_await et::when_all(std::move(dep_tasks)); + + auto& u = units.find(path_id)->second; + if(results.is_cancelled()) { + u.compiling = false; + u.completion->set(); + co_await et::cancel(); + } + + for(auto ok: *results) { + if(!ok) { + u.compiling = false; + u.completion->set(); + co_return false; + } + } + } + + // Dispatch the actual compilation, cancellable via the pre-captured token. + // Using the token captured before co_await ensures cancellation propagates + // correctly even if update() replaces the source during dependency compilation. + { + auto result = co_await et::with_token(dispatch(path_id), token); + + auto& u = units.find(path_id)->second; + if(!result.has_value()) { + u.compiling = false; + u.completion->set(); + co_await et::cancel(); + } + if(!*result) { + u.compiling = false; + u.completion->set(); + co_return false; + } + } + + // Success — only clear dirty if update() hasn't bumped the generation. + auto& final_unit = units.find(path_id)->second; + if(final_unit.generation != gen) { + // update() was called while dispatch was in flight. + final_unit.compiling = false; + final_unit.completion->set(); + co_return false; + } + final_unit.dirty = false; + final_unit.compiling = false; + final_unit.completion->set(); + co_return true; +} + +llvm::SmallVector CompileGraph::update(std::uint32_t path_id) { + llvm::SmallVector queue; + llvm::SmallVector dirtied; + queue.push_back(path_id); + + // Track visited nodes to avoid processing the same node twice. + llvm::DenseSet visited; + + while(!queue.empty()) { + auto current = queue.pop_back_val(); + + if(!visited.insert(current).second) { + continue; + } + + auto it = units.find(current); + if(it == units.end()) { + continue; + } + + auto& unit = it->second; + + // Reset resolved so dependencies are re-scanned on next compile + // (the source file may have added/removed imports). + if(current == path_id) { + unit.resolved = false; + // Clear stale dependency edges — they'll be rebuilt by ensure_resolved. + for(auto dep_id: unit.dependencies) { + auto dep_it = units.find(dep_id); + if(dep_it != units.end()) { + auto& dependents = dep_it->second.dependents; + dependents.erase(std::remove(dependents.begin(), dependents.end(), path_id), + dependents.end()); + } + } + unit.dependencies.clear(); + } + + // Cancel in-flight compilation if running. + if(unit.compiling) { + unit.source->cancel(); + unit.source = std::make_unique(); + } + unit.dirty = true; + unit.generation++; + dirtied.push_back(current); + + // Always propagate to dependents. + for(auto dep_id: unit.dependents) { + queue.push_back(dep_id); + } + } + + return dirtied; +} + +bool CompileGraph::has_wait_cycle(std::uint32_t target, + const llvm::DenseSet& ancestors) const { + // BFS through the target's dependency chain, following only compiling units. + // If any dependency is in our ancestor chain, waiting would deadlock. + llvm::SmallVector queue; + llvm::DenseSet visited; + queue.push_back(target); + + while(!queue.empty()) { + auto current = queue.pop_back_val(); + if(!visited.insert(current).second) { + continue; + } + auto it = units.find(current); + if(it == units.end()) { + continue; + } + for(auto dep_id: it->second.dependencies) { + if(ancestors.count(dep_id)) { + return true; + } + auto dep_it = units.find(dep_id); + if(dep_it != units.end() && dep_it->second.compiling) { + queue.push_back(dep_id); + } + } + } + return false; +} + +void CompileGraph::cancel_all() { + for(auto& [_, unit]: units) { + unit.source->cancel(); + unit.source = std::make_unique(); + } +} + +bool CompileGraph::has_unit(std::uint32_t path_id) const { + return units.count(path_id); +} + +bool CompileGraph::is_dirty(std::uint32_t path_id) const { + auto it = units.find(path_id); + return it != units.end() && it->second.dirty; +} + +bool CompileGraph::is_compiling(std::uint32_t path_id) const { + auto it = units.find(path_id); + return it != units.end() && it->second.compiling; +} + +} // namespace clice diff --git a/src/server/compile_graph.h b/src/server/compile_graph.h new file mode 100644 index 00000000..db2951d7 --- /dev/null +++ b/src/server/compile_graph.h @@ -0,0 +1,81 @@ +#pragma once + +#include +#include +#include + +#include "eventide/async/async.h" + +#include "llvm/ADT/DenseMap.h" +#include "llvm/ADT/DenseSet.h" +#include "llvm/ADT/SmallVector.h" + +namespace clice { + +namespace et = eventide; + +struct CompileUnit { + std::uint32_t path_id = 0; + + /// Dependencies discovered lazily by resolve_fn. + llvm::SmallVector dependencies; + + /// Back-edges: units that depend on this unit. + llvm::SmallVector dependents; + + /// Whether resolve_fn has been called for this unit. + bool resolved = false; + + bool dirty = true; + bool compiling = false; + + /// Monotonic counter bumped by update(); used by compile_impl to detect + /// stale completions without ABA risk from raw-pointer comparison. + std::uint64_t generation = 0; + + std::unique_ptr source = std::make_unique(); + std::unique_ptr completion; +}; + +class CompileGraph { +public: + /// Performs the actual compilation (e.g. produce PCM file). + using dispatch_fn = std::function(std::uint32_t path_id)>; + + /// Returns the dependency path_ids for a given path_id (called lazily on first compile). + using resolve_fn = std::function(std::uint32_t path_id)>; + + CompileGraph(dispatch_fn dispatch, resolve_fn resolve); + + /// Compile a unit and all its transitive dependencies. + et::task compile(std::uint32_t path_id); + + /// Mark path_id and all transitive dependents as dirty, + /// cancelling any in-progress compilations. + /// Returns the set of all path_ids that were marked dirty. + llvm::SmallVector update(std::uint32_t path_id); + + void cancel_all(); + + bool has_unit(std::uint32_t path_id) const; + bool is_dirty(std::uint32_t path_id) const; + bool is_compiling(std::uint32_t path_id) const; + +private: + /// Get or create a unit, resolving its dependencies if needed. + void ensure_resolved(std::uint32_t path_id); + + /// Internal compile with ancestor tracking for cycle detection. + et::task compile_impl(std::uint32_t path_id, llvm::DenseSet ancestors); + + /// Check if waiting on `target` would deadlock given our `ancestors` chain. + /// Walks the dependency graph through compiling units to see if any dep + /// transitively reaches a unit in our ancestor chain. + bool has_wait_cycle(std::uint32_t target, const llvm::DenseSet& ancestors) const; + + dispatch_fn dispatch; + resolve_fn resolve; + llvm::DenseMap units; +}; + +} // namespace clice diff --git a/tests/unit/server/compile_graph_tests.cpp b/tests/unit/server/compile_graph_tests.cpp new file mode 100644 index 00000000..3fc80f77 --- /dev/null +++ b/tests/unit/server/compile_graph_tests.cpp @@ -0,0 +1,621 @@ +#include "test/test.h" +#include "server/compile_graph.h" + +namespace clice::testing { +namespace { + +namespace et = eventide; + +/// A resolve_fn that always returns no dependencies. +inline CompileGraph::resolve_fn no_deps() { + return [](std::uint32_t) -> llvm::SmallVector { + return {}; + }; +} + +/// A resolve_fn backed by a static adjacency map. +inline CompileGraph::resolve_fn + static_resolver(llvm::DenseMap> adj) { + return [adj = std::move(adj)](std::uint32_t path_id) -> llvm::SmallVector { + auto it = adj.find(path_id); + if(it != adj.end()) { + return it->second; + } + return {}; + }; +} + +inline CompileGraph::dispatch_fn instant_dispatch() { + return [](std::uint32_t) -> et::task { + co_return true; + }; +} + +inline CompileGraph::dispatch_fn tracking_dispatch(std::vector& compiled) { + return [&compiled](std::uint32_t path_id) -> et::task { + compiled.push_back(path_id); + co_return true; + }; +} + +inline CompileGraph::dispatch_fn failing_dispatch() { + return [](std::uint32_t) -> et::task { + co_return false; + }; +} + +TEST_SUITE(CompileGraph) { + +TEST_CASE(CompileNoDeps) { + et::event_loop loop; + std::vector compiled; + CompileGraph graph(tracking_dispatch(compiled), no_deps()); + + auto test = [this, &graph, &compiled]() -> et::task<> { + auto result = co_await graph.compile(1).catch_cancel(); + EXPECT_TRUE(result.has_value()); + EXPECT_TRUE(*result); + EXPECT_EQ(compiled.size(), 1u); + EXPECT_EQ(compiled[0], 1u); + EXPECT_FALSE(graph.is_dirty(1)); + }; + + auto t = test(); + loop.schedule(t); + loop.run(); +} + +TEST_CASE(CompileWithDependency) { + et::event_loop loop; + std::vector compiled; + // Unit 1 depends on unit 2. + CompileGraph graph(tracking_dispatch(compiled), + static_resolver({ + {1, {2}} + })); + + auto test = [this, &graph, &compiled]() -> et::task<> { + auto result = co_await graph.compile(1).catch_cancel(); + EXPECT_TRUE(result.has_value()); + EXPECT_TRUE(*result); + // Both 2 (dep) and 1 (self) should be compiled, in that order. + EXPECT_EQ(compiled.size(), 2u); + auto pos2 = std::find(compiled.begin(), compiled.end(), 2u); + auto pos1 = std::find(compiled.begin(), compiled.end(), 1u); + EXPECT_TRUE(pos2 < pos1); + EXPECT_FALSE(graph.is_dirty(1)); + EXPECT_FALSE(graph.is_dirty(2)); + }; + + auto t = test(); + loop.schedule(t); + loop.run(); +} + +TEST_CASE(CompileChain) { + et::event_loop loop; + std::vector compiled; + // Chain: 1 -> 2 -> 3. + CompileGraph graph(tracking_dispatch(compiled), + static_resolver({ + {1, {2}}, + {2, {3}} + })); + + auto test = [this, &graph, &compiled]() -> et::task<> { + auto result = co_await graph.compile(1).catch_cancel(); + EXPECT_TRUE(result.has_value()); + EXPECT_TRUE(*result); + EXPECT_EQ(compiled.size(), 3u); + // 3 before 2 before 1. + auto pos3 = std::find(compiled.begin(), compiled.end(), 3u); + auto pos2 = std::find(compiled.begin(), compiled.end(), 2u); + auto pos1 = std::find(compiled.begin(), compiled.end(), 1u); + EXPECT_TRUE(pos3 < pos2); + EXPECT_TRUE(pos2 < pos1); + }; + + auto t = test(); + loop.schedule(t); + loop.run(); +} + +TEST_CASE(DiamondDependency) { + et::event_loop loop; + std::vector compiled; + // Diamond: 1 -> {2, 3}, 2 -> 4, 3 -> 4. + CompileGraph graph(tracking_dispatch(compiled), + static_resolver({ + {1, {2, 3}}, + {2, {4} }, + {3, {4} } + })); + + auto test = [this, &graph, &compiled]() -> et::task<> { + auto result = co_await graph.compile(1).catch_cancel(); + EXPECT_TRUE(result.has_value()); + EXPECT_TRUE(*result); + // Unit 4 should be compiled exactly once (dedup). + auto count4 = std::count(compiled.begin(), compiled.end(), 4u); + EXPECT_EQ(count4, 1); + EXPECT_FALSE(graph.is_dirty(2)); + EXPECT_FALSE(graph.is_dirty(3)); + EXPECT_FALSE(graph.is_dirty(4)); + }; + + auto t = test(); + loop.schedule(t); + loop.run(); +} + +TEST_CASE(UpdateInvalidates) { + et::event_loop loop; + // 1 -> 2. + CompileGraph graph(instant_dispatch(), + static_resolver({ + {1, {2}} + })); + + auto test = [this, &graph]() -> et::task<> { + co_await graph.compile(1).catch_cancel(); + EXPECT_FALSE(graph.is_dirty(2)); + EXPECT_FALSE(graph.is_dirty(1)); + + graph.update(2); + EXPECT_TRUE(graph.is_dirty(2)); + // Cascade: 1 depends on 2, so 1 should also be dirty. + EXPECT_TRUE(graph.is_dirty(1)); + }; + + auto t = test(); + loop.schedule(t); + loop.run(); +} + +TEST_CASE(UpdateCascade) { + et::event_loop loop; + // Chain: 1 -> 2 -> 3. + CompileGraph graph(instant_dispatch(), + static_resolver({ + {1, {2}}, + {2, {3}} + })); + + auto test = [this, &graph]() -> et::task<> { + co_await graph.compile(1).catch_cancel(); + EXPECT_FALSE(graph.is_dirty(2)); + EXPECT_FALSE(graph.is_dirty(3)); + + // Update leaf (3) — should cascade to 2 and 1. + graph.update(3); + EXPECT_TRUE(graph.is_dirty(3)); + EXPECT_TRUE(graph.is_dirty(2)); + EXPECT_TRUE(graph.is_dirty(1)); + }; + + auto t = test(); + loop.schedule(t); + loop.run(); +} + +TEST_CASE(CompileAfterUpdate) { + et::event_loop loop; + std::vector compiled; + // 1 -> 2. + CompileGraph graph(tracking_dispatch(compiled), + static_resolver({ + {1, {2}} + })); + + auto test = [this, &graph, &compiled]() -> et::task<> { + co_await graph.compile(1).catch_cancel(); + EXPECT_EQ(compiled.size(), 2u); + + graph.update(2); + co_await graph.compile(1).catch_cancel(); + // 2 and 1 should be recompiled. + EXPECT_EQ(compiled.size(), 4u); + }; + + auto t = test(); + loop.schedule(t); + loop.run(); +} + +TEST_CASE(DispatchFailure) { + et::event_loop loop; + // 1 -> 2. Dispatch always fails. + CompileGraph graph(failing_dispatch(), + static_resolver({ + {1, {2}} + })); + + auto test = [this, &graph]() -> et::task<> { + auto result = co_await graph.compile(1).catch_cancel(); + EXPECT_TRUE(result.has_value()); + EXPECT_FALSE(*result); + // Dep 2 failed, so it stays dirty. + EXPECT_TRUE(graph.is_dirty(2)); + }; + + auto t = test(); + loop.schedule(t); + loop.run(); +} + +TEST_CASE(CancelAll) { + CompileGraph graph(instant_dispatch(), no_deps()); + // Just verify it doesn't crash. + graph.cancel_all(); +} + +TEST_CASE(SecondCompileSkips) { + et::event_loop loop; + std::vector compiled; + CompileGraph graph(tracking_dispatch(compiled), no_deps()); + + auto test = [this, &graph, &compiled]() -> et::task<> { + co_await graph.compile(1).catch_cancel(); + EXPECT_EQ(compiled.size(), 1u); + // Second compile should skip (already clean). + co_await graph.compile(1).catch_cancel(); + EXPECT_EQ(compiled.size(), 1u); + }; + + auto t = test(); + loop.schedule(t); + loop.run(); +} + +TEST_CASE(CascadeThroughAlreadyDirty) { + et::event_loop loop; + // Chain: 1 -> 2 -> 3. + CompileGraph graph(instant_dispatch(), + static_resolver({ + {1, {2}}, + {2, {3}} + })); + + auto test = [this, &graph]() -> et::task<> { + co_await graph.compile(1).catch_cancel(); + + // Update node 2: marks 2 and 1 dirty. + graph.update(2); + EXPECT_TRUE(graph.is_dirty(1)); + EXPECT_TRUE(graph.is_dirty(2)); + EXPECT_FALSE(graph.is_dirty(3)); + + // Now update node 3: must cascade through already-dirty 2 to reach 1. + graph.update(3); + EXPECT_TRUE(graph.is_dirty(3)); + EXPECT_TRUE(graph.is_dirty(2)); + EXPECT_TRUE(graph.is_dirty(1)); + }; + + auto t = test(); + loop.schedule(t); + loop.run(); +} + +TEST_CASE(CircularDependencyDetection) { + et::event_loop loop; + // Cycle: 1 -> 2 -> 1. + CompileGraph graph(instant_dispatch(), + static_resolver({ + {1, {2}}, + {2, {1}} + })); + + auto test = [this, &graph]() -> et::task<> { + auto result = co_await graph.compile(1).catch_cancel(); + // Should return false (cycle detected), not deadlock. + EXPECT_TRUE(result.has_value()); + EXPECT_FALSE(*result); + }; + + auto t = test(); + loop.schedule(t); + loop.run(); +} + +TEST_CASE(CrossBranchCycleDetection) { + et::event_loop loop; + // Cross-branch cycle: 1 -> {2, 3}, 2 -> 3, 3 -> 2. + // With when_all, sibling branches could deadlock on each other's + // completion.wait() without proper deadlock detection. + CompileGraph graph(instant_dispatch(), + static_resolver({ + {1, {2, 3}}, + {2, {3} }, + {3, {2} } + })); + + auto test = [this, &graph]() -> et::task<> { + auto result = co_await graph.compile(1).catch_cancel(); + // Should return false (cycle detected), not deadlock. + EXPECT_TRUE(result.has_value()); + EXPECT_FALSE(*result); + }; + + auto t = test(); + loop.schedule(t); + loop.run(); +} + +TEST_CASE(UpdateResetsResolved) { + et::event_loop loop; + std::vector compiled; + int resolve_count = 0; + // 1 depends on {2} initially; after update, depends on {3}. + bool updated = false; + auto resolver = [&](std::uint32_t path_id) -> llvm::SmallVector { + if(path_id == 1) { + resolve_count++; + return updated ? llvm::SmallVector{3} + : llvm::SmallVector{2}; + } + return {}; + }; + + CompileGraph graph(tracking_dispatch(compiled), std::move(resolver)); + + auto test = [this, &graph, &compiled, &resolve_count, &updated]() -> et::task<> { + // First compile: resolves 1 -> {2}. + co_await graph.compile(1).catch_cancel(); + EXPECT_EQ(resolve_count, 1); + EXPECT_EQ(compiled.size(), 2u); // 2, then 1 + + // Update node 1: resets resolved, changes deps. + updated = true; + graph.update(1); + + // Recompile: should re-resolve 1 -> {3}. + co_await graph.compile(1).catch_cancel(); + EXPECT_EQ(resolve_count, 2); + // New dep 3 should be compiled, then 1 recompiled. + EXPECT_TRUE(std::find(compiled.begin() + 2, compiled.end(), 3u) != compiled.end()); + }; + + auto t = test(); + loop.schedule(t); + loop.run(); +} + +TEST_CASE(UpdateCleansStaleBackEdges) { + et::event_loop loop; + std::vector compiled; + bool updated = false; + auto resolver = [&](std::uint32_t path_id) -> llvm::SmallVector { + if(path_id == 1) { + // Initially depends on 2; after update, no deps. + return updated ? llvm::SmallVector{} + : llvm::SmallVector{2}; + } + return {}; + }; + + CompileGraph graph(tracking_dispatch(compiled), std::move(resolver)); + + auto test = [this, &graph, &compiled, &updated]() -> et::task<> { + // First compile: 1 -> {2}. + co_await graph.compile(1).catch_cancel(); + EXPECT_FALSE(graph.is_dirty(1)); + + // Update 1: resets resolved, removes dep on 2. + updated = true; + graph.update(1); + + // Recompile: 1 has no deps now. + co_await graph.compile(1).catch_cancel(); + EXPECT_FALSE(graph.is_dirty(1)); + + // Now update 2: should NOT cascade to 1 (back-edge was removed). + graph.update(2); + EXPECT_TRUE(graph.is_dirty(2)); + EXPECT_FALSE(graph.is_dirty(1)); + }; + + auto t = test(); + loop.schedule(t); + loop.run(); +} + +TEST_CASE(DiamondUpdateCascade) { + et::event_loop loop; + std::vector compiled; + // Diamond: 1 -> {2, 3}, 2 -> 4, 3 -> 4. + CompileGraph graph(tracking_dispatch(compiled), + static_resolver({ + {1, {2, 3}}, + {2, {4} }, + {3, {4} } + })); + + auto test = [this, &graph, &compiled]() -> et::task<> { + co_await graph.compile(1).catch_cancel(); + EXPECT_FALSE(graph.is_dirty(1)); + EXPECT_FALSE(graph.is_dirty(4)); + + // Update leaf 4: should cascade to 2, 3, and 1. + graph.update(4); + EXPECT_TRUE(graph.is_dirty(4)); + EXPECT_TRUE(graph.is_dirty(2)); + EXPECT_TRUE(graph.is_dirty(3)); + EXPECT_TRUE(graph.is_dirty(1)); + + compiled.clear(); + auto result = co_await graph.compile(1).catch_cancel(); + EXPECT_TRUE(result.has_value() && *result); + // Unit 4 should still be compiled exactly once (dedup on recompile). + auto count4 = std::count(compiled.begin(), compiled.end(), 4u); + EXPECT_EQ(count4, 1); + }; + + auto t = test(); + loop.schedule(t); + loop.run(); +} + +TEST_CASE(UpdateReturnsAllDirtied) { + et::event_loop loop; + // Chain: 1 -> 2 -> 3. + CompileGraph graph(instant_dispatch(), + static_resolver({ + {1, {2}}, + {2, {3}} + })); + + auto test = [this, &graph]() -> et::task<> { + co_await graph.compile(1).catch_cancel(); + + auto dirtied = graph.update(3); + // Should return 3, 2, 1 (all dirtied nodes). + EXPECT_EQ(dirtied.size(), 3u); + EXPECT_TRUE(llvm::find(dirtied, 1u) != dirtied.end()); + EXPECT_TRUE(llvm::find(dirtied, 2u) != dirtied.end()); + EXPECT_TRUE(llvm::find(dirtied, 3u) != dirtied.end()); + }; + + auto t = test(); + loop.schedule(t); + loop.run(); +} + +TEST_CASE(HasUnitAndIsCompiling) { + et::event_loop loop; + CompileGraph graph(instant_dispatch(), no_deps()); + + auto test = [this, &graph]() -> et::task<> { + EXPECT_FALSE(graph.has_unit(1)); + EXPECT_FALSE(graph.is_compiling(1)); + + co_await graph.compile(1).catch_cancel(); + EXPECT_TRUE(graph.has_unit(1)); + EXPECT_FALSE(graph.is_compiling(1)); + }; + + auto t = test(); + loop.schedule(t); + loop.run(); +} + +TEST_CASE(DispatchFailureLeavesDepDirty) { + et::event_loop loop; + // 1 -> 2. Dispatch always fails. + CompileGraph graph(failing_dispatch(), + static_resolver({ + {1, {2}} + })); + + auto test = [this, &graph]() -> et::task<> { + auto result = co_await graph.compile(1).catch_cancel(); + EXPECT_TRUE(result.has_value()); + EXPECT_FALSE(*result); + // Both dep and self should stay dirty. + EXPECT_TRUE(graph.is_dirty(2)); + EXPECT_TRUE(graph.is_dirty(1)); + }; + + auto t = test(); + loop.schedule(t); + loop.run(); +} + +TEST_CASE(SelfLoop) { + et::event_loop loop; + // Unit 1 depends on itself. + CompileGraph graph(instant_dispatch(), + static_resolver({ + {1, {1}} + })); + + auto test = [this, &graph]() -> et::task<> { + auto result = co_await graph.compile(1).catch_cancel(); + // Should detect cycle and return false, not deadlock. + EXPECT_TRUE(result.has_value()); + EXPECT_FALSE(*result); + }; + + auto t = test(); + loop.schedule(t); + loop.run(); +} + +TEST_CASE(CancelAllAndRecompile) { + et::event_loop loop; + std::vector compiled; + CompileGraph graph(tracking_dispatch(compiled), + static_resolver({ + {1, {2}} + })); + + auto test = [this, &graph, &compiled]() -> et::task<> { + co_await graph.compile(1).catch_cancel(); + EXPECT_EQ(compiled.size(), 2u); + EXPECT_FALSE(graph.is_dirty(1)); + EXPECT_FALSE(graph.is_dirty(2)); + + // cancel_all + update to mark dirty again. + graph.cancel_all(); + graph.update(2); + EXPECT_TRUE(graph.is_dirty(2)); + EXPECT_TRUE(graph.is_dirty(1)); + + // Recompile should succeed normally. + auto result = co_await graph.compile(1).catch_cancel(); + EXPECT_TRUE(result.has_value()); + EXPECT_TRUE(*result); + EXPECT_EQ(compiled.size(), 4u); + EXPECT_FALSE(graph.is_dirty(1)); + EXPECT_FALSE(graph.is_dirty(2)); + }; + + auto t = test(); + loop.schedule(t); + loop.run(); +} + +TEST_CASE(UpdateDuringCompile) { + et::event_loop loop; + et::event gate; + + auto gated_dispatch = [&gate](std::uint32_t) -> et::task { + co_await gate.wait(); + co_return true; + }; + + CompileGraph graph(std::move(gated_dispatch), no_deps()); + + bool compile_done = false; + bool was_cancelled = false; + + // Coroutine 1: compile(1), will suspend inside dispatch waiting on gate. + auto compiler = [&graph, &compile_done, &was_cancelled]() -> et::task<> { + auto result = co_await graph.compile(1).catch_cancel(); + compile_done = true; + was_cancelled = !result.has_value(); + }; + + // Coroutine 2: update(1) while dispatch is in flight, then unblock gate. + auto updater = [&graph, &gate]() -> et::task<> { + graph.update(1); + gate.set(); + co_return; + }; + + auto t1 = compiler(); + auto t2 = updater(); + loop.schedule(t1); + loop.schedule(t2); + loop.run(); + + // update() cancelled the source, so compile should have been cancelled. + EXPECT_TRUE(compile_done); + EXPECT_TRUE(was_cancelled); + EXPECT_TRUE(graph.is_dirty(1)); +} + +}; // TEST_SUITE(CompileGraph) + +} // namespace +} // namespace clice::testing