Skip to main content

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}