diff --git a/src/server/compiler/compile_graph.cpp b/src/server/compiler/compile_graph.cpp index 39d4070dc..95c3894b5 100644 --- a/src/server/compiler/compile_graph.cpp +++ b/src/server/compiler/compile_graph.cpp @@ -1,15 +1,75 @@ #include "server/compiler/compile_graph.h" #include +#include +#include "llvm/ADT/ArrayRef.h" #include "llvm/ADT/DenseSet.h" namespace clice { namespace ranges = std::ranges; -CompileGraph::CompileGraph(dispatch_fn dispatch, resolve_fn resolve) : - dispatch(std::move(dispatch)), resolve(std::move(resolve)) {} +/// Request scope: holds root references for one compile()/compile_deps() +/// call. The destructor runs on every exit path, including cancellation +/// unwind of the requester's frame. +struct CompileGraph::RefGuard { + CompileGraph& graph; + llvm::SmallVector held; + + RefGuard(CompileGraph& graph, llvm::ArrayRef ids) : + graph(graph), held(ids.begin(), ids.end()) { + for(auto id: held) { + graph.acquire(id); + } + } + + RefGuard(const RefGuard&) = delete; + RefGuard& operator=(const RefGuard&) = delete; + + ~RefGuard() { + for(auto id: held) { + graph.release(id); + } + } +}; + +/// Maintains all per-round invariants of a unit task. kotatsu cancellation +/// destroys a suspended frame without resuming it, so code after a co_await +/// never runs on the cancel path — only destructors of locals established +/// before the first suspension are guaranteed to execute. This guard is that +/// destructor; the body must not maintain unit state any other way. +struct CompileGraph::UnitGuard { + CompileGraph& graph; + std::uint32_t path_id; + std::shared_ptr round; + CompileUnit::Outcome outcome = CompileUnit::Outcome::Stale; + + /// Edge references acquired by this task; registration here must stay + /// synchronous with the matching refcount increment. + llvm::SmallVector acquired; + + ~UnitGuard() { + // Publish the outcome, clear the compiling flag, release edge + // references, then wake waiters — all synchronous. Resumes triggered + // by cancel()/set() are deferred by the event loop, so nothing + // re-enters the graph mid-destructor. + round->outcome = outcome; + + auto& unit = graph.units.find(path_id)->second; + assert(unit.compiling && unit.round == round && "unit round bookkeeping out of sync"); + unit.compiling = false; + + for(auto dep_id: acquired) { + graph.release(dep_id); + } + + round->completion.set(); + } +}; + +CompileGraph::CompileGraph(kota::event_loop& loop, dispatch_fn dispatch, resolve_fn resolve) : + dispatch(std::move(dispatch)), resolve(std::move(resolve)), tasks(loop) {} void CompileGraph::ensure_resolved(std::uint32_t path_id) { auto& unit = units[path_id]; @@ -33,130 +93,201 @@ void CompileGraph::ensure_resolved(std::uint32_t path_id) { } } -kota::task CompileGraph::compile_deps(std::uint32_t path_id) { - llvm::DenseSet ancestors; - co_return co_await compile_impl(path_id, ancestors, false); +void CompileGraph::acquire(std::uint32_t path_id) { + auto& unit = units[path_id]; + unit.path_id = path_id; + unit.refcount += 1; } -kota::task CompileGraph::compile(std::uint32_t path_id) { - llvm::DenseSet ancestors; - co_return co_await compile_impl(path_id, ancestors); +void CompileGraph::release(std::uint32_t path_id) { + auto& unit = units.find(path_id)->second; + assert(unit.refcount > 0 && "released more interest than acquired"); + unit.refcount -= 1; + + // No interest left in an in-flight round. The drop is often transient — + // a stale round's edges being re-acquired by the retry that re-resolves + // it — so don't cancel right away: defer the decision by one event-loop + // tick. Synchronous re-acquisition happens within the current drain + // cycle, strictly before the check fires, so retained dependencies are + // handed over to the new round instead of being killed and restarted; + // only a sustained zero cancels. + if(unit.refcount == 0 && unit.compiling && !unit.zero_check_pending) { + unit.zero_check_pending = true; + if(!tasks.spawn(zero_interest_check(path_id))) { + // Graph is shutting down; everything gets cancelled anyway. + units.find(path_id)->second.zero_check_pending = false; + } + } } -kota::task CompileGraph::compile_impl(std::uint32_t path_id, - llvm::DenseSet ancestors, - bool dispatch_self) { - ensure_resolved(path_id); +kota::task<> CompileGraph::zero_interest_check(std::uint32_t path_id) { + co_await kota::sleep(0); - // Cycle detection: if this unit is already in the compile chain, bail out. - if(!ancestors.insert(path_id).second) { - co_return false; + auto& unit = units.find(path_id)->second; + unit.zero_check_pending = false; + if(unit.refcount == 0 && unit.compiling) { + // The task unwinds asynchronously and its guard finishes the + // bookkeeping, releasing its own edge references in turn (cascading + // the cancellation). + cancel_round(unit); } +} - // Re-lookup after ensure_resolved may have mutated the map. - auto it = units.find(path_id); +void CompileGraph::cancel_round(CompileUnit& unit) { + unit.source->cancel(); + unit.source = std::make_unique(); +} - // For deps-only mode, compile dependencies concurrently and return. - if(!dispatch_self) { - auto deps = it->second.dependencies; - if(deps.empty()) { - co_return true; - } +bool CompileGraph::spawn_unit(std::uint32_t path_id) { + auto& unit = units.find(path_id)->second; + assert(!unit.compiling && "spawn requested while a round is in flight"); + unit.compiling = true; + unit.round = std::make_shared(); + auto round = unit.round; + auto token = unit.source->token(); + + // spawn resumes the body synchronously up to its first suspension point, + // which may insert units and invalidate `unit` — don't touch it below. + if(tasks.spawn(unit_task(path_id, round, token))) { + return true; + } + + // The graph is shutting down: roll back so concurrent waiters observe a + // stale, completed round instead of hanging. + units.find(path_id)->second.compiling = false; + round->completion.set(); + return false; +} + +kota::task<> CompileGraph::unit_task(std::uint32_t path_id, + std::shared_ptr round, + kota::cancellation_token token) { + // The cancellation surfaces here as an explicit outcome instead of + // unwinding this wrapper, so the task always completes as Finished. + co_await kota::with_token(unit_body(path_id, std::move(round)), std::move(token)); +} + +kota::task<> CompileGraph::unit_body(std::uint32_t path_id, + std::shared_ptr round) { + UnitGuard guard{*this, path_id, std::move(round)}; + + ensure_resolved(path_id); + + auto& unit = units.find(path_id)->second; + auto gen = unit.generation; + // Copy deps — the map may rehash while this frame is suspended. + auto deps = unit.dependencies; + + // Trivial cycle: a unit depending on itself can never make progress. + if(ranges::contains(deps, path_id)) { + guard.outcome = CompileUnit::Outcome::Failed; + co_return; + } + + // Acquire edge references on all direct dependencies. This must stay + // synchronous: no suspension between refcount++ and guard registration. + for(auto dep_id: deps) { + acquire(dep_id); + guard.acquired.push_back(dep_id); + } - std::vector> dep_tasks; - dep_tasks.reserve(deps.size()); + if(!deps.empty()) { + std::vector> waits; + waits.reserve(deps.size()); for(auto dep_id: deps) { - dep_tasks.push_back(compile_impl(dep_id, ancestors)); + waits.push_back(await_unit(dep_id, path_id)); } - auto results = co_await kota::when_all(std::move(dep_tasks)); - for(auto ok: results) { - if(!ok) { - co_return false; - } + + auto results = co_await kota::when_all(std::move(waits)); + if(!ranges::all_of(results, [](bool ok) { return ok; })) { + guard.outcome = CompileUnit::Outcome::Failed; + co_return; } - co_return true; } - // Already clean. - if(!it->second.dirty) { - co_return true; + bool ok = co_await dispatch(path_id); + + // Synchronous tail: nothing can interleave between dispatch resuming us + // and co_return, so the checks below are atomic. + if(!ok) { + guard.outcome = CompileUnit::Outcome::Failed; + co_return; } - // 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; + auto& fresh = units.find(path_id)->second; + if(fresh.generation != gen) { + // update() raced with dispatch completion: our cancellation was + // signalled but this frame resumed first. The result is stale. + co_return; } - // Begin compilation. The finish lambda ensures compiling/completion state - // is always cleaned up, regardless of how the function exits. - it->second.compiling = true; - it->second.completion = std::make_unique(); - - auto finish = [&, path_id] { - auto& u = units.find(path_id)->second; - u.compiling = false; - u.completion->set(); - }; - - // 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(kota::with_token(compile_impl(dep_id, ancestors), token)); + fresh.dirty = false; + guard.outcome = CompileUnit::Outcome::Success; +} + +kota::task CompileGraph::await_unit(std::uint32_t path_id, + std::optional waiter) { + while(true) { + auto& unit = units.find(path_id)->second; + if(!unit.dirty) { + co_return true; + } + + if(!unit.compiling && !spawn_unit(path_id)) { + co_return false; // graph is shutting down } - auto results = co_await kota::when_all(std::move(dep_tasks)); + // Re-find: spawn_unit runs the unit body synchronously, which may + // rehash the map (and may even complete the round outright). + auto round = units.find(path_id)->second.round; - if(results.is_cancelled()) { - finish(); - co_await kota::cancel(); + // Blocking on a unit whose dependency chain reaches back to the + // waiting unit would deadlock — fail as a dependency cycle instead. + if(!round->completion.is_set() && waiter && has_wait_cycle(path_id, *waiter)) { + co_return false; } - for(auto ok: *results) { - if(!ok) { - finish(); - co_return false; - } + co_await round->completion.wait(); + + switch(round->outcome) { + case CompileUnit::Outcome::Success: co_return true; + case CompileUnit::Outcome::Failed: co_return false; + // The round was cancelled and produced no result; we still hold + // interest, so drive a new round. Each retry consumes one + // staleness event — without further updates this terminates. + case CompileUnit::Outcome::Stale: break; } } +} - // Dispatch the actual compilation, cancellable via the pre-captured token. - auto result = co_await kota::with_token(dispatch(path_id), token); +kota::task CompileGraph::compile(std::uint32_t path_id) { + // Request scope: one root reference, dropped when the requester exits or + // its frame is cancelled. + RefGuard scope(*this, {path_id}); + co_return co_await await_unit(path_id, std::nullopt); +} - if(!result.has_value()) { - finish(); - co_await kota::cancel(); - } +kota::task CompileGraph::compile_deps(std::uint32_t path_id) { + ensure_resolved(path_id); - if(!*result) { - finish(); - co_return false; + // Copy deps — the map may rehash while this frame is suspended. + auto deps = units.find(path_id)->second.dependencies; + if(deps.empty()) { + co_return true; } - // Success — only clear dirty if update() hasn't bumped the generation. - auto& final_unit = units.find(path_id)->second; - if(final_unit.generation != gen) { - finish(); - co_return false; + // Request scope: root references on each direct dependency (path_id + // itself is never dispatched here). + RefGuard scope(*this, deps); + + std::vector> waits; + waits.reserve(deps.size()); + for(auto dep_id: deps) { + waits.push_back(await_unit(dep_id, std::nullopt)); } - final_unit.dirty = false; - finish(); - co_return true; + auto results = co_await kota::when_all(std::move(waits)); + co_return ranges::all_of(results, [](bool ok) { return ok; }); } llvm::SmallVector CompileGraph::update(std::uint32_t path_id) { @@ -196,11 +327,10 @@ llvm::SmallVector CompileGraph::update(std::uint32_t path_id) { unit.dependencies.clear(); } - // Cancel in-flight compilation if running. - if(unit.compiling) { - unit.source->cancel(); - unit.source = std::make_unique(); - } + // The in-flight result (if any) is stale: cancel the round. Interest + // counts are untouched — waiters keep their references and drive a + // fresh round once the cancelled task unwinds. + cancel_round(unit); unit.dirty = true; unit.generation++; dirtied.push_back(current); @@ -214,10 +344,9 @@ llvm::SmallVector CompileGraph::update(std::uint32_t path_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. +bool CompileGraph::has_wait_cycle(std::uint32_t target, std::uint32_t waiter) const { + // BFS through the target's dependency chain, following only compiling + // units. If any dependency reaches the waiting unit, waiting would deadlock. llvm::SmallVector queue; llvm::DenseSet visited; queue.push_back(target); @@ -232,7 +361,7 @@ bool CompileGraph::has_wait_cycle(std::uint32_t target, continue; } for(auto dep_id: it->second.dependencies) { - if(ancestors.count(dep_id)) { + if(dep_id == waiter) { return true; } auto dep_it = units.find(dep_id); @@ -246,11 +375,18 @@ bool CompileGraph::has_wait_cycle(std::uint32_t target, void CompileGraph::cancel_all() { for(auto& [_, unit]: units) { - unit.source->cancel(); - unit.source = std::make_unique(); + cancel_round(unit); } } +kota::task<> CompileGraph::shutdown() { + // Structured two-step shutdown: cancel every unit task regardless of + // interest, then wait for the frames to unwind. The task group must be + // joined before destruction. + tasks.cancel(); + co_await tasks.join(); +} + bool CompileGraph::has_unit(std::uint32_t path_id) const { return units.count(path_id); } @@ -265,4 +401,24 @@ bool CompileGraph::is_compiling(std::uint32_t path_id) const { return it != units.end() && it->second.compiling; } +std::uint32_t CompileGraph::refcount(std::uint32_t path_id) const { + auto it = units.find(path_id); + return it != units.end() ? it->second.refcount : 0; +} + +bool CompileGraph::idle() const { + return ranges::all_of(units, [](const auto& entry) { + const auto& unit = entry.second; + bool round_done = !unit.round || unit.round->completion.is_set(); + return !unit.compiling && unit.refcount == 0 && round_done; + }); +} + +bool CompileGraph::consistent() const { + return ranges::all_of(units, [](const auto& entry) { + const auto& unit = entry.second; + return !unit.compiling || (unit.round && !unit.round->completion.is_set()); + }); +} + } // namespace clice diff --git a/src/server/compiler/compile_graph.h b/src/server/compiler/compile_graph.h index 3e411088b..7dc83e36a 100644 --- a/src/server/compiler/compile_graph.h +++ b/src/server/compiler/compile_graph.h @@ -3,15 +3,35 @@ #include #include #include +#include #include "kota/async/async.h" #include "llvm/ADT/DenseMap.h" -#include "llvm/ADT/DenseSet.h" #include "llvm/ADT/SmallVector.h" namespace clice { struct CompileUnit { + /// Result of one compilation round, observed by waiters after the + /// round's completion event fires. + enum class Outcome : std::uint8_t { + /// The round was cancelled (file update or loss of interest) and its + /// result discarded; waiters that still hold interest retry. + Stale, + Success, + /// Dispatch failed or a dependency cycle was detected; waiters + /// propagate the failure instead of retrying. + Failed, + }; + + /// State of one compilation round. Waiters capture the shared_ptr before + /// suspending so the completion event outlives both map mutations and + /// the unit task that publishes the outcome. + struct Round { + kota::event completion; + Outcome outcome = Outcome::Stale; + }; + std::uint32_t path_id = 0; /// Dependencies discovered lazily by resolve_fn. @@ -26,15 +46,52 @@ struct CompileUnit { 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. + /// In-flight interest count: one per requesting root plus one per edge + /// held by a dependent unit's running task. Staying at zero while + /// compiling cancels this unit's round. Not a lifetime count. + std::uint32_t refcount = 0; + + /// A zero-interest cancellation check is already queued for this unit. + bool zero_check_pending = false; + + /// Monotonic counter bumped by update(); detects results that became + /// stale while the cancellation raced with a dispatch completion. std::uint64_t generation = 0; + /// Cancellation scope of the current round; replaced after every cancel + /// so the next round always starts with a fresh token. std::unique_ptr source = std::make_unique(); - std::unique_ptr completion; + + /// Current (or most recent) compilation round. + std::shared_ptr round; }; +/// Module compilation DAG with interest-counted cancellation. +/// +/// Each dirty unit is compiled by an independent task spawned into the +/// graph's task group, cancellable only through its own round token — +/// requesters and dependent units merely wait on the round's completion +/// event. Lifecycle by phase: +/// +/// - Request arrival: compile()/compile_deps() takes a root reference on the +/// requested unit(s) and waits. A dirty unit with no running round gets a +/// unit task spawned; the task acquires edge references on its direct +/// dependencies, waits for them, then dispatches its own compilation. +/// - Request cancel: the requester's frame unwinds and drops its root +/// reference. A unit whose interest stays at zero for an event-loop tick +/// has its round cancelled; the exiting task drops its edge references, +/// cascading the cancellation through no-longer-needed dependencies. +/// Shared dependencies keep compiling as long as any other consumer holds +/// interest, and a transient drop (a retry re-acquiring a retained +/// dependency after re-resolve) does not disturb the running compilation. +/// - File update: update() marks the unit and its transitive dependents +/// dirty and cancels their in-flight rounds — the results are stale. +/// Interest is NOT touched; waiters observe the stale round and drive a +/// fresh one with the new content. +/// - Compile finish: the unit task publishes success/failure through its +/// round and wakes waiters; success clears dirty, failure (compile error, +/// dependency cycle) propagates to waiters without retry. class CompileGraph { public: /// Performs the actual compilation (e.g. produce PCM file). @@ -43,7 +100,7 @@ class CompileGraph { /// 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); + CompileGraph(kota::event_loop& loop, dispatch_fn dispatch, resolve_fn resolve); /// Compile a unit and all its transitive dependencies. kota::task compile(std::uint32_t path_id); @@ -53,33 +110,95 @@ class CompileGraph { kota::task compile_deps(std::uint32_t path_id); /// Mark path_id and all transitive dependents as dirty, - /// cancelling any in-progress compilations. + /// cancelling any in-progress compilations (their results are stale). /// Returns the set of all path_ids that were marked dirty. llvm::SmallVector update(std::uint32_t path_id); + /// Cancel every in-flight round regardless of interest. Waiters that + /// still hold interest respawn their units afterwards. void cancel_all(); + /// Cancel all unit tasks and wait for their frames to unwind. Must be + /// awaited exactly once before the graph is destroyed; no compilation + /// can be started afterwards. + kota::task<> shutdown(); + 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; + /// Current in-flight interest count for a unit (testing/diagnostics). + std::uint32_t refcount(std::uint32_t path_id) const; + + /// All bookkeeping is quiesced: nothing compiling, no interest held and + /// every round's completion has fired. Holds whenever no request is in + /// flight and all unit tasks have unwound (e.g. after shutdown()). + bool idle() const; + + /// Structural sanity that holds at every drain boundary: a compiling + /// unit has an unfinished round, and a finished round never leaves the + /// compiling flag behind. + bool consistent() const; + private: + struct RefGuard; + struct UnitGuard; + /// 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. - kota::task compile_impl(std::uint32_t path_id, - llvm::DenseSet ancestors, - bool dispatch_self = true); - - /// 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; + /// Interest +1; creates the unit if needed. + void acquire(std::uint32_t path_id); + + /// Interest -1; schedules a zero-interest cancellation check when it + /// drops to zero mid-compile. + void release(std::uint32_t path_id); + + /// Cancels path_id's round if its interest is still zero one event-loop + /// tick after release() saw it drop. The delay lets transient drops + /// survive: a retry respawning the unit after update() re-acquires its + /// retained dependencies within the same drain cycle, so their in-flight + /// compilations are handed over instead of being killed and restarted. + kota::task<> zero_interest_check(std::uint32_t path_id); + + /// Cancel the current round and install a fresh cancellation scope. + void cancel_round(CompileUnit& unit); + + /// Start a unit task for path_id in the graph's task group. + /// Returns false when the graph is shutting down. + bool spawn_unit(std::uint32_t path_id); + + /// One compilation round of a unit, cancelled only through its round token. + kota::task<> unit_body(std::uint32_t path_id, std::shared_ptr round); + + /// Spawned wrapper: runs unit_body under the round token and absorbs the + /// cancellation outcome. The task group treats a cancelled child as a + /// reason to abort every sibling (structured fail-fast); a round + /// cancelled via its token is a normal event and must not do that. + kota::task<> unit_task(std::uint32_t path_id, + std::shared_ptr round, + kota::cancellation_token token); + + /// Wait until path_id reaches a terminal outcome, respawning its unit + /// task whenever a stale round invalidates the previous attempt. + /// `waiter` is the unit doing the waiting (for deadlock detection), + /// or nullopt for requests. + kota::task await_unit(std::uint32_t path_id, std::optional waiter); + + /// Check if waiting on `target` would deadlock: walks the dependency + /// graph through compiling units to see if any dependency transitively + /// reaches the waiting unit. + bool has_wait_cycle(std::uint32_t target, std::uint32_t waiter) const; dispatch_fn dispatch; resolve_fn resolve; llvm::DenseMap units; + + /// Owns every unit task; structured shutdown via shutdown(). + /// Note: kota::task_group only reclaims completed child frames on + /// destruction, so frames accumulate over the graph's lifetime — one per + /// compilation round, same trade-off as Compiler::compile_tasks. + kota::task_group<> tasks; }; } // namespace clice diff --git a/src/server/compiler/compiler.cpp b/src/server/compiler/compiler.cpp index 5f3075e2d..990d2bfaf 100644 --- a/src/server/compiler/compiler.cpp +++ b/src/server/compiler/compiler.cpp @@ -40,6 +40,12 @@ Compiler::~Compiler() { kota::task<> Compiler::stop() { compile_tasks.cancel(); co_await compile_tasks.join(); + + // Requests have unwound and released their interest; now tear down the + // module compile graph's own unit tasks. + if(workspace.compile_graph) { + co_await workspace.compile_graph->shutdown(); + } } void Compiler::init_compile_graph() { @@ -147,7 +153,7 @@ void Compiler::init_compile_graph() { }; workspace.compile_graph = - std::make_unique(std::move(dispatch), std::move(resolve)); + std::make_unique(loop, std::move(dispatch), std::move(resolve)); LOG_INFO("CompileGraph initialized with {} module(s)", workspace.path_to_module.size()); } @@ -562,11 +568,23 @@ kota::task Compiler::ensure_deps(Session& session, const std::string& directory, const std::vector& arguments, std::pair& pch, - std::unordered_map& pcms) { + std::unordered_map& pcms, + std::optional scope) { auto path_id = session.path_id; + // Compile module dependencies within the request scope: cancelling the + // scope unwinds the wait and releases this request's interest in the + // dependency graph, without touching the shared compilations themselves. + auto compile_deps = [&](std::uint32_t pid) -> kota::task { + if(!scope) { + co_return co_await workspace.compile_graph->compile_deps(pid); + } + auto result = co_await kota::with_token(workspace.compile_graph->compile_deps(pid), *scope); + co_return result.has_value() && *result; + }; + // Compile C++20 module dependencies (PCMs). - if(workspace.compile_graph && !co_await workspace.compile_graph->compile_deps(path_id)) { + if(workspace.compile_graph && !co_await compile_deps(path_id)) { co_return false; } @@ -584,7 +602,7 @@ kota::task Compiler::ensure_deps(Session& session, // If PCM not already built, try to build it. if(workspace.pcm_paths.find(pid) == workspace.pcm_paths.end()) { if(workspace.compile_graph && workspace.compile_graph->has_unit(pid)) { - co_await workspace.compile_graph->compile_deps(pid); + co_await compile_deps(pid); } } found = true; @@ -597,6 +615,12 @@ kota::task Compiler::ensure_deps(Session& session, } } + // The buffer-scan waits above tolerate failed PCM builds, but a cancelled + // scope means this round was superseded — abandon it before the PCH step. + if(scope && scope->cancelled()) { + co_return false; + } + // Build or reuse PCH. auto pch_ok = co_await ensure_pch(session, directory, arguments); if(pch_ok) { @@ -673,7 +697,14 @@ kota::task<> Compiler::run_compile(std::uint32_t pid, std::shared_ptrdeps_scope.token()); + pc->deps_done = true; + if(!deps_ok) { LOG_WARN("Dependency preparation failed for {}, skipping compile", uri_str); finish_compile(); co_return; @@ -685,6 +716,18 @@ kota::task<> Compiler::run_compile(std::uint32_t pid, std::shared_ptrgeneration != gen) { + LOG_INFO("ensure_compiled: superseded before send ({} vs {}) for {}", + sess->generation, + gen, + uri_str); + finish_compile(); + co_return; + } + auto result = co_await pool.send_stateful(pid, params); sess = find_session(); @@ -770,24 +813,42 @@ kota::task Compiler::ensure_compiled(Session& session) { session.ast_dirty = true; } - // If another compile is already in flight, wait for it. + // If an up-to-date compile is already in flight, wait for it. // This co_await may be cancelled by LSP $/cancelRequest — that's fine, // it just means this particular feature request is abandoned. The // detached compile task keeps running independently. while(session.compiling) { auto pending = session.compiling; + if(pending->generation != session.generation && !pending->deps_done) { + // The in-flight compile is stale (user edited since it started) + // and still holds interest in the module graph — supersede it. + // A stale compile already past its dependency phase is left to + // finish instead: superseding it gains nothing (the worker send + // is not cancellable), and waiting coalesces rapid edits into a + // single follow-up compile at the latest generation. + break; + } co_await pending->done.wait(); if(!session.ast_dirty) co_return true; } + auto superseded = session.compiling; auto pending_compile = std::make_shared(); + pending_compile->generation = session.generation; session.compiling = pending_compile; LOG_INFO("ensure_compiled: launching compile path_id={} gen={}", path_id, session.generation); + // Spawn the replacement before cancelling the superseded compile: the new + // round acquires its module-dependency interest synchronously, so shared + // dependencies never see their interest drop to zero across the swap. compile_tasks.spawn(run_compile(path_id, pending_compile)); + if(superseded) { + superseded->deps_scope.cancel(); + } + // Wait for the detached compile to finish. If this wait is cancelled // by LSP $/cancelRequest, the detached task continues unaffected. co_await pending_compile->done.wait(); diff --git a/src/server/compiler/compiler.h b/src/server/compiler/compiler.h index 7ffd840fc..4339c3703 100644 --- a/src/server/compiler/compiler.h +++ b/src/server/compiler/compiler.h @@ -109,11 +109,14 @@ class Compiler { private: kota::task<> run_compile(std::uint32_t path_id, std::shared_ptr pc); + /// @param scope When set, cancels the module-dependency wait if this + /// compile round is superseded by a newer one. kota::task ensure_deps(Session& session, const std::string& directory, const std::vector& arguments, std::pair& pch, - std::unordered_map& pcms); + std::unordered_map& pcms, + std::optional scope = {}); kota::task ensure_pch(Session& session, const std::string& directory, diff --git a/src/server/compiler/indexer.cpp b/src/server/compiler/indexer.cpp index 4248574ef..2012ff9a4 100644 --- a/src/server/compiler/indexer.cpp +++ b/src/server/compiler/indexer.cpp @@ -16,6 +16,7 @@ #include "kota/ipc/lsp/position.h" #include "kota/ipc/lsp/protocol.h" #include "kota/ipc/lsp/uri.h" +#include "llvm/ADT/DenseSet.h" #include "llvm/Support/FileSystem.h" #include "llvm/Support/MemoryBuffer.h" #include "llvm/Support/Path.h" diff --git a/src/server/service/session.h b/src/server/service/session.h index 0d66c688c..d9ebeaca3 100644 --- a/src/server/service/session.h +++ b/src/server/service/session.h @@ -45,6 +45,19 @@ struct Session { struct PendingCompile { kota::event done; bool succeeded = false; + + /// Generation snapshot at spawn; a later didChange supersedes this compile. + std::uint64_t generation = 0; + + /// True once module dependencies are settled and the compile has moved + /// on to the worker phase. Past this point the compile holds no + /// interest in the module graph and superseding it gains nothing — + /// stale waiters coalesce on its completion instead. + bool deps_done = false; + + /// Cancels the module-dependency wait when this compile is superseded, + /// releasing its interest in the old dependency set. + kota::cancellation_source deps_scope; }; std::shared_ptr compiling; diff --git a/tests/unit/server/compile_graph_integration_tests.cpp b/tests/unit/server/compile_graph_integration_tests.cpp index beb155350..4d1300fbd 100644 --- a/tests/unit/server/compile_graph_integration_tests.cpp +++ b/tests/unit/server/compile_graph_integration_tests.cpp @@ -108,12 +108,55 @@ struct ModuleTestEnv { TEST_SUITE(CompileGraphIntegration) { -// ============================================================================ -// Basic module interface units -// ============================================================================ +ModuleTestEnv env; +std::optional loop; +std::optional cg; -TEST_CASE(SingleModuleNoDeps) { - ModuleTestEnv env; +CompileGraph::dispatch_fn default_dispatch() { + return make_dispatch(env.cdb, env.toolchain, env.pool, env.graph, env.pcm_paths); +} + +CompileGraph::resolve_fn default_resolver() { + return make_resolver(env.cdb, env.toolchain, env.pool, env.graph); +} + +void make_graph(CompileGraph::dispatch_fn dispatch, CompileGraph::resolve_fn resolve) { + loop.emplace(); + cg.emplace(*loop, std::move(dispatch), std::move(resolve)); +} + +void make_graph() { + make_graph(default_dispatch(), default_resolver()); +} + +/// Zero-interest cancellation is deferred by one event-loop tick per cascade +/// level; wait (bounded) until `pred` holds before asserting settled state. +template +kota::task<> settle(Pred pred) { + for(int i = 0; i < 100 && !pred(); i++) { + co_await kota::sleep(1); + } + EXPECT_TRUE(pred()); +} + +/// Run the test body, then verify the shutdown protocol leaves the graph idle. +template +void execute(F&& fn) { + auto wrapper = [&]() -> kota::task<> { + co_await fn(); + co_await cg->shutdown(); + EXPECT_TRUE(cg->idle()); + }; + auto t = wrapper(); + loop->schedule(t); + loop->run(); +} + +/// ============================================================================ +/// Basic module interface units +/// ============================================================================ + +TEST_CASE(single_module) { env.tmp.touch("mod_a.cppm", "export module A;\n" "export int foo() { return 42; }\n"); auto json = build_cdb_json({ @@ -124,23 +167,16 @@ TEST_CASE(SingleModuleNoDeps) { ASSERT_FALSE(env.graph.lookup_module("A").empty()); auto pid_a = env.lookup("A"); - CompileGraph cg(make_dispatch(env.cdb, env.toolchain, env.pool, env.graph, env.pcm_paths), - make_resolver(env.cdb, env.toolchain, env.pool, env.graph)); - - kota::event_loop loop; - auto test = [this, &cg, &env, pid_a]() -> kota::task<> { - auto result = co_await cg.compile(pid_a).catch_cancel(); + make_graph(); + execute([&]() -> kota::task<> { + auto result = co_await cg->compile(pid_a).catch_cancel(); EXPECT_TRUE(result.has_value()); EXPECT_TRUE(*result); EXPECT_TRUE(env.pcm_paths.contains(pid_a)); - }; - auto t = test(); - loop.schedule(t); - loop.run(); + }); } -TEST_CASE(ChainedModules) { - ModuleTestEnv env; +TEST_CASE(chained_modules) { env.tmp.touch("mod_a.cppm", "export module A;\n" "export int foo() { return 42; }\n"); env.tmp.touch("mod_b.cppm", "export module B;\n" @@ -158,24 +194,17 @@ TEST_CASE(ChainedModules) { ASSERT_NE(pid_a, UINT32_MAX); ASSERT_NE(pid_b, UINT32_MAX); - CompileGraph cg(make_dispatch(env.cdb, env.toolchain, env.pool, env.graph, env.pcm_paths), - make_resolver(env.cdb, env.toolchain, env.pool, env.graph)); - - kota::event_loop loop; - auto test = [this, &cg, &env, pid_a, pid_b]() -> kota::task<> { - auto result = co_await cg.compile(pid_b).catch_cancel(); + make_graph(); + execute([&]() -> kota::task<> { + auto result = co_await cg->compile(pid_b).catch_cancel(); EXPECT_TRUE(result.has_value()); EXPECT_TRUE(*result); EXPECT_TRUE(env.pcm_paths.contains(pid_a)); EXPECT_TRUE(env.pcm_paths.contains(pid_b)); - }; - auto t = test(); - loop.schedule(t); - loop.run(); + }); } -TEST_CASE(DiamondModules) { - ModuleTestEnv env; +TEST_CASE(diamond_modules) { env.tmp.touch("mod_base.cppm", "export module Base;\n" "export int base_val() { return 10; }\n"); env.tmp.touch("mod_left.cppm", @@ -203,27 +232,20 @@ TEST_CASE(DiamondModules) { auto pid_top = env.lookup("Top"); ASSERT_NE(pid_top, UINT32_MAX); - CompileGraph cg(make_dispatch(env.cdb, env.toolchain, env.pool, env.graph, env.pcm_paths), - make_resolver(env.cdb, env.toolchain, env.pool, env.graph)); - - kota::event_loop loop; - auto test = [this, &cg, &env, pid_top]() -> kota::task<> { - auto result = co_await cg.compile(pid_top).catch_cancel(); + make_graph(); + execute([&]() -> kota::task<> { + auto result = co_await cg->compile(pid_top).catch_cancel(); EXPECT_TRUE(result.has_value()); EXPECT_TRUE(*result); EXPECT_EQ(env.pcm_paths.size(), 4u); - }; - auto t = test(); - loop.schedule(t); - loop.run(); + }); } -// ============================================================================ -// Dotted module names -// ============================================================================ +/// ============================================================================ +/// Dotted module names +/// ============================================================================ -TEST_CASE(DottedModuleName) { - ModuleTestEnv env; +TEST_CASE(dotted_module_name) { env.tmp.touch("io.cppm", "export module my.io;\n" "export void print() {}\n"); env.tmp.touch("app.cppm", "export module my.app;\n" @@ -239,27 +261,20 @@ TEST_CASE(DottedModuleName) { auto pid_app = env.lookup("my.app"); ASSERT_NE(pid_app, UINT32_MAX); - CompileGraph cg(make_dispatch(env.cdb, env.toolchain, env.pool, env.graph, env.pcm_paths), - make_resolver(env.cdb, env.toolchain, env.pool, env.graph)); - - kota::event_loop loop; - auto test = [this, &cg, &env, pid_app]() -> kota::task<> { - auto result = co_await cg.compile(pid_app).catch_cancel(); + make_graph(); + execute([&]() -> kota::task<> { + auto result = co_await cg->compile(pid_app).catch_cancel(); EXPECT_TRUE(result.has_value()); EXPECT_TRUE(*result); EXPECT_EQ(env.pcm_paths.size(), 2u); - }; - auto t = test(); - loop.schedule(t); - loop.run(); + }); } -// ============================================================================ -// Re-export (export import) -// ============================================================================ +/// ============================================================================ +/// Re-export (export import) +/// ============================================================================ -TEST_CASE(ReExport) { - ModuleTestEnv env; +TEST_CASE(re_export) { env.tmp.touch("core.cppm", "export module Core;\n" "export int core_fn() { return 1; }\n"); env.tmp.touch("wrapper.cppm", "export module Wrapper;\n" @@ -281,27 +296,20 @@ TEST_CASE(ReExport) { auto pid_user = env.lookup("User"); ASSERT_NE(pid_user, UINT32_MAX); - CompileGraph cg(make_dispatch(env.cdb, env.toolchain, env.pool, env.graph, env.pcm_paths), - make_resolver(env.cdb, env.toolchain, env.pool, env.graph)); - - kota::event_loop loop; - auto test = [this, &cg, &env, pid_user]() -> kota::task<> { - auto result = co_await cg.compile(pid_user).catch_cancel(); + make_graph(); + execute([&]() -> kota::task<> { + auto result = co_await cg->compile(pid_user).catch_cancel(); EXPECT_TRUE(result.has_value()); EXPECT_TRUE(*result); EXPECT_EQ(env.pcm_paths.size(), 3u); - }; - auto t = test(); - loop.schedule(t); - loop.run(); + }); } -// ============================================================================ -// Export block syntax -// ============================================================================ +/// ============================================================================ +/// Export block syntax +/// ============================================================================ -TEST_CASE(ExportBlock) { - ModuleTestEnv env; +TEST_CASE(export_block) { env.tmp.touch("block.cppm", "export module Block;\n" "export {\n" @@ -325,27 +333,20 @@ TEST_CASE(ExportBlock) { auto pid = env.lookup("Consumer"); ASSERT_NE(pid, UINT32_MAX); - CompileGraph cg(make_dispatch(env.cdb, env.toolchain, env.pool, env.graph, env.pcm_paths), - make_resolver(env.cdb, env.toolchain, env.pool, env.graph)); - - kota::event_loop loop; - auto test = [this, &cg, &env, pid]() -> kota::task<> { - auto result = co_await cg.compile(pid).catch_cancel(); + make_graph(); + execute([&]() -> kota::task<> { + auto result = co_await cg->compile(pid).catch_cancel(); EXPECT_TRUE(result.has_value()); EXPECT_TRUE(*result); EXPECT_EQ(env.pcm_paths.size(), 2u); - }; - auto t = test(); - loop.schedule(t); - loop.run(); + }); } -// ============================================================================ -// Global module fragment -// ============================================================================ +/// ============================================================================ +/// Global module fragment +/// ============================================================================ -TEST_CASE(GlobalModuleFragment) { - ModuleTestEnv env; +TEST_CASE(global_module_fragment) { env.tmp.touch("legacy.h", "inline int legacy_fn() { return 99; }\n"); env.tmp.touch("gmf.cppm", "module;\n" @@ -361,27 +362,20 @@ TEST_CASE(GlobalModuleFragment) { auto pid = env.lookup("GMF"); ASSERT_NE(pid, UINT32_MAX); - CompileGraph cg(make_dispatch(env.cdb, env.toolchain, env.pool, env.graph, env.pcm_paths), - make_resolver(env.cdb, env.toolchain, env.pool, env.graph)); - - kota::event_loop loop; - auto test = [this, &cg, &env, pid]() -> kota::task<> { - auto result = co_await cg.compile(pid).catch_cancel(); + make_graph(); + execute([&]() -> kota::task<> { + auto result = co_await cg->compile(pid).catch_cancel(); EXPECT_TRUE(result.has_value()); EXPECT_TRUE(*result); EXPECT_TRUE(env.pcm_paths.contains(pid)); - }; - auto t = test(); - loop.schedule(t); - loop.run(); + }); } -// ============================================================================ -// Private module fragment -// ============================================================================ +/// ============================================================================ +/// Private module fragment +/// ============================================================================ -TEST_CASE(PrivateModuleFragment) { - ModuleTestEnv env; +TEST_CASE(private_module_fragment) { env.tmp.touch("priv.cppm", "export module Priv;\n" "export int public_fn();\n" @@ -397,27 +391,20 @@ TEST_CASE(PrivateModuleFragment) { auto pid = env.lookup("Priv"); ASSERT_NE(pid, UINT32_MAX); - CompileGraph cg(make_dispatch(env.cdb, env.toolchain, env.pool, env.graph, env.pcm_paths), - make_resolver(env.cdb, env.toolchain, env.pool, env.graph)); - - kota::event_loop loop; - auto test = [this, &cg, &env, pid]() -> kota::task<> { - auto result = co_await cg.compile(pid).catch_cancel(); + make_graph(); + execute([&]() -> kota::task<> { + auto result = co_await cg->compile(pid).catch_cancel(); EXPECT_TRUE(result.has_value()); EXPECT_TRUE(*result); EXPECT_TRUE(env.pcm_paths.contains(pid)); - }; - auto t = test(); - loop.schedule(t); - loop.run(); + }); } -// ============================================================================ -// Module partitions — interface partition -// ============================================================================ +/// ============================================================================ +/// Module partitions — interface partition +/// ============================================================================ -TEST_CASE(PartitionInterface) { - ModuleTestEnv env; +TEST_CASE(partition_interface) { // Partition interface unit. env.tmp.touch("part.cppm", "export module M:Part;\n" "export int part_fn() { return 5; }\n"); // Primary module interface re-exports the partition. @@ -437,28 +424,21 @@ TEST_CASE(PartitionInterface) { ASSERT_NE(pid_m, UINT32_MAX); ASSERT_NE(env.lookup("M:Part"), UINT32_MAX); - CompileGraph cg(make_dispatch(env.cdb, env.toolchain, env.pool, env.graph, env.pcm_paths), - make_resolver(env.cdb, env.toolchain, env.pool, env.graph)); - - kota::event_loop loop; - auto test = [this, &cg, &env, pid_m]() -> kota::task<> { - auto result = co_await cg.compile(pid_m).catch_cancel(); + make_graph(); + execute([&]() -> kota::task<> { + auto result = co_await cg->compile(pid_m).catch_cancel(); EXPECT_TRUE(result.has_value()); EXPECT_TRUE(*result); // Both partition and primary should be compiled. EXPECT_EQ(env.pcm_paths.size(), 2u); - }; - auto t = test(); - loop.schedule(t); - loop.run(); + }); } -// ============================================================================ -// Multiple partitions -// ============================================================================ +/// ============================================================================ +/// Multiple partitions +/// ============================================================================ -TEST_CASE(MultiplePartitions) { - ModuleTestEnv env; +TEST_CASE(multiple_partitions) { env.tmp.touch("part_a.cppm", "export module Lib:A;\n" "export int a_fn() { return 1; }\n"); env.tmp.touch("part_b.cppm", "export module Lib:B;\n" "export int b_fn() { return 2; }\n"); env.tmp.touch("lib.cppm", @@ -477,28 +457,21 @@ TEST_CASE(MultiplePartitions) { auto pid_lib = env.lookup("Lib"); ASSERT_NE(pid_lib, UINT32_MAX); - CompileGraph cg(make_dispatch(env.cdb, env.toolchain, env.pool, env.graph, env.pcm_paths), - make_resolver(env.cdb, env.toolchain, env.pool, env.graph)); - - kota::event_loop loop; - auto test = [this, &cg, &env, pid_lib]() -> kota::task<> { - auto result = co_await cg.compile(pid_lib).catch_cancel(); + make_graph(); + execute([&]() -> kota::task<> { + auto result = co_await cg->compile(pid_lib).catch_cancel(); EXPECT_TRUE(result.has_value()); EXPECT_TRUE(*result); // Lib:A, Lib:B, and Lib. EXPECT_EQ(env.pcm_paths.size(), 3u); - }; - auto t = test(); - loop.schedule(t); - loop.run(); + }); } -// ============================================================================ -// Partition importing another partition (within same module) -// ============================================================================ +/// ============================================================================ +/// Partition importing another partition (within same module) +/// ============================================================================ -TEST_CASE(PartitionChain) { - ModuleTestEnv env; +TEST_CASE(partition_chain) { env.tmp.touch("types.cppm", "export module Sys:Types;\n" "export struct Config { int value = 0; };\n"); env.tmp.touch("core.cppm", @@ -520,28 +493,21 @@ TEST_CASE(PartitionChain) { auto pid_sys = env.lookup("Sys"); ASSERT_NE(pid_sys, UINT32_MAX); - CompileGraph cg(make_dispatch(env.cdb, env.toolchain, env.pool, env.graph, env.pcm_paths), - make_resolver(env.cdb, env.toolchain, env.pool, env.graph)); - - kota::event_loop loop; - auto test = [this, &cg, &env, pid_sys]() -> kota::task<> { - auto result = co_await cg.compile(pid_sys).catch_cancel(); + make_graph(); + execute([&]() -> kota::task<> { + auto result = co_await cg->compile(pid_sys).catch_cancel(); EXPECT_TRUE(result.has_value()); EXPECT_TRUE(*result); // Sys:Types, Sys:Core, Sys. EXPECT_EQ(env.pcm_paths.size(), 3u); - }; - auto t = test(); - loop.schedule(t); - loop.run(); + }); } -// ============================================================================ -// Module with exported namespace -// ============================================================================ +/// ============================================================================ +/// Module with exported namespace +/// ============================================================================ -TEST_CASE(ExportNamespace) { - ModuleTestEnv env; +TEST_CASE(export_namespace) { env.tmp.touch("ns.cppm", "export module NS;\n" "export namespace math {\n" @@ -562,27 +528,20 @@ TEST_CASE(ExportNamespace) { auto pid = env.lookup("Calc"); ASSERT_NE(pid, UINT32_MAX); - CompileGraph cg(make_dispatch(env.cdb, env.toolchain, env.pool, env.graph, env.pcm_paths), - make_resolver(env.cdb, env.toolchain, env.pool, env.graph)); - - kota::event_loop loop; - auto test = [this, &cg, &env, pid]() -> kota::task<> { - auto result = co_await cg.compile(pid).catch_cancel(); + make_graph(); + execute([&]() -> kota::task<> { + auto result = co_await cg->compile(pid).catch_cancel(); EXPECT_TRUE(result.has_value()); EXPECT_TRUE(*result); EXPECT_EQ(env.pcm_paths.size(), 2u); - }; - auto t = test(); - loop.schedule(t); - loop.run(); + }); } -// ============================================================================ -// GMF with include + module import -// ============================================================================ +/// ============================================================================ +/// GMF with include + module import +/// ============================================================================ -TEST_CASE(GMFWithImport) { - ModuleTestEnv env; +TEST_CASE(gmf_with_import) { env.tmp.touch("util.h", "inline int util_helper() { return 7; }\n"); env.tmp.touch("base.cppm", "export module Base;\n" "export int base() { return 100; }\n"); env.tmp.touch("combined.cppm", @@ -601,27 +560,20 @@ TEST_CASE(GMFWithImport) { auto pid = env.lookup("Combined"); ASSERT_NE(pid, UINT32_MAX); - CompileGraph cg(make_dispatch(env.cdb, env.toolchain, env.pool, env.graph, env.pcm_paths), - make_resolver(env.cdb, env.toolchain, env.pool, env.graph)); - - kota::event_loop loop; - auto test = [this, &cg, &env, pid]() -> kota::task<> { - auto result = co_await cg.compile(pid).catch_cancel(); + make_graph(); + execute([&]() -> kota::task<> { + auto result = co_await cg->compile(pid).catch_cancel(); EXPECT_TRUE(result.has_value()); EXPECT_TRUE(*result); EXPECT_EQ(env.pcm_paths.size(), 2u); - }; - auto t = test(); - loop.schedule(t); - loop.run(); + }); } -// ============================================================================ -// Deep chain (5 modules) -// ============================================================================ +/// ============================================================================ +/// Deep chain (5 modules) +/// ============================================================================ -TEST_CASE(DeepChain) { - ModuleTestEnv env; +TEST_CASE(deep_chain) { env.tmp.touch("m1.cppm", "export module M1;\n" "export int f1() { return 1; }\n"); env.tmp.touch("m2.cppm", "export module M2;\n" @@ -652,27 +604,20 @@ TEST_CASE(DeepChain) { auto pid = env.lookup("M5"); ASSERT_NE(pid, UINT32_MAX); - CompileGraph cg(make_dispatch(env.cdb, env.toolchain, env.pool, env.graph, env.pcm_paths), - make_resolver(env.cdb, env.toolchain, env.pool, env.graph)); - - kota::event_loop loop; - auto test = [this, &cg, &env, pid]() -> kota::task<> { - auto result = co_await cg.compile(pid).catch_cancel(); + make_graph(); + execute([&]() -> kota::task<> { + auto result = co_await cg->compile(pid).catch_cancel(); EXPECT_TRUE(result.has_value()); EXPECT_TRUE(*result); EXPECT_EQ(env.pcm_paths.size(), 5u); - }; - auto t = test(); - loop.schedule(t); - loop.run(); + }); } -// ============================================================================ -// Multiple independent modules (no shared deps) -// ============================================================================ +/// ============================================================================ +/// Multiple independent modules (no shared deps) +/// ============================================================================ -TEST_CASE(IndependentModules) { - ModuleTestEnv env; +TEST_CASE(independent_modules) { env.tmp.touch("x.cppm", "export module X;\n" "export int x() { return 1; }\n"); env.tmp.touch("y.cppm", "export module Y;\n" "export int y() { return 2; }\n"); @@ -687,28 +632,21 @@ TEST_CASE(IndependentModules) { ASSERT_NE(pid_x, UINT32_MAX); ASSERT_NE(pid_y, UINT32_MAX); - CompileGraph cg(make_dispatch(env.cdb, env.toolchain, env.pool, env.graph, env.pcm_paths), - make_resolver(env.cdb, env.toolchain, env.pool, env.graph)); - - kota::event_loop loop; - auto test = [this, &cg, &env, pid_x, pid_y]() -> kota::task<> { - auto r1 = co_await cg.compile(pid_x).catch_cancel(); + make_graph(); + execute([&]() -> kota::task<> { + auto r1 = co_await cg->compile(pid_x).catch_cancel(); EXPECT_TRUE(r1.has_value() && *r1); - auto r2 = co_await cg.compile(pid_y).catch_cancel(); + auto r2 = co_await cg->compile(pid_y).catch_cancel(); EXPECT_TRUE(r2.has_value() && *r2); EXPECT_EQ(env.pcm_paths.size(), 2u); - }; - auto t = test(); - loop.schedule(t); - loop.run(); + }); } -// ============================================================================ -// Module with template exports -// ============================================================================ +/// ============================================================================ +/// Module with template exports +/// ============================================================================ -TEST_CASE(TemplateExport) { - ModuleTestEnv env; +TEST_CASE(template_export) { env.tmp.touch("tmpl.cppm", "export module Tmpl;\n" "export template\n" @@ -729,27 +667,20 @@ TEST_CASE(TemplateExport) { auto pid = env.lookup("UseTmpl"); ASSERT_NE(pid, UINT32_MAX); - CompileGraph cg(make_dispatch(env.cdb, env.toolchain, env.pool, env.graph, env.pcm_paths), - make_resolver(env.cdb, env.toolchain, env.pool, env.graph)); - - kota::event_loop loop; - auto test = [this, &cg, &env, pid]() -> kota::task<> { - auto result = co_await cg.compile(pid).catch_cancel(); + make_graph(); + execute([&]() -> kota::task<> { + auto result = co_await cg->compile(pid).catch_cancel(); EXPECT_TRUE(result.has_value()); EXPECT_TRUE(*result); EXPECT_EQ(env.pcm_paths.size(), 2u); - }; - auto t = test(); - loop.schedule(t); - loop.run(); + }); } -// ============================================================================ -// Module with class export and inheritance across modules -// ============================================================================ +/// ============================================================================ +/// Module with class export and inheritance across modules +/// ============================================================================ -TEST_CASE(ClassExportAndInheritance) { - ModuleTestEnv env; +TEST_CASE(class_export_inheritance) { env.tmp.touch("shape.cppm", "export module Shape;\n" "export class Shape {\n" @@ -776,27 +707,20 @@ TEST_CASE(ClassExportAndInheritance) { auto pid = env.lookup("Circle"); ASSERT_NE(pid, UINT32_MAX); - CompileGraph cg(make_dispatch(env.cdb, env.toolchain, env.pool, env.graph, env.pcm_paths), - make_resolver(env.cdb, env.toolchain, env.pool, env.graph)); - - kota::event_loop loop; - auto test = [this, &cg, &env, pid]() -> kota::task<> { - auto result = co_await cg.compile(pid).catch_cancel(); + make_graph(); + execute([&]() -> kota::task<> { + auto result = co_await cg->compile(pid).catch_cancel(); EXPECT_TRUE(result.has_value()); EXPECT_TRUE(*result); EXPECT_EQ(env.pcm_paths.size(), 2u); - }; - auto t = test(); - loop.schedule(t); - loop.run(); + }); } -// ============================================================================ -// Recompile after update (invalidation + recompile) -// ============================================================================ +/// ============================================================================ +/// Recompile after update (invalidation + recompile) +/// ============================================================================ -TEST_CASE(RecompileAfterUpdate) { - ModuleTestEnv env; +TEST_CASE(recompile_after_update) { env.tmp.touch("leaf.cppm", "export module Leaf;\n" "export int leaf() { return 1; }\n"); env.tmp.touch("mid.cppm", "export module Mid;\n" @@ -814,40 +738,33 @@ TEST_CASE(RecompileAfterUpdate) { ASSERT_NE(pid_leaf, UINT32_MAX); ASSERT_NE(pid_mid, UINT32_MAX); - CompileGraph cg(make_dispatch(env.cdb, env.toolchain, env.pool, env.graph, env.pcm_paths), - make_resolver(env.cdb, env.toolchain, env.pool, env.graph)); - - kota::event_loop loop; - auto test = [this, &cg, &env, pid_leaf, pid_mid]() -> kota::task<> { + make_graph(); + execute([&]() -> kota::task<> { // First compile. - auto r1 = co_await cg.compile(pid_mid).catch_cancel(); + auto r1 = co_await cg->compile(pid_mid).catch_cancel(); EXPECT_TRUE(r1.has_value() && *r1); EXPECT_EQ(env.pcm_paths.size(), 2u); - EXPECT_FALSE(cg.is_dirty(pid_leaf)); - EXPECT_FALSE(cg.is_dirty(pid_mid)); + EXPECT_FALSE(cg->is_dirty(pid_leaf)); + EXPECT_FALSE(cg->is_dirty(pid_mid)); // Simulate editing Leaf — should cascade to Mid. - cg.update(pid_leaf); - EXPECT_TRUE(cg.is_dirty(pid_leaf)); - EXPECT_TRUE(cg.is_dirty(pid_mid)); + cg->update(pid_leaf); + EXPECT_TRUE(cg->is_dirty(pid_leaf)); + EXPECT_TRUE(cg->is_dirty(pid_mid)); // Recompile. - auto r2 = co_await cg.compile(pid_mid).catch_cancel(); + auto r2 = co_await cg->compile(pid_mid).catch_cancel(); EXPECT_TRUE(r2.has_value() && *r2); - EXPECT_FALSE(cg.is_dirty(pid_leaf)); - EXPECT_FALSE(cg.is_dirty(pid_mid)); - }; - auto t = test(); - loop.schedule(t); - loop.run(); + EXPECT_FALSE(cg->is_dirty(pid_leaf)); + EXPECT_FALSE(cg->is_dirty(pid_mid)); + }); } -// ============================================================================ -// Partition with GMF (#include inside global module fragment of partition) -// ============================================================================ +/// ============================================================================ +/// Partition with GMF (#include inside global module fragment of partition) +/// ============================================================================ -TEST_CASE(PartitionWithGMF) { - ModuleTestEnv env; +TEST_CASE(partition_with_gmf) { env.tmp.touch("config.h", "#define MAX_SIZE 100\n"); env.tmp.touch("part_cfg.cppm", "module;\n" @@ -865,27 +782,20 @@ TEST_CASE(PartitionWithGMF) { auto pid = env.lookup("Cfg"); ASSERT_NE(pid, UINT32_MAX); - CompileGraph cg(make_dispatch(env.cdb, env.toolchain, env.pool, env.graph, env.pcm_paths), - make_resolver(env.cdb, env.toolchain, env.pool, env.graph)); - - kota::event_loop loop; - auto test = [this, &cg, &env, pid]() -> kota::task<> { - auto result = co_await cg.compile(pid).catch_cancel(); + make_graph(); + execute([&]() -> kota::task<> { + auto result = co_await cg->compile(pid).catch_cancel(); EXPECT_TRUE(result.has_value()); EXPECT_TRUE(*result); EXPECT_EQ(env.pcm_paths.size(), 2u); - }; - auto t = test(); - loop.schedule(t); - loop.run(); + }); } -// ============================================================================ -// Cross-module partition + external import -// ============================================================================ +/// ============================================================================ +/// Cross-module partition + external import +/// ============================================================================ -TEST_CASE(PartitionWithExternalImport) { - ModuleTestEnv env; +TEST_CASE(partition_external_import) { // External module. env.tmp.touch("ext.cppm", "export module Ext;\n" "export int ext_val() { return 99; }\n"); // Partition that imports the external module. @@ -906,28 +816,21 @@ TEST_CASE(PartitionWithExternalImport) { auto pid_app = env.lookup("App"); ASSERT_NE(pid_app, UINT32_MAX); - CompileGraph cg(make_dispatch(env.cdb, env.toolchain, env.pool, env.graph, env.pcm_paths), - make_resolver(env.cdb, env.toolchain, env.pool, env.graph)); - - kota::event_loop loop; - auto test = [this, &cg, &env, pid_app]() -> kota::task<> { - auto result = co_await cg.compile(pid_app).catch_cancel(); + make_graph(); + execute([&]() -> kota::task<> { + auto result = co_await cg->compile(pid_app).catch_cancel(); EXPECT_TRUE(result.has_value()); EXPECT_TRUE(*result); // Ext, App:Core, App. EXPECT_EQ(env.pcm_paths.size(), 3u); - }; - auto t = test(); - loop.schedule(t); - loop.run(); + }); } -// ============================================================================ -// Diamond update cascade + recompile -// ============================================================================ +/// ============================================================================ +/// Diamond update cascade + recompile +/// ============================================================================ -TEST_CASE(DiamondUpdateCascade) { - ModuleTestEnv env; +TEST_CASE(diamond_update_cascade) { env.tmp.touch("mod_base.cppm", "export module Base;\n" "export int base_val() { return 10; }\n"); env.tmp.touch("mod_left.cppm", @@ -959,13 +862,10 @@ TEST_CASE(DiamondUpdateCascade) { ASSERT_NE(pid_base, UINT32_MAX); ASSERT_NE(pid_top, UINT32_MAX); - CompileGraph cg(make_dispatch(env.cdb, env.toolchain, env.pool, env.graph, env.pcm_paths), - make_resolver(env.cdb, env.toolchain, env.pool, env.graph)); - - kota::event_loop loop; - auto test = [this, &cg, &env, pid_base, pid_left, pid_right, pid_top]() -> kota::task<> { + make_graph(); + execute([&]() -> kota::task<> { // Initial compile. - auto r1 = co_await cg.compile(pid_top).catch_cancel(); + auto r1 = co_await cg->compile(pid_top).catch_cancel(); EXPECT_TRUE(r1.has_value() && *r1); EXPECT_EQ(env.pcm_paths.size(), 4u); @@ -973,11 +873,11 @@ TEST_CASE(DiamondUpdateCascade) { auto old_base_pcm = env.pcm_paths[pid_base]; // Update base: should cascade to Left, Right, Top. - auto dirtied = cg.update(pid_base); - EXPECT_TRUE(cg.is_dirty(pid_base)); - EXPECT_TRUE(cg.is_dirty(pid_left)); - EXPECT_TRUE(cg.is_dirty(pid_right)); - EXPECT_TRUE(cg.is_dirty(pid_top)); + auto dirtied = cg->update(pid_base); + EXPECT_TRUE(cg->is_dirty(pid_base)); + EXPECT_TRUE(cg->is_dirty(pid_left)); + EXPECT_TRUE(cg->is_dirty(pid_right)); + EXPECT_TRUE(cg->is_dirty(pid_top)); // Simulate MasterServer: erase stale PCMs for all dirtied nodes. for(auto id: dirtied) { @@ -986,23 +886,19 @@ TEST_CASE(DiamondUpdateCascade) { EXPECT_EQ(env.pcm_paths.size(), 0u); // Recompile. - auto r2 = co_await cg.compile(pid_top).catch_cancel(); + auto r2 = co_await cg->compile(pid_top).catch_cancel(); EXPECT_TRUE(r2.has_value() && *r2); EXPECT_EQ(env.pcm_paths.size(), 4u); // PCM path should have changed (new temp file). EXPECT_NE(env.pcm_paths[pid_base], old_base_pcm); - }; - auto t = test(); - loop.schedule(t); - loop.run(); + }); } -// ============================================================================ -// Verify resolve_fn is re-invoked after update (resolved=false) -// ============================================================================ +/// ============================================================================ +/// Verify resolve_fn is re-invoked after update (resolved=false) +/// ============================================================================ -TEST_CASE(ReResolveAfterUpdate) { - ModuleTestEnv env; +TEST_CASE(re_resolve_after_update) { // Start with Mid importing Leaf. env.tmp.touch("leaf.cppm", "export module Leaf;\n" "export int leaf() { return 1; }\n"); env.tmp.touch("extra.cppm", "export module Extra;\n" "export int extra() { return 99; }\n"); @@ -1025,57 +921,36 @@ TEST_CASE(ReResolveAfterUpdate) { ASSERT_NE(pid_extra, UINT32_MAX); int resolve_count = 0; - auto counting_resolver = [&](std::uint32_t path_id) -> llvm::SmallVector { + auto counting_resolver = + [&, inner = default_resolver()](std::uint32_t path_id) -> llvm::SmallVector { if(path_id == pid_mid) { resolve_count++; } - // Delegate to the standard resolver. - auto file_path = env.pool.resolve(path_id); - auto results = env.cdb.lookup(file_path); - if(results.empty()) { - return {}; - } - env.toolchain.resolve_or_warn(results[0]); - auto scan_result = scan_precise(results[0].to_argv(), results[0].resolved.directory); - llvm::SmallVector deps; - for(auto& mod_name: scan_result.modules) { - auto mod_ids = env.graph.lookup_module(mod_name); - if(!mod_ids.empty()) { - deps.push_back(mod_ids[0]); - } - } - return deps; + return inner(path_id); }; - CompileGraph cg(make_dispatch(env.cdb, env.toolchain, env.pool, env.graph, env.pcm_paths), - std::move(counting_resolver)); - - kota::event_loop loop; - auto test = [this, &cg, &env, &resolve_count, pid_mid]() -> kota::task<> { + make_graph(default_dispatch(), std::move(counting_resolver)); + execute([&]() -> kota::task<> { // First compile: resolve_fn called once for Mid. - auto r1 = co_await cg.compile(pid_mid).catch_cancel(); + auto r1 = co_await cg->compile(pid_mid).catch_cancel(); EXPECT_TRUE(r1.has_value() && *r1); EXPECT_EQ(resolve_count, 1); // Update Mid: resets resolved. - cg.update(pid_mid); + cg->update(pid_mid); // Recompile: resolve_fn should be called again. - auto r2 = co_await cg.compile(pid_mid).catch_cancel(); + auto r2 = co_await cg->compile(pid_mid).catch_cancel(); EXPECT_TRUE(r2.has_value() && *r2); EXPECT_EQ(resolve_count, 2); - }; - auto t = test(); - loop.schedule(t); - loop.run(); + }); } -// ============================================================================ -// Compilation failure propagation (real clang error) -// ============================================================================ +/// ============================================================================ +/// Compilation failure propagation (real clang error) +/// ============================================================================ -TEST_CASE(CompileFailurePropagation) { - ModuleTestEnv env; +TEST_CASE(compile_failure_propagation) { // Good module. env.tmp.touch("good.cppm", "export module Good;\n" "export int good() { return 1; }\n"); // Bad module with syntax error. @@ -1093,12 +968,9 @@ TEST_CASE(CompileFailurePropagation) { auto pid_bad = env.lookup("Bad"); ASSERT_NE(pid_bad, UINT32_MAX); - CompileGraph cg(make_dispatch(env.cdb, env.toolchain, env.pool, env.graph, env.pcm_paths), - make_resolver(env.cdb, env.toolchain, env.pool, env.graph)); - - kota::event_loop loop; - auto test = [this, &cg, &env, pid_bad]() -> kota::task<> { - auto result = co_await cg.compile(pid_bad).catch_cancel(); + make_graph(); + execute([&]() -> kota::task<> { + auto result = co_await cg->compile(pid_bad).catch_cancel(); EXPECT_TRUE(result.has_value()); // Compilation should fail due to undefined symbol. EXPECT_FALSE(*result); @@ -1107,18 +979,14 @@ TEST_CASE(CompileFailurePropagation) { EXPECT_TRUE(env.pcm_paths.contains(pid_good)); // Bad module should NOT have a PCM. EXPECT_FALSE(env.pcm_paths.contains(pid_bad)); - }; - auto t = test(); - loop.schedule(t); - loop.run(); + }); } -// ============================================================================ -// Module implementation unit (consumes PCM, doesn't produce one) -// ============================================================================ +/// ============================================================================ +/// Module implementation unit (consumes PCM, doesn't produce one) +/// ============================================================================ -TEST_CASE(ModuleImplementationUnit) { - ModuleTestEnv env; +TEST_CASE(module_implementation_unit) { // Module interface unit — produces PCM. env.tmp.touch("iface.cppm", "export module Greeter;\n" "export const char* greet();\n"); // Module implementation unit — consumes PCM, no export. @@ -1134,13 +1002,10 @@ TEST_CASE(ModuleImplementationUnit) { auto pid_iface = env.lookup("Greeter"); ASSERT_NE(pid_iface, UINT32_MAX); - CompileGraph cg(make_dispatch(env.cdb, env.toolchain, env.pool, env.graph, env.pcm_paths), - make_resolver(env.cdb, env.toolchain, env.pool, env.graph)); - - kota::event_loop loop; - auto test = [this, &cg, &env, pid_iface]() -> kota::task<> { + make_graph(); + execute([&]() -> kota::task<> { // Build the interface PCM via CompileGraph. - auto r1 = co_await cg.compile(pid_iface).catch_cancel(); + auto r1 = co_await cg->compile(pid_iface).catch_cancel(); EXPECT_TRUE(r1.has_value() && *r1); EXPECT_TRUE(env.pcm_paths.contains(pid_iface)); @@ -1166,10 +1031,188 @@ TEST_CASE(ModuleImplementationUnit) { auto unit = compile(cp); EXPECT_TRUE(unit.completed()); + }); +} + +/// ============================================================================ +/// Shared dependency: switching import target must not kill or restart it +/// ============================================================================ + +TEST_CASE(shared_dep_import_switch) { + env.tmp.touch("shared.cppm", + "export module Shared;\n" "export int shared_val() { return 1; }\n"); + env.tmp.touch("a.cppm", + "export module A;\n" + "import Shared;\n" + "export int a_val() { return shared_val(); }\n"); + env.tmp.touch("c.cppm", + "export module C;\n" + "import Shared;\n" + "export int c_val() { return shared_val(); }\n"); + + auto json = build_cdb_json({ + {env.tmp.root, env.tmp.path("shared.cppm"), {}}, + {env.tmp.root, env.tmp.path("a.cppm"), {}}, + {env.tmp.root, env.tmp.path("c.cppm"), {}}, + }); + env.setup({}, json); + + auto pid_shared = env.lookup("Shared"); + auto pid_a = env.lookup("A"); + auto pid_c = env.lookup("C"); + ASSERT_NE(pid_shared, UINT32_MAX); + ASSERT_NE(pid_a, UINT32_MAX); + ASSERT_NE(pid_c, UINT32_MAX); + + // Gate the shared module's dispatch so the import switch can be injected + // while it is still compiling. + kota::event shared_started; + kota::event shared_proceed; + int shared_calls = 0; + auto inner = default_dispatch(); + auto dispatch = [&](std::uint32_t pid) -> kota::task { + if(pid == pid_shared) { + shared_calls += 1; + shared_started.set(); + co_await shared_proceed.wait(); + } + co_return co_await inner(pid); + }; + + make_graph(std::move(dispatch), default_resolver()); + + kota::cancellation_source req_a; + kota::event start_c; + std::optional result_a, result_c; + + auto request_a = [&]() -> kota::task<> { + auto r = co_await kota::with_token(cg->compile(pid_a), req_a.token()); + if(r.has_value()) { + result_a = *r; + } + }; + + auto request_c = [&]() -> kota::task<> { + co_await start_c.wait(); + auto r = co_await cg->compile(pid_c).catch_cancel(); + if(r.has_value()) { + result_c = *r; + } + }; + + execute([&]() -> kota::task<> { + auto driver = [&]() -> kota::task<> { + co_await shared_started.wait(); + EXPECT_EQ(cg->refcount(pid_shared), 1u); + EXPECT_EQ(shared_calls, 1); + + // Simulate switching `import A` to `import C`: start the new + // request first, then cancel the old one — the same order the + // supersede path in Compiler::ensure_compiled uses, so the + // shared module's interest never drops to zero. + start_c.set(); + req_a.cancel(); + co_await settle([&] { return !cg->is_compiling(pid_a); }); + + EXPECT_TRUE(cg->is_compiling(pid_shared)); + EXPECT_EQ(cg->refcount(pid_shared), 1u); + EXPECT_EQ(shared_calls, 1); + + shared_proceed.set(); + co_return; + }; + + co_await kota::when_all(request_a(), request_c(), driver()); + + // A was cancelled; C completed using the surviving shared build. + EXPECT_FALSE(result_a.has_value()); + EXPECT_TRUE(result_c == true); + EXPECT_EQ(shared_calls, 1); + EXPECT_TRUE(env.pcm_paths.contains(pid_shared)); + EXPECT_TRUE(env.pcm_paths.contains(pid_c)); + EXPECT_FALSE(env.pcm_paths.contains(pid_a)); + }); +} + +/// ============================================================================ +/// Shared dependency failure propagates to every consumer of the same round +/// ============================================================================ + +TEST_CASE(shared_dep_fails_both) { + env.tmp.touch( + "shared.cppm", + "export module Shared;\n" "export int shared_val() { return UNDEFINED_SYMBOL; }\n"); + env.tmp.touch("a.cppm", + "export module A;\n" + "import Shared;\n" + "export int a_val() { return shared_val(); }\n"); + env.tmp.touch("c.cppm", + "export module C;\n" + "import Shared;\n" + "export int c_val() { return shared_val(); }\n"); + + auto json = build_cdb_json({ + {env.tmp.root, env.tmp.path("shared.cppm"), {}}, + {env.tmp.root, env.tmp.path("a.cppm"), {}}, + {env.tmp.root, env.tmp.path("c.cppm"), {}}, + }); + env.setup({}, json); + + auto pid_shared = env.lookup("Shared"); + auto pid_a = env.lookup("A"); + auto pid_c = env.lookup("C"); + ASSERT_NE(pid_shared, UINT32_MAX); + + // Gate the shared module so both consumers join the same failing round. + kota::event shared_started; + kota::event shared_proceed; + int shared_calls = 0; + auto inner = default_dispatch(); + auto dispatch = [&](std::uint32_t pid) -> kota::task { + if(pid == pid_shared) { + shared_calls += 1; + shared_started.set(); + co_await shared_proceed.wait(); + } + co_return co_await inner(pid); }; - auto t = test(); - loop.schedule(t); - loop.run(); + + make_graph(std::move(dispatch), default_resolver()); + + std::optional result_a, result_c; + auto request_a = [&]() -> kota::task<> { + auto r = co_await cg->compile(pid_a).catch_cancel(); + if(r.has_value()) { + result_a = *r; + } + }; + auto request_c = [&]() -> kota::task<> { + auto r = co_await cg->compile(pid_c).catch_cancel(); + if(r.has_value()) { + result_c = *r; + } + }; + + execute([&]() -> kota::task<> { + auto driver = [&]() -> kota::task<> { + co_await shared_started.wait(); + co_await kota::sleep(1); + // Both chains hold interest in the same round. + EXPECT_EQ(cg->refcount(pid_shared), 2u); + shared_proceed.set(); + co_return; + }; + + co_await kota::when_all(request_a(), request_c(), driver()); + + // One failing round, both consumers fail without retry. + EXPECT_TRUE(result_a == false); + EXPECT_TRUE(result_c == false); + EXPECT_EQ(shared_calls, 1); + EXPECT_FALSE(env.pcm_paths.contains(pid_shared)); + EXPECT_FALSE(env.pcm_paths.contains(pid_a)); + EXPECT_FALSE(env.pcm_paths.contains(pid_c)); + }); } }; // TEST_SUITE(CompileGraphIntegration) diff --git a/tests/unit/server/compile_graph_tests.cpp b/tests/unit/server/compile_graph_tests.cpp index 680927f11..07e34f0f6 100644 --- a/tests/unit/server/compile_graph_tests.cpp +++ b/tests/unit/server/compile_graph_tests.cpp @@ -1,8 +1,11 @@ #include +#include #include "test/test.h" #include "server/compiler/compile_graph.h" +#include "llvm/ADT/DenseSet.h" + namespace clice::testing { namespace { @@ -53,21 +56,115 @@ CompileGraph::dispatch_fn selective_dispatch(llvm::DenseSet fail_ }; } +/// Dispatch driven manually by per-unit events: the test observes when a +/// unit enters dispatch (started) and decides when and how it completes +/// (proceed/result). Cancellation can thus be injected at every suspension +/// point with deterministic timing. +struct ManualDispatch { + struct Gate { + kota::event started; + kota::event proceed; + bool result = true; + int calls = 0; + }; + + llvm::DenseMap> gates; + + Gate& gate(std::uint32_t path_id) { + auto& slot = gates[path_id]; + if(!slot) { + slot = std::make_unique(); + } + return *slot; + } + + void open(std::initializer_list path_ids) { + for(auto id: path_ids) { + gate(id).proceed.set(); + } + } + + CompileGraph::dispatch_fn fn() { + return [this](std::uint32_t path_id) -> kota::task { + auto& g = gate(path_id); + g.calls += 1; + g.started.set(); + co_await g.proceed.wait(); + co_return g.result; + }; + } +}; + +/// A cancellable compile request and its observed result. +/// result is empty while running and after cancellation. +struct Request { + kota::cancellation_source source; + std::optional result; + bool done = false; +}; + TEST_SUITE(CompileGraph) { std::vector compiled; +std::optional loop; std::optional graph; +void make_graph(CompileGraph::dispatch_fn dispatch, CompileGraph::resolve_fn resolve) { + loop.emplace(); + graph.emplace(*loop, std::move(dispatch), std::move(resolve)); +} + +/// Run the test body, then verify the shutdown protocol: cancel + join must +/// exit cleanly and leave the graph fully quiesced (no compiling residue, no +/// held interest, every completion fired). template void execute(F&& fn) { - kota::event_loop loop; - auto t = fn(); - loop.schedule(t); - loop.run(); + auto wrapper = [&]() -> kota::task<> { + co_await fn(); + co_await graph->shutdown(); + EXPECT_TRUE(graph->idle()); + }; + auto t = wrapper(); + loop->schedule(t); + loop->run(); +} + +kota::task<> run_request(std::uint32_t path_id, Request& req) { + auto result = co_await kota::with_token(graph->compile(path_id), req.source.token()); + req.done = true; + if(result.has_value()) { + req.result = *result; + } +} + +kota::task<> run_deps_request(std::uint32_t path_id, Request& req) { + auto result = co_await kota::with_token(graph->compile_deps(path_id), req.source.token()); + req.done = true; + if(result.has_value()) { + req.result = *result; + } +} + +/// Zero-interest cancellation is deferred by one event-loop tick per cascade +/// level; wait (bounded) until `pred` holds before asserting settled state. +template +kota::task<> settle(Pred pred) { + for(int i = 0; i < 100 && !pred(); i++) { + co_await kota::sleep(1); + } + EXPECT_TRUE(pred()); } -TEST_CASE(CompileNoDeps) { - graph.emplace(tracking_dispatch(compiled), no_deps()); +/// ============================================================================ +/// Basic compilation +/// ============================================================================ +/// +/// A request compiles the unit and its transitive dependencies, +/// dependencies first, each dirty unit exactly once. + +TEST_CASE(compile_no_deps) { + // A unit without dependencies is dispatched once and becomes clean. + make_graph(tracking_dispatch(compiled), no_deps()); execute([&]() -> kota::task<> { auto result = co_await graph->compile(1).catch_cancel(); @@ -79,18 +176,17 @@ TEST_CASE(CompileNoDeps) { }); } -TEST_CASE(CompileWithDependency) { - // Unit 1 depends on unit 2. - graph.emplace(tracking_dispatch(compiled), - static_resolver({ - {1, {2}} +TEST_CASE(compile_single_dep) { + // 1 -> 2: the dependency is dispatched before the dependent. + make_graph(tracking_dispatch(compiled), + static_resolver({ + {1, {2}} })); execute([&]() -> kota::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 = ranges::find(compiled, 2u); auto pos1 = ranges::find(compiled, 1u); @@ -100,12 +196,12 @@ TEST_CASE(CompileWithDependency) { }); } -TEST_CASE(CompileChain) { - // Chain: 1 -> 2 -> 3. - graph.emplace(tracking_dispatch(compiled), - static_resolver({ - {1, {2}}, - {2, {3}} +TEST_CASE(compile_chain) { + // 1 -> 2 -> 3: dispatch order follows the dependency chain bottom-up. + make_graph(tracking_dispatch(compiled), + static_resolver({ + {1, {2}}, + {2, {3}} })); execute([&]() -> kota::task<> { @@ -113,7 +209,6 @@ TEST_CASE(CompileChain) { EXPECT_TRUE(result.has_value()); EXPECT_TRUE(*result); EXPECT_EQ(compiled.size(), 3u); - // 3 before 2 before 1. auto pos3 = ranges::find(compiled, 3u); auto pos2 = ranges::find(compiled, 2u); auto pos1 = ranges::find(compiled, 1u); @@ -122,20 +217,20 @@ TEST_CASE(CompileChain) { }); } -TEST_CASE(DiamondDependency) { - // Diamond: 1 -> {2, 3}, 2 -> 4, 3 -> 4. - graph.emplace(tracking_dispatch(compiled), - static_resolver({ - {1, {2, 3}}, - {2, {4} }, - {3, {4} } +TEST_CASE(compile_diamond_dedup) { + // Diamond 1 -> {2, 3}, 2 -> 4, 3 -> 4: the shared dependency 4 is + // reached through two branches but dispatched exactly once. + make_graph(tracking_dispatch(compiled), + static_resolver({ + {1, {2, 3}}, + {2, {4} }, + {3, {4} } })); execute([&]() -> kota::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 = ranges::count(compiled, 4u); EXPECT_EQ(count4, 1); EXPECT_FALSE(graph->is_dirty(2)); @@ -144,116 +239,293 @@ TEST_CASE(DiamondDependency) { }); } -TEST_CASE(UpdateInvalidates) { - // 1 -> 2. - graph.emplace(instant_dispatch(), - static_resolver({ - {1, {2}} - })); +TEST_CASE(second_compile_skips) { + // A clean unit is not redispatched by a later request. + make_graph(tracking_dispatch(compiled), no_deps()); execute([&]() -> kota::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)); + EXPECT_EQ(compiled.size(), 1u); + co_await graph->compile(1).catch_cancel(); + EXPECT_EQ(compiled.size(), 1u); }); } -TEST_CASE(UpdateCascade) { - // Chain: 1 -> 2 -> 3. - graph.emplace(instant_dispatch(), - static_resolver({ - {1, {2}}, - {2, {3}} - })); +TEST_CASE(state_queries) { + // has_unit/is_compiling reflect the unit's lifecycle: absent before the + // first request, present and not compiling after completion. + make_graph(instant_dispatch(), no_deps()); execute([&]() -> kota::task<> { + EXPECT_FALSE(graph->has_unit(1)); + EXPECT_FALSE(graph->is_compiling(1)); + co_await graph->compile(1).catch_cancel(); - EXPECT_FALSE(graph->is_dirty(2)); - EXPECT_FALSE(graph->is_dirty(3)); + EXPECT_TRUE(graph->has_unit(1)); + EXPECT_FALSE(graph->is_compiling(1)); + }); +} - // 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)); +TEST_CASE(concurrent_requests_share_round) { + // Two concurrent requests for the same unit join one round: a single + // dispatch serves both. + ManualDispatch md; + make_graph(md.fn(), no_deps()); + + Request ra, rc; + execute([&]() -> kota::task<> { + auto driver = [&]() -> kota::task<> { + co_await md.gate(1).started.wait(); + EXPECT_EQ(graph->refcount(1), 2u); + md.gate(1).proceed.set(); + // Suspend once before finishing: a when_all child that completes + // synchronously during the arm phase trips a when_any bookkeeping + // assert downstream (kotatsu bug, pending an upstream fix). + co_await kota::sleep(0); + co_return; + }; + + co_await kota::when_all(run_request(1, ra), run_request(1, rc), driver()); + + EXPECT_TRUE(ra.result == true); + EXPECT_TRUE(rc.result == true); + EXPECT_EQ(md.gate(1).calls, 1); + }); +} + +/// ============================================================================ +/// compile_deps +/// ============================================================================ +/// +/// Compiles a unit's transitive dependencies but never the unit itself — +/// used for plain .cpp files that import modules. + +TEST_CASE(compile_deps_empty) { + // No dependencies: nothing is dispatched, the request succeeds. + make_graph(tracking_dispatch(compiled), no_deps()); + + execute([&]() -> kota::task<> { + auto result = co_await graph->compile_deps(1).catch_cancel(); + EXPECT_TRUE(result.has_value()); + EXPECT_TRUE(*result); + EXPECT_EQ(compiled.size(), 0u); }); } -TEST_CASE(CompileAfterUpdate) { - // 1 -> 2. - graph.emplace(tracking_dispatch(compiled), - static_resolver({ - {1, {2}} +TEST_CASE(compile_deps_single) { + // 1 -> 2: only the dependency is dispatched, never unit 1 itself. + make_graph(tracking_dispatch(compiled), + static_resolver({ + {1, {2}} })); execute([&]() -> kota::task<> { - co_await graph->compile(1).catch_cancel(); + auto result = co_await graph->compile_deps(1).catch_cancel(); + EXPECT_TRUE(result.has_value()); + EXPECT_TRUE(*result); + EXPECT_EQ(compiled.size(), 1u); + EXPECT_EQ(compiled[0], 2u); + EXPECT_TRUE(ranges::find(compiled, 1u) == compiled.end()); + }); +} + +TEST_CASE(compile_deps_chain) { + // 1 -> 2 -> 3: transitive dependencies are compiled, the root is not. + make_graph(tracking_dispatch(compiled), + static_resolver({ + {1, {2}}, + {2, {3}} + })); + + execute([&]() -> kota::task<> { + auto result = co_await graph->compile_deps(1).catch_cancel(); + EXPECT_TRUE(result.has_value()); + EXPECT_TRUE(*result); EXPECT_EQ(compiled.size(), 2u); + EXPECT_TRUE(ranges::find(compiled, 3u) != compiled.end()); + EXPECT_TRUE(ranges::find(compiled, 2u) != compiled.end()); + EXPECT_TRUE(ranges::find(compiled, 1u) == compiled.end()); + }); +} - graph->update(2); - co_await graph->compile(1).catch_cancel(); - // 2 and 1 should be recompiled. - EXPECT_EQ(compiled.size(), 4u); +TEST_CASE(compile_deps_diamond) { + // Diamond below the root: every dependency once, the root never. + make_graph(tracking_dispatch(compiled), + static_resolver({ + {1, {2, 3}}, + {2, {4} }, + {3, {4} } + })); + + execute([&]() -> kota::task<> { + auto result = co_await graph->compile_deps(1).catch_cancel(); + EXPECT_TRUE(result.has_value()); + EXPECT_TRUE(*result); + EXPECT_TRUE(ranges::find(compiled, 1u) == compiled.end()); + EXPECT_TRUE(ranges::find(compiled, 2u) != compiled.end()); + EXPECT_TRUE(ranges::find(compiled, 3u) != compiled.end()); + EXPECT_TRUE(ranges::find(compiled, 4u) != compiled.end()); + auto count4 = ranges::count(compiled, 4u); + EXPECT_EQ(count4, 1); }); } -TEST_CASE(DispatchFailure) { - // 1 -> 2. Dispatch always fails. - graph.emplace(failing_dispatch(), - static_resolver({ - {1, {2}} +TEST_CASE(compile_deps_plain_cpp) { + // The .cpp file (10) importing a module (20) gets its module built + // without ever being treated as a module unit itself. + make_graph(tracking_dispatch(compiled), + static_resolver({ + {10, {20}} })); execute([&]() -> kota::task<> { - auto result = co_await graph->compile(1).catch_cancel(); + auto result = co_await graph->compile_deps(10).catch_cancel(); EXPECT_TRUE(result.has_value()); - EXPECT_FALSE(*result); - // Dep 2 failed, so it stays dirty. - EXPECT_TRUE(graph->is_dirty(2)); + EXPECT_TRUE(*result); + EXPECT_EQ(compiled.size(), 1u); + EXPECT_EQ(compiled[0], 20u); }); } -TEST_CASE(CancelAll) { - graph.emplace(instant_dispatch(), no_deps()); - // Just verify it doesn't crash. - graph->cancel_all(); +TEST_CASE(compile_deps_concurrent_dedup) { + // Two concurrent requests with overlapping dependency sets ({3,4} and + // {3,5}): the shared dependency 3 is dispatched exactly once. + make_graph(tracking_dispatch(compiled), + static_resolver({ + {1, {3, 4}}, + {2, {3, 5}}, + })); + + execute([&]() -> kota::task<> { + auto t1 = graph->compile_deps(1); + auto t2 = graph->compile_deps(2); + auto results = co_await kota::when_all(std::move(t1), std::move(t2)); + + auto [r1, r2] = results; + EXPECT_TRUE(r1); + EXPECT_TRUE(r2); + + ranges::sort(compiled); + EXPECT_EQ(compiled.size(), 3u); + EXPECT_EQ(compiled[0], 3u); + EXPECT_EQ(compiled[1], 4u); + EXPECT_EQ(compiled[2], 5u); + }); } -TEST_CASE(SecondCompileSkips) { - graph.emplace(tracking_dispatch(compiled), no_deps()); +TEST_CASE(compile_deps_resolve_once) { + // resolve_fn is expensive (full module scan): it runs at most once per + // unit even when concurrent requests touch the same dependency. + int resolve_count = 0; + + auto resolve = [&resolve_count](std::uint32_t path_id) -> llvm::SmallVector { + resolve_count++; + if(path_id == 1 || path_id == 2) + return {3}; + return {}; + }; + + make_graph(tracking_dispatch(compiled), std::move(resolve)); execute([&]() -> kota::task<> { - co_await graph->compile(1).catch_cancel(); + auto t1 = graph->compile_deps(1); + auto t2 = graph->compile_deps(2); + auto results = co_await kota::when_all(std::move(t1), std::move(t2)); + + auto [r1, r2] = results; + EXPECT_TRUE(r1); + EXPECT_TRUE(r2); + EXPECT_EQ(compiled.size(), 1u); - // Second compile should skip (already clean). + EXPECT_EQ(compiled[0], 3u); + EXPECT_EQ(resolve_count, 3); + }); +} + +TEST_CASE(compile_deps_failure) { + // A failing dependency fails the request; the root unit is never + // dispatched on a failed preparation. + auto fail_and_track = [&](std::uint32_t path_id) -> kota::task { + compiled.push_back(path_id); + co_return false; + }; + + make_graph(std::move(fail_and_track), + static_resolver({ + {1, {2}} + })); + + execute([&]() -> kota::task<> { + auto result = co_await graph->compile_deps(1).catch_cancel(); + EXPECT_TRUE(result.has_value()); + EXPECT_FALSE(*result); + EXPECT_TRUE(ranges::find(compiled, 1u) == compiled.end()); + }); +} + +/// ============================================================================ +/// Staleness marking +/// ============================================================================ +/// +/// update() marks a changed file and everything that (transitively) +/// depends on it dirty, following reverse edges. Marking alone never +/// recompiles — the next request does. + +TEST_CASE(update_invalidates) { + // Updating a dependency dirties it and its dependent. + make_graph(instant_dispatch(), + static_resolver({ + {1, {2}} + })); + + execute([&]() -> kota::task<> { co_await graph->compile(1).catch_cancel(); - EXPECT_EQ(compiled.size(), 1u); + EXPECT_FALSE(graph->is_dirty(2)); + EXPECT_FALSE(graph->is_dirty(1)); + + graph->update(2); + EXPECT_TRUE(graph->is_dirty(2)); + EXPECT_TRUE(graph->is_dirty(1)); + }); +} + +TEST_CASE(update_cascade) { + // 1 -> 2 -> 3: updating the leaf dirties the whole dependent chain. + make_graph(instant_dispatch(), + static_resolver({ + {1, {2}}, + {2, {3}} + })); + + execute([&]() -> kota::task<> { + co_await graph->compile(1).catch_cancel(); + EXPECT_FALSE(graph->is_dirty(2)); + EXPECT_FALSE(graph->is_dirty(3)); + + graph->update(3); + EXPECT_TRUE(graph->is_dirty(3)); + EXPECT_TRUE(graph->is_dirty(2)); + EXPECT_TRUE(graph->is_dirty(1)); }); } -TEST_CASE(CascadeThroughAlreadyDirty) { - // Chain: 1 -> 2 -> 3. - graph.emplace(instant_dispatch(), - static_resolver({ - {1, {2}}, - {2, {3}} +TEST_CASE(cascade_through_dirty) { + // The cascade must not stop at an already-dirty intermediate node: + // after update(2) dirtied {2, 1}, update(3) still has to reach 1. + make_graph(instant_dispatch(), + static_resolver({ + {1, {2}}, + {2, {3}} })); execute([&]() -> kota::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)); @@ -261,44 +533,75 @@ TEST_CASE(CascadeThroughAlreadyDirty) { }); } -TEST_CASE(CircularDependencyDetection) { - // Cycle: 1 -> 2 -> 1. - graph.emplace(instant_dispatch(), - static_resolver({ - {1, {2}}, - {2, {1}} +TEST_CASE(diamond_update_cascade) { + // Updating the bottom of a diamond dirties both branches and the top; + // the recompile still dedups the shared node. + make_graph(tracking_dispatch(compiled), + static_resolver({ + {1, {2, 3}}, + {2, {4} }, + {3, {4} } })); execute([&]() -> kota::task<> { + co_await graph->compile(1).catch_cancel(); + EXPECT_FALSE(graph->is_dirty(1)); + EXPECT_FALSE(graph->is_dirty(4)); + + 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(); - // Should return false (cycle detected), not deadlock. - EXPECT_TRUE(result.has_value()); - EXPECT_FALSE(*result); + EXPECT_TRUE(result.has_value() && *result); + auto count4 = ranges::count(compiled, 4u); + EXPECT_EQ(count4, 1); }); } -TEST_CASE(CrossBranchCycleDetection) { - // 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. - graph.emplace(instant_dispatch(), - static_resolver({ - {1, {2, 3}}, - {2, {3} }, - {3, {2} } +TEST_CASE(update_returns_dirtied) { + // The caller (PCM cache eviction) gets the full set of dirtied units. + make_graph(instant_dispatch(), + static_resolver({ + {1, {2}}, + {2, {3}} })); execute([&]() -> kota::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); + co_await graph->compile(1).catch_cancel(); + + auto dirtied = graph->update(3); + 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()); + }); +} + +TEST_CASE(compile_after_update) { + // A request after an update recompiles exactly the dirtied units. + make_graph(tracking_dispatch(compiled), + static_resolver({ + {1, {2}} + })); + + execute([&]() -> kota::task<> { + co_await graph->compile(1).catch_cancel(); + EXPECT_EQ(compiled.size(), 2u); + + graph->update(2); + co_await graph->compile(1).catch_cancel(); + EXPECT_EQ(compiled.size(), 4u); }); } -TEST_CASE(UpdateResetsResolved) { +TEST_CASE(update_resets_resolved) { + // The updated file may have added/removed imports: its dependencies are + // re-resolved on the next compile and the new set takes effect. 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) { @@ -309,433 +612,1085 @@ TEST_CASE(UpdateResetsResolved) { return {}; }; - graph.emplace(tracking_dispatch(compiled), std::move(resolver)); + make_graph(tracking_dispatch(compiled), std::move(resolver)); execute([&]() -> kota::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. auto tail = compiled | std::views::drop(2); EXPECT_TRUE(ranges::find(tail, 3u) != tail.end()); }); } -TEST_CASE(UpdateCleansBackEdges) { +TEST_CASE(update_cleans_back_edges) { + // When a re-resolve drops a dependency, the reverse edge goes with it: + // updating the ex-dependency no longer cascades to the ex-dependent. 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 {}; }; - graph.emplace(tracking_dispatch(compiled), std::move(resolver)); + make_graph(tracking_dispatch(compiled), std::move(resolver)); execute([&]() -> kota::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)); }); } -TEST_CASE(DiamondUpdateCascade) { - // Diamond: 1 -> {2, 3}, 2 -> 4, 3 -> 4. - graph.emplace(tracking_dispatch(compiled), - static_resolver({ - {1, {2, 3}}, - {2, {4} }, - {3, {4} } +TEST_CASE(update_unknown_id) { + // Updating a file the graph has never seen is a harmless no-op. + make_graph(instant_dispatch(), no_deps()); + + auto dirtied = graph->update(999); + EXPECT_EQ(dirtied.size(), 0u); + EXPECT_FALSE(graph->has_unit(999)); +} + +/// ============================================================================ +/// Update vs in-flight rounds +/// ============================================================================ +/// +/// The stale round is cancelled unconditionally (its result is garbage), +/// interest is untouched, and the waiters drive a fresh round with the +/// new content. Dependencies that are NOT stale themselves must keep +/// compiling across the retry. + +TEST_CASE(update_during_dispatch) { + // The unit is updated mid-dispatch: that round's result is discarded, + // the waiter respawns the unit and succeeds. Exactly one retry — each + // retry consumes one staleness event, so there is no retry storm. + ManualDispatch md; + make_graph(md.fn(), no_deps()); + + execute([&]() -> kota::task<> { + bool done = false; + std::optional result; + + auto compiler = [&]() -> kota::task<> { + auto r = co_await graph->compile(1).catch_cancel(); + done = true; + if(r.has_value()) { + result = *r; + } + }; + + auto driver = [&]() -> kota::task<> { + co_await md.gate(1).started.wait(); + md.gate(1).started.reset(); + graph->update(1); + co_await md.gate(1).started.wait(); + EXPECT_EQ(md.gate(1).calls, 2); + md.gate(1).proceed.set(); + co_return; + }; + + co_await kota::when_all(compiler(), driver()); + + EXPECT_TRUE(done); + EXPECT_TRUE(result == true); + EXPECT_FALSE(graph->is_dirty(1)); + EXPECT_EQ(md.gate(1).calls, 2); + }); +} + +TEST_CASE(update_keeps_waited_dep) { + // Unit 1 is updated while waiting on its (unchanged) dependency 2. The + // retry re-acquires 2 within the same drain cycle, so 2's in-flight + // round is handed over — neither cancelled nor restarted. + ManualDispatch md; + make_graph(md.fn(), + static_resolver({ + {1, {2}} })); + md.open({1}); execute([&]() -> kota::task<> { - co_await graph->compile(1).catch_cancel(); + bool done = false; + std::optional result; + + auto compiler = [&]() -> kota::task<> { + auto r = co_await graph->compile(1).catch_cancel(); + done = true; + if(r.has_value()) { + result = *r; + } + }; + + auto driver = [&]() -> kota::task<> { + co_await md.gate(2).started.wait(); + EXPECT_TRUE(graph->is_compiling(1)); + + graph->update(1); + co_await kota::sleep(1); + + // Unit 1's round was respawned; 2 kept compiling throughout. + EXPECT_TRUE(graph->is_compiling(2)); + EXPECT_EQ(md.gate(2).calls, 1); + EXPECT_EQ(graph->refcount(2), 1u); + + md.gate(2).proceed.set(); + co_return; + }; + + co_await kota::when_all(compiler(), driver()); + + EXPECT_TRUE(done); + EXPECT_TRUE(result == true); EXPECT_FALSE(graph->is_dirty(1)); + EXPECT_FALSE(graph->is_dirty(2)); + EXPECT_EQ(md.gate(2).calls, 1); + }); +} + +TEST_CASE(update_keeps_retained_dep) { + // Unit 1 waits on {2, 3} when an update changes its imports to {2, 4}: + // the orphaned 3 is cancelled, while the retained 2 is handed over to + // the retry — not cancelled and restarted, which would waste the work + // done so far. + bool flipped = false; + auto resolver = [&](std::uint32_t path_id) -> llvm::SmallVector { + if(path_id == 1) { + return flipped ? llvm::SmallVector{2, 4} + : llvm::SmallVector{2, 3}; + } + return {}; + }; + + ManualDispatch md; + make_graph(md.fn(), std::move(resolver)); + md.open({1, 4}); + + execute([&]() -> kota::task<> { + bool done = false; + std::optional result; + + auto compiler = [&]() -> kota::task<> { + auto r = co_await graph->compile(1).catch_cancel(); + done = true; + if(r.has_value()) { + result = *r; + } + }; + + auto driver = [&]() -> kota::task<> { + co_await md.gate(2).started.wait(); + co_await md.gate(3).started.wait(); + EXPECT_EQ(graph->refcount(2), 1u); + EXPECT_EQ(graph->refcount(3), 1u); + + flipped = true; + graph->update(1); + co_await settle([&] { return !graph->is_compiling(3); }); + + // 3 lost its last interest and was cancelled; 2 kept compiling. + EXPECT_TRUE(graph->is_dirty(3)); + EXPECT_TRUE(graph->is_compiling(2)); + EXPECT_EQ(md.gate(2).calls, 1); + EXPECT_EQ(md.gate(3).calls, 1); + EXPECT_EQ(graph->refcount(2), 1u); + + md.gate(2).proceed.set(); + co_return; + }; + + co_await kota::when_all(compiler(), driver()); + + EXPECT_TRUE(done); + EXPECT_TRUE(result == true); + EXPECT_FALSE(graph->is_dirty(1)); + EXPECT_FALSE(graph->is_dirty(2)); EXPECT_FALSE(graph->is_dirty(4)); + // The retained dependency was dispatched exactly once overall. + EXPECT_EQ(md.gate(2).calls, 1); + EXPECT_EQ(md.gate(3).calls, 1); + }); +} - // 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)); +TEST_CASE(update_swaps_deps) { + // Unit 1's import set changes from {2} to {3} mid-flight: the retry + // re-resolves, the orphaned 2 is released (its now-unwanted round + // cancelled, never restarted) and its reverse edge fully detached. + bool flipped = false; + auto resolver = [&](std::uint32_t path_id) -> llvm::SmallVector { + if(path_id == 1) { + return flipped ? llvm::SmallVector{3} + : llvm::SmallVector{2}; + } + return {}; + }; - 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 = ranges::count(compiled, 4u); - EXPECT_EQ(count4, 1); + ManualDispatch md; + make_graph(md.fn(), std::move(resolver)); + md.open({1, 3}); + + execute([&]() -> kota::task<> { + bool done = false; + std::optional result; + + auto compiler = [&]() -> kota::task<> { + auto r = co_await graph->compile(1).catch_cancel(); + done = true; + if(r.has_value()) { + result = *r; + } + }; + + auto driver = [&]() -> kota::task<> { + co_await md.gate(2).started.wait(); + EXPECT_TRUE(graph->is_compiling(1)); + + flipped = true; + graph->update(1); + + co_await md.gate(3).started.wait(); + EXPECT_EQ(md.gate(2).calls, 1); + EXPECT_EQ(md.gate(3).calls, 1); + co_return; + }; + + co_await kota::when_all(compiler(), driver()); + + EXPECT_TRUE(done); + EXPECT_TRUE(result == true); + EXPECT_FALSE(graph->is_dirty(1)); + EXPECT_FALSE(graph->is_dirty(3)); + // The orphan stays dirty and detached: updating it no longer + // cascades to 1. + EXPECT_TRUE(graph->is_dirty(2)); + auto dirtied = graph->update(2); + EXPECT_EQ(dirtied.size(), 1u); + EXPECT_FALSE(graph->is_dirty(1)); }); } -TEST_CASE(UpdateReturnsAllDirtied) { - // Chain: 1 -> 2 -> 3. - graph.emplace(instant_dispatch(), - static_resolver({ - {1, {2}}, - {2, {3}} +TEST_CASE(update_cascades_cancel) { + // Updating a dependency cancels the in-flight rounds of its dependents + // too (their results would embed the stale dependency); the surviving + // request retries the whole chain with the new content. + ManualDispatch md; + make_graph(md.fn(), + static_resolver({ + {1, {2}}, + {2, {3}} })); + md.open({1, 2}); execute([&]() -> kota::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()); + bool done = false; + std::optional result; + + auto compiler = [&]() -> kota::task<> { + auto r = co_await graph->compile(1).catch_cancel(); + done = true; + if(r.has_value()) { + result = *r; + } + }; + + auto driver = [&]() -> kota::task<> { + co_await md.gate(3).started.wait(); + EXPECT_TRUE(graph->is_compiling(1)); + EXPECT_TRUE(graph->is_compiling(2)); + + md.gate(3).started.reset(); + graph->update(3); + EXPECT_TRUE(graph->is_dirty(1)); + EXPECT_TRUE(graph->is_dirty(2)); + + // The updated unit itself is stale, so its dispatch restarts. + co_await md.gate(3).started.wait(); + EXPECT_EQ(md.gate(3).calls, 2); + EXPECT_TRUE(graph->is_compiling(1)); + EXPECT_TRUE(graph->is_compiling(2)); + md.gate(3).proceed.set(); + co_return; + }; + + co_await kota::when_all(compiler(), driver()); + + EXPECT_TRUE(done); + EXPECT_TRUE(result == true); + EXPECT_FALSE(graph->is_dirty(1)); + EXPECT_FALSE(graph->is_dirty(2)); + EXPECT_FALSE(graph->is_dirty(3)); + // No further updates arrived — exactly one retry, no storm. + EXPECT_EQ(md.gate(3).calls, 2); }); } -TEST_CASE(HasUnitAndIsCompiling) { - graph.emplace(instant_dispatch(), no_deps()); +TEST_CASE(shared_dep_update_retries) { + // The shared dependency 5 of two waiting chains is updated mid-dispatch: + // both chains observe the stale round and retry, sharing a single fresh + // round — 5 is dispatched exactly twice overall, not three times. + ManualDispatch md; + make_graph(md.fn(), + static_resolver({ + {1, {5}}, + {3, {5}} + })); + md.open({1, 3}); + Request ra, rc; execute([&]() -> kota::task<> { - EXPECT_FALSE(graph->has_unit(1)); - EXPECT_FALSE(graph->is_compiling(1)); + auto driver = [&]() -> kota::task<> { + co_await md.gate(5).started.wait(); + EXPECT_EQ(graph->refcount(5), 2u); - co_await graph->compile(1).catch_cancel(); - EXPECT_TRUE(graph->has_unit(1)); - EXPECT_FALSE(graph->is_compiling(1)); + md.gate(5).started.reset(); + graph->update(5); + + co_await md.gate(5).started.wait(); + EXPECT_EQ(md.gate(5).calls, 2); + md.gate(5).proceed.set(); + co_return; + }; + + co_await kota::when_all(run_request(1, ra), run_request(3, rc), driver()); + + EXPECT_TRUE(ra.result == true); + EXPECT_TRUE(rc.result == true); + EXPECT_EQ(md.gate(5).calls, 2); + EXPECT_FALSE(graph->is_dirty(5)); }); } -TEST_CASE(FailureLeavesDepsDirty) { - // 1 -> 2. Dispatch always fails. - graph.emplace(failing_dispatch(), - static_resolver({ - {1, {2}} +/// ============================================================================ +/// Failure semantics +/// ============================================================================ +/// +/// A failed round (compile error, cycle) propagates to every waiter +/// without retry — retrying failures would turn a syntax error into a +/// storm. Failure is not sticky: the next explicit request tries again. + +TEST_CASE(failure_leaves_dirty) { + // A failing dependency fails the request; neither the failed dependency + // nor the never-dispatched dependent is marked clean. + make_graph(failing_dispatch(), + static_resolver({ + {1, {2}} })); execute([&]() -> kota::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)); }); } -TEST_CASE(SelfLoop) { - // Unit 1 depends on itself. - graph.emplace(instant_dispatch(), - static_resolver({ - {1, {1}} +TEST_CASE(partial_dep_failure) { + // 1 -> {2, 3}, only 3 fails: 2's success is kept, 3 stays dirty, and 1 + // fails without being dispatched. + make_graph(selective_dispatch({ + 3 + }), + static_resolver({{1, {2, 3}}})); + + execute([&]() -> kota::task<> { + auto result = co_await graph->compile(1).catch_cancel(); + EXPECT_TRUE(result.has_value()); + EXPECT_FALSE(*result); + EXPECT_FALSE(graph->is_dirty(2)); + EXPECT_TRUE(graph->is_dirty(3)); + EXPECT_TRUE(graph->is_dirty(1)); + }); +} + +TEST_CASE(failed_dep_no_retry) { + // The dependency fails once: the failure propagates (1 never + // dispatched, no retry) and the failed round releases its interest. + ManualDispatch md; + make_graph(md.fn(), + static_resolver({ + {1, {2}} })); + md.gate(2).result = false; + md.open({1, 2}); execute([&]() -> kota::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); + EXPECT_EQ(md.gate(2).calls, 1); + EXPECT_EQ(md.gate(1).calls, 0); + EXPECT_EQ(graph->refcount(2), 0u); + EXPECT_EQ(graph->refcount(1), 0u); }); } -TEST_CASE(CancelAllAndRecompile) { - graph.emplace(tracking_dispatch(compiled), - static_resolver({ - {1, {2}} +TEST_CASE(recompile_after_failure) { + // Failure is not sticky: a new request drives a fresh attempt, which + // succeeds once the dependency compiles. + ManualDispatch md; + make_graph(md.fn(), + static_resolver({ + {1, {2}} })); + md.gate(2).result = false; + md.open({1, 2}); execute([&]() -> kota::task<> { - co_await graph->compile(1).catch_cancel(); - EXPECT_EQ(compiled.size(), 2u); + auto r1 = co_await graph->compile(1).catch_cancel(); + EXPECT_TRUE(r1.has_value()); + EXPECT_FALSE(*r1); + + md.gate(2).result = true; + auto r2 = co_await graph->compile(1).catch_cancel(); + EXPECT_TRUE(r2.has_value()); + EXPECT_TRUE(*r2); + EXPECT_EQ(md.gate(2).calls, 2); + EXPECT_EQ(md.gate(1).calls, 1); 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)); +TEST_CASE(shared_dep_failure_propagates) { + // One failing round of a shared dependency fails every consumer waiting + // on it; the failing dispatch runs only once. + ManualDispatch md; + make_graph(md.fn(), + static_resolver({ + {1, {5}}, + {3, {5}} + })); + md.gate(5).result = false; + + Request ra, rc; + execute([&]() -> kota::task<> { + // Hold 5's gate until both chains wait on the same round. + auto driver = [&]() -> kota::task<> { + co_await md.gate(5).started.wait(); + EXPECT_EQ(graph->refcount(5), 2u); + md.gate(5).proceed.set(); + // Suspend once before finishing (kotatsu when_all arm bug, see + // concurrent_requests_share_round). + co_await kota::sleep(0); + co_return; + }; + + co_await kota::when_all(run_request(1, ra), run_request(3, rc), driver()); + + EXPECT_TRUE(ra.result == false); + EXPECT_TRUE(rc.result == false); + EXPECT_EQ(md.gate(5).calls, 1); + EXPECT_EQ(md.gate(1).calls, 0); + EXPECT_EQ(md.gate(3).calls, 0); + EXPECT_TRUE(graph->is_dirty(5)); + }); +} - // Recompile should succeed normally. +/// ============================================================================ +/// Cycle handling +/// ============================================================================ +/// +/// Every shape of dependency cycle terminates with failure — never a +/// deadlock, never unbounded retry. + +TEST_CASE(self_loop) { + // A unit importing itself fails immediately after resolve. + make_graph(instant_dispatch(), + static_resolver({ + {1, {1}} + })); + + execute([&]() -> kota::task<> { 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)); + EXPECT_FALSE(*result); }); } -TEST_CASE(UpdateDuringCompile) { - kota::event_loop loop; - kota::event gate; +TEST_CASE(circular_dependency) { + // 1 -> 2 -> 1: the waiter that would close the wait loop detects it + // and fails the unit instead of blocking. + make_graph(instant_dispatch(), + static_resolver({ + {1, {2}}, + {2, {1}} + })); - auto gated_dispatch = [&gate](std::uint32_t) -> kota::task { - co_await gate.wait(); - co_return true; - }; + execute([&]() -> kota::task<> { + auto result = co_await graph->compile(1).catch_cancel(); + EXPECT_TRUE(result.has_value()); + EXPECT_FALSE(*result); + }); +} - graph.emplace(std::move(gated_dispatch), no_deps()); +TEST_CASE(cross_branch_cycle) { + // 1 -> {2, 3}, 2 -> 3, 3 -> 2: sibling unit tasks would deadlock on + // each other's completion without wait-cycle detection. + make_graph(instant_dispatch(), + static_resolver({ + {1, {2, 3}}, + {2, {3} }, + {3, {2} } + })); + + execute([&]() -> kota::task<> { + auto result = co_await graph->compile(1).catch_cancel(); + EXPECT_TRUE(result.has_value()); + EXPECT_FALSE(*result); + }); +} - bool compile_done = false; - bool was_cancelled = false; +TEST_CASE(partitioned_cycle) { + // The cycle (2 <-> 3) does not involve the requested root; the failure + // still propagates up to it. + make_graph(instant_dispatch(), + static_resolver({ + {1, {2}}, + {2, {3}}, + {3, {2}} + })); - // Coroutine 1: compile(1), will suspend inside dispatch waiting on gate. - auto compiler = [&]() -> kota::task<> { + execute([&]() -> kota::task<> { auto result = co_await graph->compile(1).catch_cancel(); - compile_done = true; - was_cancelled = !result.has_value(); - }; + EXPECT_TRUE(result.has_value()); + EXPECT_FALSE(*result); + }); +} - // Coroutine 2: update(1) while dispatch is in flight, then unblock gate. - auto updater = [&]() -> kota::task<> { - graph->update(1); - gate.set(); - co_return; +TEST_CASE(update_introduced_cycle) { + // The cycle only appears after update() forces a re-resolve of unit 2; + // the retry detects it and fails instead of hanging. + bool flipped = false; + auto resolver = [&](std::uint32_t path_id) -> llvm::SmallVector { + if(path_id == 1) { + return {2}; + } + if(path_id == 2 && flipped) { + return {1}; + } + return {}; }; - auto t1 = compiler(); - auto t2 = updater(); - loop.schedule(t1); - loop.schedule(t2); - loop.run(); + make_graph(instant_dispatch(), std::move(resolver)); + + execute([&]() -> kota::task<> { + auto r1 = co_await graph->compile(1).catch_cancel(); + EXPECT_TRUE(r1.has_value() && *r1); + + flipped = true; + graph->update(2); - // update() cancelled the source, so compile should have been cancelled. - EXPECT_TRUE(compile_done); - EXPECT_TRUE(was_cancelled); - EXPECT_TRUE(graph->is_dirty(1)); + auto r2 = co_await graph->compile(1).catch_cancel(); + EXPECT_TRUE(r2.has_value()); + EXPECT_FALSE(*r2); + }); } -TEST_CASE(WhenAllPartialFailure) { - // 1 -> {2, 3}. Only unit 3 fails. - graph.emplace(selective_dispatch({ - 3 - }), - static_resolver({{1, {2, 3}}})); +/// ============================================================================ +/// Shared dependencies & cancellation +/// ============================================================================ +/// +/// Topology unless noted: A(1) -> B(2) -> E(5) and C(3) -> D(4) -> E(5), +/// E shared by both chains. Cancelling a request must only kill the parts +/// of its chain no other consumer holds interest in. + +TEST_CASE(shared_dep_survives_cancel) { + // Cancel request A while E dispatches: the A-chain dies, but D still + // holds interest in E — E keeps compiling and serves C unscathed. + ManualDispatch md; + make_graph(md.fn(), + static_resolver({ + {1, {2}}, + {2, {5}}, + {3, {4}}, + {4, {5}} + })); + md.open({1, 2, 3, 4}); + Request ra, rc; execute([&]() -> kota::task<> { - auto result = co_await graph->compile(1).catch_cancel(); - EXPECT_TRUE(result.has_value()); - EXPECT_FALSE(*result); - // Unit 2 succeeded — should be clean. - EXPECT_FALSE(graph->is_dirty(2)); - // Unit 3 failed — stays dirty. - EXPECT_TRUE(graph->is_dirty(3)); - // Unit 1 was not dispatched — stays dirty. + auto driver = [&]() -> kota::task<> { + co_await md.gate(5).started.wait(); + EXPECT_EQ(graph->refcount(5), 2u); + EXPECT_EQ(md.gate(5).calls, 1); + + ra.source.cancel(); + co_await settle([&] { return !graph->is_compiling(2); }); + + EXPECT_TRUE(ra.done); + EXPECT_TRUE(graph->is_compiling(5)); + EXPECT_EQ(graph->refcount(5), 1u); + EXPECT_EQ(md.gate(5).calls, 1); + + md.gate(5).proceed.set(); + co_return; + }; + + co_await kota::when_all(run_request(1, ra), run_request(3, rc), driver()); + + EXPECT_FALSE(ra.result.has_value()); + EXPECT_TRUE(rc.result == true); + EXPECT_FALSE(graph->is_dirty(5)); + EXPECT_FALSE(graph->is_dirty(3)); EXPECT_TRUE(graph->is_dirty(1)); }); } -TEST_CASE(UpdateUnknownPathId) { - graph.emplace(instant_dispatch(), no_deps()); +TEST_CASE(shared_dep_sequential_cancel) { + // Both directions, stepwise: cancel C first (E survives via the + // A-chain, refcount 2 -> 1), then cancel A too (last interest gone, + // refcount 1 -> 0, E dies). E is never restarted along the way. + ManualDispatch md; + make_graph(md.fn(), + static_resolver({ + {1, {2}}, + {2, {5}}, + {3, {4}}, + {4, {5}} + })); + md.open({1, 2, 3, 4}); - // update on a path_id that was never compiled should not crash. - auto dirtied = graph->update(999); - EXPECT_EQ(dirtied.size(), 0u); - EXPECT_FALSE(graph->has_unit(999)); -} + Request ra, rc; + execute([&]() -> kota::task<> { + auto driver = [&]() -> kota::task<> { + co_await md.gate(5).started.wait(); + EXPECT_EQ(graph->refcount(5), 2u); -TEST_CASE(EmptyGraphNoCompile) { - // Construct and destroy without any compile calls. - graph.emplace(instant_dispatch(), no_deps()); - EXPECT_FALSE(graph->has_unit(1)); - graph->cancel_all(); // Should not crash on empty graph. + rc.source.cancel(); + co_await settle([&] { return !graph->is_compiling(4); }); + + EXPECT_TRUE(graph->is_compiling(5)); + EXPECT_EQ(graph->refcount(5), 1u); + EXPECT_EQ(md.gate(5).calls, 1); + + ra.source.cancel(); + co_await settle([&] { return !graph->is_compiling(5); }); + + EXPECT_TRUE(graph->is_dirty(5)); + EXPECT_EQ(graph->refcount(5), 0u); + EXPECT_EQ(md.gate(5).calls, 1); + co_return; + }; + + co_await kota::when_all(run_request(1, ra), run_request(3, rc), driver()); + + EXPECT_FALSE(ra.result.has_value()); + EXPECT_FALSE(rc.result.has_value()); + }); } -TEST_CASE(CompileDepsNoDeps) { - graph.emplace(tracking_dispatch(compiled), no_deps()); +TEST_CASE(shared_dep_both_cancelled) { + // E shared at different depths (A -> B -> E, C -> E); cancelling both + // requests in the same tick drops E to zero and cancels it. + ManualDispatch md; + make_graph(md.fn(), + static_resolver({ + {1, {2}}, + {2, {5}}, + {3, {5}} + })); + md.open({1, 2, 3}); + Request ra, rc; execute([&]() -> kota::task<> { - auto result = co_await graph->compile_deps(1).catch_cancel(); - EXPECT_TRUE(result.has_value()); - EXPECT_TRUE(*result); - // No dependencies, so nothing should be dispatched. - EXPECT_EQ(compiled.size(), 0u); + auto driver = [&]() -> kota::task<> { + co_await md.gate(5).started.wait(); + EXPECT_EQ(graph->refcount(5), 2u); + + ra.source.cancel(); + rc.source.cancel(); + co_await settle([&] { return !graph->is_compiling(5); }); + + EXPECT_TRUE(graph->is_dirty(5)); + EXPECT_EQ(graph->refcount(5), 0u); + EXPECT_EQ(md.gate(5).calls, 1); + co_return; + }; + + co_await kota::when_all(run_request(1, ra), run_request(3, rc), driver()); + + EXPECT_FALSE(ra.result.has_value()); + EXPECT_FALSE(rc.result.has_value()); }); } -TEST_CASE(CompileDepsWithDependency) { - // Unit 1 depends on unit 2. - graph.emplace(tracking_dispatch(compiled), - static_resolver({ - {1, {2}} +TEST_CASE(shared_dep_queued_cancel) { + // E is still queued behind its own dependency F(9) (not yet + // dispatching) when A is cancelled: E's task survives and keeps + // waiting, F is untouched. + ManualDispatch md; + make_graph(md.fn(), + static_resolver({ + {1, {2}}, + {2, {5}}, + {3, {4}}, + {4, {5}}, + {5, {9}} })); + md.open({1, 2, 3, 4, 5}); + Request ra, rc; execute([&]() -> kota::task<> { - auto result = co_await graph->compile_deps(1).catch_cancel(); - EXPECT_TRUE(result.has_value()); - EXPECT_TRUE(*result); - // Only dep 2 should be compiled, NOT unit 1 itself. - EXPECT_EQ(compiled.size(), 1u); - EXPECT_EQ(compiled[0], 2u); - auto pos1 = ranges::find(compiled, 1u); - EXPECT_TRUE(pos1 == compiled.end()); + auto driver = [&]() -> kota::task<> { + co_await md.gate(9).started.wait(); + EXPECT_TRUE(graph->is_compiling(5)); + EXPECT_EQ(graph->refcount(5), 2u); + + ra.source.cancel(); + co_await settle([&] { return !graph->is_compiling(2); }); + + EXPECT_TRUE(graph->is_compiling(5)); + EXPECT_TRUE(graph->is_compiling(9)); + EXPECT_EQ(graph->refcount(5), 1u); + EXPECT_EQ(md.gate(9).calls, 1); + + md.gate(9).proceed.set(); + co_return; + }; + + co_await kota::when_all(run_request(1, ra), run_request(3, rc), driver()); + + EXPECT_FALSE(ra.result.has_value()); + EXPECT_TRUE(rc.result == true); + EXPECT_FALSE(graph->is_dirty(5)); + EXPECT_FALSE(graph->is_dirty(9)); }); } -TEST_CASE(CompileDepsChain) { - // Chain: 1 -> 2 -> 3. - graph.emplace(tracking_dispatch(compiled), - static_resolver({ - {1, {2}}, - {2, {3}} +TEST_CASE(shared_dep_already_compiled) { + // E was already built by the A-chain; later requests reuse it without + // recompiling. + ManualDispatch md; + make_graph(md.fn(), + static_resolver({ + {1, {2}}, + {2, {5}}, + {3, {4}}, + {4, {5}} })); + md.open({1, 2, 3, 4, 5}); execute([&]() -> kota::task<> { - auto result = co_await graph->compile_deps(1).catch_cancel(); - EXPECT_TRUE(result.has_value()); - EXPECT_TRUE(*result); - // Deps 2 and 3 should be compiled, but NOT unit 1. - EXPECT_EQ(compiled.size(), 2u); - EXPECT_TRUE(ranges::find(compiled, 3u) != compiled.end()); - EXPECT_TRUE(ranges::find(compiled, 2u) != compiled.end()); - EXPECT_TRUE(ranges::find(compiled, 1u) == compiled.end()); + auto r1 = co_await graph->compile(1).catch_cancel(); + EXPECT_TRUE(r1.has_value() && *r1); + EXPECT_EQ(md.gate(5).calls, 1); + + auto r2 = co_await graph->compile(3).catch_cancel(); + EXPECT_TRUE(r2.has_value() && *r2); + EXPECT_EQ(md.gate(5).calls, 1); }); } -TEST_CASE(CompileDepsDiamond) { - // Diamond: 1 -> {2, 3}, 2 -> 4, 3 -> 4. - graph.emplace(tracking_dispatch(compiled), - static_resolver({ - {1, {2, 3}}, - {2, {4} }, - {3, {4} } +TEST_CASE(compile_deps_cancel_releases) { + // A plain .cpp (10) holds root references on its direct deps; + // cancelling the compile_deps request releases them without killing a + // dependency that another consumer still needs. + ManualDispatch md; + make_graph(md.fn(), + static_resolver({ + {10, {5}}, + {3, {4}}, + {4, {5}} })); + md.open({3, 4}); + Request ra, rc; execute([&]() -> kota::task<> { - auto result = co_await graph->compile_deps(1).catch_cancel(); - EXPECT_TRUE(result.has_value()); - EXPECT_TRUE(*result); - // Deps 2, 3, 4 should be compiled, but NOT unit 1. - EXPECT_TRUE(ranges::find(compiled, 1u) == compiled.end()); - EXPECT_TRUE(ranges::find(compiled, 2u) != compiled.end()); - EXPECT_TRUE(ranges::find(compiled, 3u) != compiled.end()); - EXPECT_TRUE(ranges::find(compiled, 4u) != compiled.end()); - // Unit 4 should be compiled exactly once (dedup). - auto count4 = ranges::count(compiled, 4u); - EXPECT_EQ(count4, 1); + auto driver = [&]() -> kota::task<> { + co_await md.gate(5).started.wait(); + // Root reference from the compile_deps request + edge from 4. + EXPECT_EQ(graph->refcount(5), 2u); + + ra.source.cancel(); + co_await kota::sleep(1); + + EXPECT_TRUE(ra.done); + EXPECT_TRUE(graph->is_compiling(5)); + EXPECT_EQ(graph->refcount(5), 1u); + EXPECT_EQ(md.gate(5).calls, 1); + + md.gate(5).proceed.set(); + co_return; + }; + + co_await kota::when_all(run_deps_request(10, ra), run_request(3, rc), driver()); + + EXPECT_FALSE(ra.result.has_value()); + EXPECT_TRUE(rc.result == true); + EXPECT_FALSE(graph->is_dirty(5)); }); } -TEST_CASE(CompileDepsFailure) { - // 1 -> 2. Dispatch fails for unit 2. - auto fail_and_track = [&](std::uint32_t path_id) -> kota::task { - compiled.push_back(path_id); - co_return false; - }; +TEST_CASE(duplicate_requests_cancel_one) { + // Two requests on the same unit; cancelling one must not disturb the + // round the other is waiting on. + ManualDispatch md; + make_graph(md.fn(), no_deps()); - graph.emplace(std::move(fail_and_track), - static_resolver({ - {1, {2}} - })); + Request ra, rc; + execute([&]() -> kota::task<> { + auto driver = [&]() -> kota::task<> { + co_await md.gate(1).started.wait(); + EXPECT_EQ(graph->refcount(1), 2u); + + ra.source.cancel(); + co_await kota::sleep(1); + + EXPECT_TRUE(ra.done); + EXPECT_TRUE(graph->is_compiling(1)); + EXPECT_EQ(graph->refcount(1), 1u); + EXPECT_EQ(md.gate(1).calls, 1); + md.gate(1).proceed.set(); + co_return; + }; + + co_await kota::when_all(run_request(1, ra), run_request(1, rc), driver()); + + EXPECT_FALSE(ra.result.has_value()); + EXPECT_TRUE(rc.result == true); + EXPECT_EQ(md.gate(1).calls, 1); + }); +} + +TEST_CASE(rerequest_within_grace) { + // The only request is cancelled and a new one arrives within the same + // drain cycle — release first, re-acquire second, strictly worse than + // the supersede handoff ordering. The deferred zero-interest check + // bridges the transient zero: the in-flight round survives instead of + // being killed and restarted. + ManualDispatch md; + make_graph(md.fn(), no_deps()); + + kota::event start_c; + Request ra, rc; execute([&]() -> kota::task<> { - auto result = co_await graph->compile_deps(1).catch_cancel(); - EXPECT_TRUE(result.has_value()); - EXPECT_FALSE(*result); - // Unit 1 should NOT be dispatched at all. - EXPECT_TRUE(ranges::find(compiled, 1u) == compiled.end()); + auto delayed_request = [&]() -> kota::task<> { + co_await start_c.wait(); + co_await run_request(1, rc); + }; + + auto driver = [&]() -> kota::task<> { + co_await md.gate(1).started.wait(); + EXPECT_EQ(graph->refcount(1), 1u); + + ra.source.cancel(); + start_c.set(); + // Two sleeps: the second is armed after the zero-interest check, + // so the assertions observe its (skipped) outcome. + co_await kota::sleep(1); + co_await kota::sleep(1); + + EXPECT_TRUE(graph->is_compiling(1)); + EXPECT_EQ(graph->refcount(1), 1u); + EXPECT_EQ(md.gate(1).calls, 1); + + md.gate(1).proceed.set(); + co_return; + }; + + co_await kota::when_all(run_request(1, ra), delayed_request(), driver()); + + EXPECT_FALSE(ra.result.has_value()); + EXPECT_TRUE(rc.result == true); + EXPECT_EQ(md.gate(1).calls, 1); }); } -TEST_CASE(CompileDepsPlainCpp) { - // Simulates a plain .cpp file (unit 10) that imports a module (unit 20). - graph.emplace(tracking_dispatch(compiled), - static_resolver({ - {10, {20}} +/// ============================================================================ +/// Lifecycle +/// ============================================================================ +/// +/// cancel_all restarts in-flight work without dropping waiters; shutdown +/// (cancel + join) quiesces the graph with tasks still in flight. + +TEST_CASE(empty_graph) { + // A graph that never compiled anything: cancel_all is a no-op and + // teardown is clean. + make_graph(instant_dispatch(), no_deps()); + EXPECT_FALSE(graph->has_unit(1)); + graph->cancel_all(); +} + +TEST_CASE(cancel_all_recompile) { + // The graph stays fully usable after cancel_all: a later update + + // compile recompiles everything as usual. + make_graph(tracking_dispatch(compiled), + static_resolver({ + {1, {2}} })); execute([&]() -> kota::task<> { - auto result = co_await graph->compile_deps(10).catch_cancel(); + co_await graph->compile(1).catch_cancel(); + EXPECT_EQ(compiled.size(), 2u); + EXPECT_FALSE(graph->is_dirty(1)); + EXPECT_FALSE(graph->is_dirty(2)); + + graph->cancel_all(); + graph->update(2); + EXPECT_TRUE(graph->is_dirty(2)); + EXPECT_TRUE(graph->is_dirty(1)); + + auto result = co_await graph->compile(1).catch_cancel(); EXPECT_TRUE(result.has_value()); EXPECT_TRUE(*result); - // Only dep 20 should be compiled, NOT the .cpp file itself. - EXPECT_EQ(compiled.size(), 1u); - EXPECT_EQ(compiled[0], 20u); - EXPECT_TRUE(ranges::find(compiled, 10u) == compiled.end()); + EXPECT_EQ(compiled.size(), 4u); + EXPECT_FALSE(graph->is_dirty(1)); + EXPECT_FALSE(graph->is_dirty(2)); }); } -TEST_CASE(CompileDepsConcurrentDedup) { - // Two concurrent compile_deps calls with overlapping dependencies. - // Each dep should be dispatched exactly once (no duplicate compilation). - // Unit 1 depends on {3, 4}, unit 2 depends on {3, 5}. - // Dep 3 is shared — must be compiled only once. - graph.emplace(tracking_dispatch(compiled), - static_resolver({ - {1, {3, 4}}, - {2, {3, 5}}, - })); +TEST_CASE(cancel_all_respawns) { + // cancel_all kills the in-flight round; the waiter still holds its + // interest, so it respawns the unit and the request succeeds. + ManualDispatch md; + make_graph(md.fn(), no_deps()); execute([&]() -> kota::task<> { - // Launch both compile_deps concurrently. - auto t1 = graph->compile_deps(1); - auto t2 = graph->compile_deps(2); - auto results = co_await kota::when_all(std::move(t1), std::move(t2)); - - auto [r1, r2] = results; - EXPECT_TRUE(r1); - EXPECT_TRUE(r2); - - // Deps 3, 4, 5 should each be compiled exactly once. - // Unit 1 and 2 should NOT be compiled. - ranges::sort(compiled); - EXPECT_EQ(compiled.size(), 3u); - EXPECT_EQ(compiled[0], 3u); - EXPECT_EQ(compiled[1], 4u); - EXPECT_EQ(compiled[2], 5u); + bool done = false; + std::optional result; + + auto compiler = [&]() -> kota::task<> { + auto r = co_await graph->compile(1).catch_cancel(); + done = true; + if(r.has_value()) { + result = *r; + } + }; + + auto driver = [&]() -> kota::task<> { + co_await md.gate(1).started.wait(); + md.gate(1).started.reset(); + graph->cancel_all(); + co_await md.gate(1).started.wait(); + EXPECT_EQ(md.gate(1).calls, 2); + md.gate(1).proceed.set(); + co_return; + }; + + co_await kota::when_all(compiler(), driver()); + + EXPECT_TRUE(done); + EXPECT_TRUE(result == true); + EXPECT_FALSE(graph->is_dirty(1)); }); } -TEST_CASE(CompileDepsResolveOnce) { - // Verify that resolve_fn is called at most once per unit, - // even when multiple compile_deps requests touch the same dependency. - int resolve_count = 0; +TEST_CASE(shutdown_with_inflight) { + // shutdown() with several unit tasks in flight: pending requests + // resolve with failure (the graph refuses to respawn), every frame + // unwinds, and the destructor protocol holds (idle, no asserts). + ManualDispatch md; + make_graph(md.fn(), + static_resolver({ + {1, {2, 3}}, + {2, {4} }, + {3, {4} } + })); - auto resolve = [&resolve_count](std::uint32_t path_id) -> llvm::SmallVector { - resolve_count++; - if(path_id == 1 || path_id == 2) - return {3}; - return {}; + Request req; + auto driver = [&]() -> kota::task<> { + co_await md.gate(4).started.wait(); + co_await graph->shutdown(); + co_return; }; - graph.emplace(tracking_dispatch(compiled), std::move(resolve)); + auto t1 = run_request(1, req); + auto t2 = driver(); + loop->schedule(t1); + loop->schedule(t2); + loop->run(); - execute([&]() -> kota::task<> { - auto t1 = graph->compile_deps(1); - auto t2 = graph->compile_deps(2); - auto results = co_await kota::when_all(std::move(t1), std::move(t2)); + EXPECT_TRUE(req.done); + EXPECT_TRUE(req.result == false); + EXPECT_TRUE(graph->idle()); +} - auto [r1, r2] = results; - EXPECT_TRUE(r1); - EXPECT_TRUE(r2); +/// ============================================================================ +/// Randomized stress +/// ============================================================================ +/// +/// A fixed-seed, single-threaded interleaving of requests, cancellations, +/// updates and dispatch completions. Verifies the structural invariants +/// at every step and full quiescence at teardown. + +TEST_CASE(randomized_stress) { + kota::semaphore permits{0}; + auto dispatch = [&](std::uint32_t) -> kota::task { + co_await permits.acquire(); + co_return true; + }; - // Dep 3 compiled exactly once. - EXPECT_EQ(compiled.size(), 1u); - EXPECT_EQ(compiled[0], 3u); + make_graph(std::move(dispatch), + static_resolver({ + {1, {2, 3}}, + {2, {4} }, + {3, {4} }, + {4, {5} }, + {6, {4, 7}}, + {7, {5} }, + {8, {6} } + })); - // resolve_fn called for units 1, 2, 3 — each at most once (3 total). - EXPECT_EQ(resolve_count, 3); + execute([&]() -> kota::task<> { + std::mt19937 rng(20260612u); + std::vector> requests; + kota::task_group<> inflight(*loop); + + constexpr std::uint32_t roots[] = {1, 6, 8}; + constexpr std::uint32_t nodes[] = {1, 2, 3, 4, 5, 6, 7, 8}; + + for(int step = 0; step < 200; ++step) { + switch(rng() % 4) { + case 0: { + auto& req = requests.emplace_back(std::make_unique()); + inflight.spawn(run_request(roots[rng() % 3], *req)); + break; + } + case 1: { + if(!requests.empty()) { + requests[rng() % requests.size()]->source.cancel(); + } + break; + } + case 2: { + graph->update(nodes[rng() % 8]); + break; + } + case 3: { + // Let one pending dispatch finish. + permits.release(); + break; + } + } + + // Let deferred unwinds land, then check structural sanity. + co_await kota::sleep(0); + EXPECT_TRUE(graph->consistent()); + } + + // Drain: cancel every outstanding request and wait for them all. + for(auto& req: requests) { + req->source.cancel(); + } + co_await inflight.join(); }); }