alba_engine/session.rs
1//! The session loop: run the target, then keep answering whatever asks for
2//! the next run.
3//!
4//! ## Shape
5//!
6//! [`session`] owns the long-lived state a run does not: the loaded
7//! project, the watched set, the watcher, and what the next run is for. It
8//! builds the `WatchSet`, runs the target through the ordinary scheduler
9//! (the cache does the incremental work), then waits for the [`Watcher`]'s
10//! debounced batches. A relevant batch during the wait starts the next run;
11//! one during a run cancels that run first — latest code wins — and the
12//! trigger event is emitted after the cancelled run's summary, which is
13//! what marks it as interrupted by the watch. The caller's token ends the
14//! session; each run gets a child token so a restart never looks like a
15//! user interrupt.
16//!
17//! ## The two things that ask for a run
18//!
19//! A file change is one; a [`SessionCommand`] from a driver — the TUI — is
20//! the other, and the loop is built so the two converge. Both cancel a run
21//! in flight, both decide what the next iteration builds, and neither is a
22//! special case of the other: a `RunBeam` *retargets* the session, so the
23//! watch triggers that follow re-run the beam the driver last asked for.
24//! Where they meet, the command wins on what to run — it is the later,
25//! more explicit request — while a batch that landed too is still honoured
26//! for what only it can say: which files changed, and whether a Beamfile is
27//! among them.
28//!
29//! A session with no command channel is a headless `alba run --watch`: the
30//! command arm is a future that is never ready, and the loop is exactly
31//! what it was before drivers existed. A channel whose sender was dropped
32//! is the opposite — a driver that left — and reads as `Shutdown`.
33//!
34//! Watching is a toggle rather than a mode. Batches are *always* drained,
35//! because a watcher whose channel nobody reads buffers for the life of the
36//! process; `watch_enabled` decides only whether a drained batch is
37//! classified into a trigger. `WatchWaiting` follows the same rule: a
38//! session that is not watching has finished a run, not started waiting on
39//! files, and says so by saying nothing.
40//!
41//! A trigger that names a loaded Beamfile reloads the project first, so
42//! the next run schedules the beams as they are now written. A project
43//! the session cannot work from — one that no longer parses, one whose
44//! target beam is gone — parks the loop in
45//! [`park_until_the_project_changes`]: reported, announced as a wait,
46//! executing nothing, until a save makes it loadable again.
47//!
48//! ## Why the loop renders errors through a callback
49//!
50//! A mid-session failure (a run that cannot be scheduled, a broken
51//! Beamfile) must be *rendered* — spans, carets, suggestions — and
52//! rendering lives in the CLI, above this crate, which is the only place
53//! that knows how. The callback renders and hands back the string; the
54//! loop is what wraps that answer in a [`crate::RunEvent::ProjectBroken`]
55//! and sends it down the same channel every other event travels on, so a
56//! stream consumer sees it too. Embedding the raw [`SessionError`] itself
57//! in the event would still be wrong — it would force `Clone` and a wire
58//! format on types ([`SourceMap`], [`alba_core::LoadError`]) that exist to
59//! be pretty-printed once — but the rendered text has no such cost, which
60//! is why it, and not the error, is what crosses into the stream.
61
62use std::path::{Path, PathBuf};
63
64use alba_core::{BeamId, Project, SourceMap};
65use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
66use tokio_util::sync::CancellationToken;
67
68use crate::EngineError;
69use crate::affected::{Selection, select};
70use crate::event::RunEvent;
71use crate::scheduler::{Executors, RunOptions, run};
72use crate::watch::set::{Relevance, WatchSet};
73use crate::watch::{SessionError, WatchBatch, WatchExit, Watcher, beamfile_dir, display_paths};
74
75/// What a session can be told to do, by the TUI or any future driver.
76#[derive(Debug)]
77pub enum SessionCommand {
78 /// Run `id` and its subgraph now. Retargets the session: subsequent
79 /// watch triggers re-run `id` too. `force` empties the cache's read
80 /// side for that one run. A run already in flight is cancelled first —
81 /// same rule as a watch trigger, latest request wins.
82 RunBeam { id: BeamId, force: bool },
83 /// Cancel the run in flight; the session stays alive, waiting.
84 CancelRun,
85 /// Turn filesystem watching on or off. Does not touch a run in flight;
86 /// only decides whether future batches trigger anything.
87 SetWatch(bool),
88 /// Cancel any run and end the session.
89 Shutdown,
90}
91
92/// Runs `selection` and keeps re-running it — for a relevant change while
93/// `watch_enabled`, for a [`SessionCommand`] from `commands` — until
94/// `cancel` fires, a `Shutdown` arrives, or the watcher dies.
95///
96/// `project` and `sources` are taken owned: the session outlives the
97/// caller's own copy, and both are cheap to clone.
98///
99/// A `selection` this project cannot resolve — an unknown beam, or a git
100/// reference that does not exist — is *not* an error here: it
101/// parks the session like any other project it cannot work from, because
102/// mid-session the fix is one Beamfile save away and a session that quit
103/// on a renamed beam would be a session that quits while the user is
104/// typing. A caller that wants the other answer — the exit code 2 an
105/// unknown target earns on the command line — asks
106/// [`alba_core::execution_subgraph`] before starting the session, which is
107/// exactly what the CLI does: it is only at startup that "no such beam"
108/// means the invocation was wrong rather than the edit unfinished.
109#[allow(clippy::too_many_arguments)]
110pub async fn session(
111 beamfile: &Path,
112 mut project: Project,
113 mut sources: SourceMap,
114 mut selection: Selection,
115 options: RunOptions,
116 executors: Executors,
117 events: UnboundedSender<RunEvent>,
118 mut commands: Option<UnboundedReceiver<SessionCommand>>,
119 mut watch_enabled: bool,
120 cancel: CancellationToken,
121 mut watcher: Box<dyn Watcher>,
122 render_error: &mut (dyn FnMut(&SessionError) -> String + Send),
123) -> WatchExit {
124 let root = beamfile_dir(beamfile);
125 // `--force` empties the cache's read side for the initial run only:
126 // applying it to every triggered run would re-run the whole subgraph
127 // on every keystroke, which is exactly what the cache is here to
128 // prevent.
129 let mut force_next = options.cache.as_ref().is_some_and(|cache| cache.force);
130
131 loop {
132 // Rebuilt for each run: the set caches the files its patterns
133 // resolved to, and the run just before may have created or
134 // deleted some of them.
135 let mut set = match WatchSet::new(&project, selection.watched_target(), &sources) {
136 Ok(set) => set,
137 Err(error) => {
138 // The target is not in this project — a beam renamed by
139 // the save that got us here, most often. Nothing but a
140 // different Beamfile can change that answer, so park on
141 // one instead of re-running the same failure.
142 let session_error = SessionError::Run {
143 error: EngineError::Core(error),
144 sources: sources.clone(),
145 };
146 let _ = events.send(RunEvent::ProjectBroken {
147 diagnostic: render_error(&session_error),
148 });
149 match park_until_the_project_changes(
150 beamfile,
151 sources.clone(),
152 &events,
153 &mut *watcher,
154 &cancel,
155 &mut commands,
156 &mut watch_enabled,
157 render_error,
158 )
159 .await
160 {
161 Reloaded::Project {
162 project: fresh_project,
163 sources: fresh_sources,
164 commanded,
165 } => {
166 (project, sources) = (fresh_project, fresh_sources);
167 match commanded {
168 Some((id, force)) => {
169 (selection, force_next) = (Selection::Beam(id), force);
170 }
171 None => announce_recovery(&events),
172 }
173 continue;
174 }
175 Reloaded::Exit(exit) => return exit,
176 }
177 }
178 };
179
180 let targets = match select(&project, &sources, &root, &selection) {
181 Ok(targets) => targets,
182 Err(error) => {
183 // Same parking as an unknown target above: nothing but a
184 // change to the project can move this answer (a git
185 // failure included: the reference is fixed for the
186 // session, and a repository that appears or a reference
187 // that gets created will not announce itself).
188 let session_error = SessionError::Run {
189 error,
190 sources: sources.clone(),
191 };
192 let _ = events.send(RunEvent::ProjectBroken {
193 diagnostic: render_error(&session_error),
194 });
195 match park_until_the_project_changes(
196 beamfile,
197 sources.clone(),
198 &events,
199 &mut *watcher,
200 &cancel,
201 &mut commands,
202 &mut watch_enabled,
203 render_error,
204 )
205 .await
206 {
207 Reloaded::Project {
208 project: fresh_project,
209 sources: fresh_sources,
210 commanded,
211 } => {
212 (project, sources) = (fresh_project, fresh_sources);
213 match commanded {
214 Some((id, force)) => {
215 (selection, force_next) = (Selection::Beam(id), force);
216 }
217 None => announce_recovery(&events),
218 }
219 continue;
220 }
221 Reloaded::Exit(exit) => return exit,
222 }
223 }
224 };
225
226 // ---- run phase -------------------------------------------------
227 let run_cancel = cancel.child_token();
228 let mut run_options = options.clone();
229 if let Some(cache) = run_options.cache.as_mut() {
230 cache.force = force_next;
231 }
232 force_next = false;
233
234 // A batch that lands while the run is in flight cancels it and is
235 // held here, so the wait phase below starts the next run at once
236 // instead of announcing a wait nobody is waiting for.
237 let mut pending: Option<Trigger> = None;
238 // A `RunBeam` that lands while the run is in flight cancels it the
239 // same way and is held here; unlike a trigger it also says what to
240 // run next, which is why the wait phase gets skipped when it is set.
241 let mut commanded: Option<(BeamId, bool)> = None;
242 let result = {
243 let run_future = run(
244 &project,
245 &targets,
246 run_options,
247 executors.clone(),
248 events.clone(),
249 run_cancel.clone(),
250 );
251 tokio::pin!(run_future);
252 loop {
253 tokio::select! {
254 result = &mut run_future => break result,
255 command = next_command(&mut commands) => match command {
256 SessionCommand::RunBeam { id, force } => {
257 commanded = Some((id, force));
258 run_cancel.cancel();
259 }
260 SessionCommand::CancelRun => run_cancel.cancel(),
261 // Only what future batches mean; the run in flight
262 // is none of this command's business.
263 SessionCommand::SetWatch(enabled) => watch_enabled = enabled,
264 SessionCommand::Shutdown => {
265 // The same courtesy a closing watcher gets: the
266 // run winds down before the session leaves.
267 run_cancel.cancel();
268 let _ = (&mut run_future).await;
269 return WatchExit::Interrupted;
270 }
271 },
272 batch = watcher.next_batch() => match batch {
273 Some(batch) => {
274 if watch_enabled && let Some(trigger) = relevant(&mut set, batch) {
275 merge(&mut pending, trigger);
276 run_cancel.cancel();
277 }
278 }
279 None => {
280 // Let the run wind down before leaving, so the
281 // session never abandons running commands.
282 run_cancel.cancel();
283 let _ = (&mut run_future).await;
284 return WatchExit::WatcherClosed;
285 }
286 },
287 }
288 }
289 };
290 if let Err(error) = result {
291 let session_error = SessionError::Run {
292 error,
293 sources: sources.clone(),
294 };
295 let _ = events.send(RunEvent::ProjectBroken {
296 diagnostic: render_error(&session_error),
297 });
298 }
299 if cancel.is_cancelled() {
300 return WatchExit::Interrupted;
301 }
302
303 // ---- wait phase ------------------------------------------------
304 // A command that arrived during the run has already decided the
305 // next one, so there is nothing left to wait for: retarget and go.
306 let commanded_run = commanded.is_some();
307 if let Some((id, force)) = commanded {
308 (selection, force_next) = (Selection::Beam(id), force);
309 }
310
311 let trigger = match pending.take() {
312 Some(trigger) => Some(trigger),
313 None if commanded_run => None,
314 None => {
315 if watch_enabled {
316 let _ = events.send(RunEvent::WatchWaiting {
317 files: set.file_count(),
318 });
319 }
320 loop {
321 tokio::select! {
322 () = cancel.cancelled() => return WatchExit::Interrupted,
323 command = next_command(&mut commands) => match command {
324 SessionCommand::RunBeam { id, force } => {
325 (selection, force_next) = (Selection::Beam(id), force);
326 break None;
327 }
328 // Nothing is in flight to cancel.
329 SessionCommand::CancelRun => {}
330 SessionCommand::SetWatch(enabled) => {
331 watch_enabled = enabled;
332 // Re-announce the wait so a consumer sees
333 // the toggle land, with the file count it
334 // now means something for. Turning watching
335 // off adds nothing a wait has not said.
336 if enabled {
337 let _ = events.send(RunEvent::WatchWaiting {
338 files: set.file_count(),
339 });
340 }
341 }
342 SessionCommand::Shutdown => return WatchExit::Interrupted,
343 },
344 batch = watcher.next_batch() => match batch {
345 Some(batch) => {
346 if watch_enabled && let Some(trigger) = relevant(&mut set, batch) {
347 break Some(trigger);
348 }
349 }
350 None => return WatchExit::WatcherClosed,
351 },
352 }
353 }
354 }
355 };
356 // A run the driver asked for announces itself through `RunStarted`
357 // like any other; there is no file change to name.
358 let Some(trigger) = trigger else { continue };
359 let _ = events.send(RunEvent::WatchTriggered {
360 paths: display_paths(&root, &trigger.paths),
361 });
362
363 // ---- reload phase ----------------------------------------------
364 // The next iteration schedules the beams as they are now written,
365 // not as they were when the session started. Whichever way the
366 // project comes back, it is the loop's next iteration that rebuilds
367 // the watched set and runs.
368 if trigger.beamfile {
369 match alba_core::load_project(beamfile) {
370 Ok((fresh_project, fresh_sources)) => {
371 (project, sources) = (fresh_project, fresh_sources);
372 }
373 Err(error) => {
374 let rejected = error.sources.clone();
375 let session_error = SessionError::Load(error);
376 let _ = events.send(RunEvent::ProjectBroken {
377 diagnostic: render_error(&session_error),
378 });
379 match park_until_the_project_changes(
380 beamfile,
381 rejected,
382 &events,
383 &mut *watcher,
384 &cancel,
385 &mut commands,
386 &mut watch_enabled,
387 render_error,
388 )
389 .await
390 {
391 Reloaded::Project {
392 project: fresh_project,
393 sources: fresh_sources,
394 commanded,
395 } => {
396 (project, sources) = (fresh_project, fresh_sources);
397 match commanded {
398 Some((id, force)) => {
399 (selection, force_next) = (Selection::Beam(id), force);
400 }
401 None => announce_recovery(&events),
402 }
403 }
404 Reloaded::Exit(exit) => return exit,
405 }
406 }
407 }
408 }
409 }
410}
411
412/// The driver's next command, or a future that never completes when there
413/// is no driver: `None` is how a headless `alba run --watch` opts out of
414/// the whole mechanism, as one `select!` arm that is never ready.
415///
416/// A closed channel answers `Shutdown` rather than pending forever. A
417/// driver that dropped its sender is a driver that left, and a session
418/// nobody can talk to any more — not to retarget it, not to stop it — has
419/// nothing left to answer.
420///
421/// Cancel-safe, as every arm of the loop's `select!`s must be: `recv` loses
422/// no message when its future is dropped, and a pending future has none.
423async fn next_command(commands: &mut Option<UnboundedReceiver<SessionCommand>>) -> SessionCommand {
424 match commands {
425 Some(receiver) => receiver.recv().await.unwrap_or(SessionCommand::Shutdown),
426 None => std::future::pending().await,
427 }
428}
429
430/// A batch the session acts on.
431struct Trigger {
432 /// The changed paths that concern the session. Empty is a trigger
433 /// too — that is what a rescan looks like, a change the watcher
434 /// cannot name.
435 paths: Vec<PathBuf>,
436 /// Whether one of them is a loaded Beamfile, in which case the
437 /// project is reloaded before the next run.
438 beamfile: bool,
439}
440
441/// What a batch amounts to once classified, `None` when none of its paths
442/// concern the session.
443fn relevant(set: &mut WatchSet, batch: WatchBatch) -> Option<Trigger> {
444 match batch {
445 // The watcher cannot say what changed, so the Beamfile is among
446 // the candidates; a reload is one file read and a parse, far
447 // cheaper than running a stale project until the next save.
448 WatchBatch::Rescan => Some(Trigger {
449 paths: Vec::new(),
450 beamfile: true,
451 }),
452 WatchBatch::Paths(paths) => {
453 let mut trigger = Trigger {
454 paths: Vec::new(),
455 beamfile: false,
456 };
457 for path in paths {
458 match set.classify(&path) {
459 Relevance::Beamfile => {
460 trigger.beamfile = true;
461 trigger.paths.push(path);
462 }
463 Relevance::Input => trigger.paths.push(path),
464 Relevance::Irrelevant => {}
465 }
466 }
467 (!trigger.paths.is_empty()).then_some(trigger)
468 }
469 }
470}
471
472/// Folds a fresh trigger into the one already held, so a burst of batches
473/// during a single run announces one run over their union.
474fn merge(pending: &mut Option<Trigger>, fresh: Trigger) {
475 match pending {
476 Some(existing) => {
477 existing.beamfile |= fresh.beamfile;
478 for path in fresh.paths {
479 if !existing.paths.contains(&path) {
480 existing.paths.push(path);
481 }
482 }
483 }
484 None => *pending = Some(fresh),
485 }
486}
487
488/// Announces the run that a recovered project is about to get.
489///
490/// The asymmetry with the reload that succeeds outright, which stays
491/// silent: there, the trigger just emitted announced the Beamfile change,
492/// and the run is that change being answered — one trigger, one cycle. A
493/// recovery has no such trigger to lean on. The one it could point at
494/// announced the *breaking* edit, and that cycle was closed by an error
495/// report; without this, the fixing save would produce a full run of
496/// output out of nowhere, leaving the user unable to tell whether what
497/// they are reading is the answer to their fix.
498///
499/// No paths are named: the session parked on batches it deliberately
500/// never classified, so it does not know which of them was the fix.
501fn announce_recovery(events: &UnboundedSender<RunEvent>) {
502 let _ = events.send(RunEvent::WatchTriggered { paths: Vec::new() });
503}
504
505/// How the broken-project idle state ended.
506enum Reloaded {
507 /// A project the session can work from again, and the sources it came
508 /// from. `commanded` is set when a `RunBeam` is what ended the park:
509 /// the beam to run, and whether to force it.
510 Project {
511 project: Project,
512 sources: SourceMap,
513 commanded: Option<(BeamId, bool)>,
514 },
515 /// The session ended before one arrived.
516 Exit(WatchExit),
517}
518
519/// One retry of the load while parked: the project when it comes back, and
520/// a fresh report on its way out when the failure is a *different* one.
521fn retry_load(
522 beamfile: &Path,
523 rejected: &mut SourceMap,
524 events: &UnboundedSender<RunEvent>,
525 render_error: &mut (dyn FnMut(&SessionError) -> String + Send),
526) -> Option<(Project, SourceMap)> {
527 match alba_core::load_project(beamfile) {
528 Ok(loaded) => Some(loaded),
529 Err(error) => {
530 if error.sources != *rejected {
531 *rejected = error.sources.clone();
532 let session_error = SessionError::Load(error);
533 let _ = events.send(RunEvent::ProjectBroken {
534 diagnostic: render_error(&session_error),
535 });
536 }
537 None
538 }
539 }
540}
541
542/// The broken-project idle state: the last load — or the watched set built
543/// from it — failed and was reported; nothing may execute until a project
544/// the session can work from exists again. `rejected` is the project text
545/// that report was about.
546///
547/// Every subsequent batch retries the load rather than being classified
548/// first: the stale `WatchSet` could still name the Beamfiles it knew
549/// about, but not one that a fixed `import` line has only just added, and
550/// a retry is one file read plus a parse. What is *not* repeated is the
551/// answer: a load whose sources come back byte-for-byte what was already
552/// reported on says nothing new, and reprinting it would bury the
553/// diagnostic under one copy per write anywhere in the watched tree — an
554/// editor's temporary files, an LSP, a build writing its own artifacts.
555/// Loading is deterministic, so equal sources mean the same outcome and
556/// the same rendering; anything that could change the answer, including
557/// the appearance of a file no load ever managed to read, changes them.
558///
559/// A park answers commands like any other phase — a driver must be able to
560/// quit, or retarget, a session that is executing nothing.
561#[allow(clippy::too_many_arguments)]
562async fn park_until_the_project_changes(
563 beamfile: &Path,
564 mut rejected: SourceMap,
565 events: &UnboundedSender<RunEvent>,
566 watcher: &mut dyn Watcher,
567 cancel: &CancellationToken,
568 commands: &mut Option<UnboundedReceiver<SessionCommand>>,
569 watch_enabled: &mut bool,
570 render_error: &mut (dyn FnMut(&SessionError) -> String + Send),
571) -> Reloaded {
572 // Without this the stream's last word is the trigger that means
573 // "running", and a consumer would read a stopped session as a busy
574 // one for as long as the project stays broken. Zero files is the
575 // honest count: nothing is resolved while nothing loads. Announced
576 // whether or not watching is on, because a park is not a wait on files:
577 // it is the session saying it can do nothing at all.
578 let _ = events.send(RunEvent::WatchWaiting { files: 0 });
579
580 loop {
581 tokio::select! {
582 () = cancel.cancelled() => return Reloaded::Exit(WatchExit::Interrupted),
583 command = next_command(commands) => match command {
584 // The park's own answer to `RunBeam`: retry the load on the
585 // spot and hand the beam back with it, without the "nothing
586 // changed" guard the batches below get. A different target
587 // can make the very same project text work again — a park
588 // on a renamed beam is waiting for exactly that — so a load
589 // that comes back byte-for-byte what was rejected is still
590 // news here. One that fails again simply re-parks.
591 SessionCommand::RunBeam { id, force } => {
592 if let Some((project, sources)) =
593 retry_load(beamfile, &mut rejected, events, render_error)
594 {
595 return Reloaded::Project {
596 project,
597 sources,
598 commanded: Some((id, force)),
599 };
600 }
601 }
602 // Nothing is in flight to cancel; a park executes nothing.
603 SessionCommand::CancelRun => {}
604 // Recorded for the session that resumes: a park classifies
605 // no batch, so the toggle changes nothing while it lasts.
606 SessionCommand::SetWatch(enabled) => *watch_enabled = enabled,
607 SessionCommand::Shutdown => return Reloaded::Exit(WatchExit::Interrupted),
608 },
609 batch = watcher.next_batch() => match batch {
610 Some(_) => {
611 if let Some((project, sources)) =
612 retry_load(beamfile, &mut rejected, events, render_error)
613 && sources != rejected
614 {
615 return Reloaded::Project {
616 project,
617 sources,
618 commanded: None,
619 };
620 }
621 }
622 None => return Reloaded::Exit(WatchExit::WatcherClosed),
623 },
624 }
625 }
626}