Run imports and deletes on the server, and say so on an event
Leaving the import screen used to cancel the batch it had started: the
request was the work, so the coroutine that owned it died with the screen
and coming back showed no sign anything had happened. A half-imported
session is the expensive kind of missing -- the row is back looking
untouched, and taking it again is the second `--resume` the import path
exists to prevent.
So the work runs on the server now. Delete and a new per-session import both
answer 202 and spawn the work, and `session::pending` is the record of it:
what is running, and how the last attempt failed. The phone reads that two
ways and needs both. Every row of the listing carries `pending` and `error`,
which is what a phone that was asleep, out of range or freshly opened has to
go on; `GET /setups/{id}/importable/events` streams the changes, which is
what makes a screen somebody is watching change by itself.
Neither alone is enough, and that is not theoretical. A broadcast has no
memory, so an operation that started and finished while the stream was still
connecting was one nothing would ever be said about -- with responses held
back far enough to make it visible, one row of a pair of deletes cleared and
the other sat on "waiting" for good. The screen now asks again after a
handover when anything still looks outstanding, and takes its row states
from that answer rather than from what it remembers.
The single tap still waits, because "take me to it" needs the session that
was made and 202 does not carry one. Both paths go through the same `spawn`
so they cannot drift about what importing means.
Resolving one importable session no longer lists every one of them:
`import::find` is the same script with one glob narrower, which takes the
import seed off the 3.7-second full scan that `delete` came off earlier.
The SSE connection and its framing are now `Sse`, shared with the session
transcript stream rather than written a second time.
This commit is contained in:
1 parent
b172c464ea
commit
3c159fa1e1
12 files changed
+992
-176
No files matched your search
+188
-16
@@ -63,12 +63,13 @@ use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{delete, get, post};
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
use tokio_stream::StreamExt;
|
||||
use tokio_stream::wrappers::{BroadcastStream, ReceiverStream};
|
||||
|
||||
use crate::session::driver::SessionCommand;
|
||||
use crate::session::pending::Operation;
|
||||
use crate::session::transcript::{CATCH_UP_LIMIT, CatchUp, SeqEvent, catch_up};
|
||||
use crate::session::{LiveSession, SessionInfo, SessionManager, SpawnSpec};
|
||||
|
||||
@@ -81,6 +82,13 @@ pub fn router(manager: Arc<SessionManager>) -> Router {
|
||||
"/setups/{id}/importable/{session}",
|
||||
delete(delete_importable),
|
||||
)
|
||||
.route(
|
||||
"/setups/{id}/importable/{session}/import",
|
||||
post(start_import),
|
||||
)
|
||||
// Static segment, so this wins over `{session}` above rather than
|
||||
// being read as a session called "events".
|
||||
.route("/setups/{id}/importable/events", get(importable_events))
|
||||
.route(
|
||||
"/setups/{id}",
|
||||
get(read_setup).put(update_setup).delete(delete_setup),
|
||||
@@ -450,7 +458,7 @@ struct SpawnRequest {
|
||||
async fn list_importable(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath(id): UrlPath<String>,
|
||||
) -> Result<axum::Json<Vec<crate::session::import::Importable>>, ApiError> {
|
||||
) -> Result<axum::Json<Vec<ImportableRow>>, ApiError> {
|
||||
let setup = setup_by_id(&manager, &id)?;
|
||||
let transport = crate::session::transport::Transport::for_setup(&setup);
|
||||
let mut found = crate::session::import::list(&transport)
|
||||
@@ -464,8 +472,59 @@ async fn list_importable(
|
||||
// Joined here because the importer knows about files and the manager
|
||||
// knows about sessions, and putting the two together is the route's
|
||||
// job rather than either one's.
|
||||
found.retain(|candidate| manager.session_driving(&candidate.id).is_none());
|
||||
Ok(axum::Json(found))
|
||||
//
|
||||
// Except while this server is in the middle of importing it. A spawn
|
||||
// creates the session partway through, so the row would vanish the
|
||||
// instant the work started and reappear as a session only once it
|
||||
// finished -- and in between, the screen that asked for it would be
|
||||
// showing nothing at all where the thing it is waiting for used to be.
|
||||
// A row with an operation on it stays until the operation settles.
|
||||
found.retain(|candidate| {
|
||||
manager.pending().running(&id, &candidate.id).is_some()
|
||||
|| manager.session_driving(&candidate.id).is_none()
|
||||
});
|
||||
|
||||
// What the server is doing to each of them, joined on here because a
|
||||
// phone that was asleep, out of range, or freshly opened never heard
|
||||
// the events -- see `pending`. An operation is *not* filtered out
|
||||
// above: a row being imported has to stay visible, marked, or the list
|
||||
// would say the work never started.
|
||||
let present: Vec<String> = found.iter().map(|row| row.id.clone()).collect();
|
||||
manager.pending().prune(&id, &present);
|
||||
let rows: Vec<ImportableRow> = found
|
||||
.into_iter()
|
||||
.map(|importable| ImportableRow {
|
||||
pending: manager
|
||||
.pending()
|
||||
.running(&id, &importable.id)
|
||||
.map(|operation| operation.label()),
|
||||
error: manager.pending().failure(&id, &importable.id),
|
||||
importable,
|
||||
})
|
||||
.collect();
|
||||
Ok(axum::Json(rows))
|
||||
}
|
||||
|
||||
/// A row of the import list: what the machine has, plus what this server is
|
||||
/// doing to it.
|
||||
///
|
||||
/// Flattened, so the two halves arrive as one object -- the phone is
|
||||
/// drawing one row and has no use for the seam between "what the machine
|
||||
/// said" and "what we are doing about it".
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ImportableRow {
|
||||
#[serde(flatten)]
|
||||
importable: crate::session::import::Importable,
|
||||
/// The word the row shows while something is running: "importing" or
|
||||
/// "deleting". Absent when nothing is.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pending: Option<&'static str>,
|
||||
/// How the last attempt on this row failed, if it did. Kept until
|
||||
/// something replaces it, because the phone that needs to see it may
|
||||
/// not have been connected when it happened.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
/// Removes a Claude Code session from a machine.
|
||||
@@ -480,30 +539,143 @@ async fn delete_importable(
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let setup = setup_by_id(&manager, &id)?;
|
||||
let transport = crate::session::transport::Transport::for_setup(&setup);
|
||||
crate::session::import::delete(&transport, &session)
|
||||
.await
|
||||
.map_err(bad_request)?;
|
||||
tracing::info!("deleted Claude Code session {session} from setup {id}");
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
let target = session.clone();
|
||||
in_background(&manager, id, session, Operation::Deleting, async move {
|
||||
crate::session::import::delete(&transport, &target).await
|
||||
});
|
||||
Ok(StatusCode::ACCEPTED)
|
||||
}
|
||||
|
||||
/// Continues a Claude Code session, in the background.
|
||||
///
|
||||
/// Separate from `POST /sessions` because the two are asked different
|
||||
/// questions. That one means "start this and take me to it", so it waits
|
||||
/// and answers with the session. This one is the import screen's batch:
|
||||
/// several at once, nobody waiting on any particular one, and the answer
|
||||
/// arrives as a row changing rather than as a reply -- which is the whole
|
||||
/// point, since the screen it was started from may well be gone by then.
|
||||
async fn start_import(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath((id, session)): UrlPath<(String, String)>,
|
||||
axum::Json(body): axum::Json<ImportRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
// Checked before accepting, so an unknown machine is still an error the
|
||||
// caller sees rather than a failure it has to go and read off a row.
|
||||
setup_by_id(&manager, &id)?;
|
||||
let request = SpawnRequest {
|
||||
setup: id.clone(),
|
||||
provider: body.provider,
|
||||
// Nothing to say: `spawn` titles an import from the session it
|
||||
// continues, and the cwd comes from the same place.
|
||||
title: None,
|
||||
model: body.model,
|
||||
cwd: None,
|
||||
permission_mode: body.permission_mode,
|
||||
params: std::collections::BTreeMap::new(),
|
||||
import: Some(session.clone()),
|
||||
};
|
||||
let inner = Arc::clone(&manager);
|
||||
in_background(&manager, id, session, Operation::Importing, async move {
|
||||
spawn(&inner, request)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|err| anyhow::anyhow!("{err}"))
|
||||
});
|
||||
Ok(StatusCode::ACCEPTED)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ImportRequest {
|
||||
provider: String,
|
||||
#[serde(default)]
|
||||
model: Option<String>,
|
||||
#[serde(default)]
|
||||
permission_mode: Option<String>,
|
||||
}
|
||||
|
||||
/// Runs `work` on the server, marked as in flight for as long as it takes.
|
||||
///
|
||||
/// Spawned rather than awaited, which is the whole difference: the phone
|
||||
/// asked for it, but the phone leaving must not cancel it. What replaces
|
||||
/// the reply is the pending registry -- the row says what is happening to
|
||||
/// it, whoever is looking and whenever they look.
|
||||
fn in_background<F>(
|
||||
manager: &Arc<SessionManager>,
|
||||
setup: String,
|
||||
session: String,
|
||||
operation: Operation,
|
||||
work: F,
|
||||
) where
|
||||
F: std::future::Future<Output = anyhow::Result<()>> + Send + 'static,
|
||||
{
|
||||
let running = manager.pending().begin(&setup, &session, operation);
|
||||
tokio::spawn(async move {
|
||||
match work.await {
|
||||
Ok(()) => {
|
||||
tracing::info!("{} {session} on {setup}: done", operation.label());
|
||||
running.succeeded();
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("{} {session} on {setup} failed: {err:#}", operation.label());
|
||||
// The server's own words, the way every other failure in
|
||||
// this app reaches a person.
|
||||
running.failed(format!("{err:#}"));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Every change to what is in flight against one machine.
|
||||
///
|
||||
/// Scoped to the setup the screen is showing, the same way a session's
|
||||
/// events are scoped to that session -- a phone watching one machine's
|
||||
/// import list has no use for another's.
|
||||
async fn importable_events(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath(id): UrlPath<String>,
|
||||
) -> Sse<impl tokio_stream::Stream<Item = Result<SseEvent, Infallible>>> {
|
||||
let live = manager.pending().subscribe();
|
||||
let stream = BroadcastStream::new(live).filter_map(move |item| {
|
||||
// A lagged subscriber has missed changes it cannot get back here,
|
||||
// and that is what the listing is for: the screen refetches on
|
||||
// arrival and carries the truth whatever this stream missed.
|
||||
let change = item.ok()?;
|
||||
if change.setup() != id {
|
||||
return None;
|
||||
}
|
||||
Some(Ok(SseEvent::default().json_data(&change).ok()?))
|
||||
});
|
||||
Sse::new(stream).keep_alive(KeepAlive::default())
|
||||
}
|
||||
|
||||
async fn spawn_session(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
axum::Json(body): axum::Json<SpawnRequest>,
|
||||
) -> Result<axum::Json<SessionInfo>, ApiError> {
|
||||
spawn(&manager, body).await.map(axum::Json)
|
||||
}
|
||||
|
||||
/// Starts a session, continuing a Claude Code one where `body.import` names
|
||||
/// it.
|
||||
///
|
||||
/// A function rather than only a handler because the import screen's batch
|
||||
/// runs this from a background task -- see [`start_import`]. Spawning has to
|
||||
/// mean exactly the same thing either way: the same refusal when something
|
||||
/// else already has the conversation open, the same title, the same working
|
||||
/// directory.
|
||||
async fn spawn(manager: &Arc<SessionManager>, body: SpawnRequest) -> Result<SessionInfo, ApiError> {
|
||||
// Resolved before the spawn because both halves of it are the
|
||||
// machine's answer, not the phone's: which file that id names, and
|
||||
// what is in it.
|
||||
let seed = match &body.import {
|
||||
Some(want) => {
|
||||
let setup = setup_by_id(&manager, &body.setup)?;
|
||||
let setup = setup_by_id(manager, &body.setup)?;
|
||||
let transport = crate::session::transport::Transport::for_setup(&setup);
|
||||
let found = crate::session::import::list(&transport)
|
||||
let chosen = crate::session::import::find(&transport, want)
|
||||
.await
|
||||
.map_err(bad_request)?;
|
||||
let chosen = found
|
||||
.into_iter()
|
||||
.find(|candidate| &candidate.id == want)
|
||||
.map_err(bad_request)?
|
||||
.ok_or_else(|| {
|
||||
ApiError::NotFound(format!(
|
||||
"setup \"{}\" has no Claude Code session {want} to import",
|
||||
@@ -615,7 +787,7 @@ async fn spawn_session(
|
||||
info.id,
|
||||
info.title
|
||||
);
|
||||
Ok(axum::Json(info))
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
||||
@@ -166,7 +166,53 @@ pub async fn list(transport: &Transport) -> Result<Vec<Importable>> {
|
||||
// its text in a list, so that reading lost twenty rows rather than
|
||||
// two. Excluding `tool_use_id` keeps both shapes of a real message
|
||||
// and drops the one that is not.
|
||||
let script = r#"
|
||||
let script = listing_script(r#""$HOME"/.claude/projects/*/*.jsonl"#);
|
||||
let launch = Launch::new("sh", vec!["-c".to_string(), script], None);
|
||||
parse_listing(&transport.capture(&launch).await?)
|
||||
}
|
||||
|
||||
/// The same listing, for one session named by id.
|
||||
///
|
||||
/// Importing needs everything a row holds -- the path to follow, how many
|
||||
/// lines have already been written, what it is called, where it was working
|
||||
/// and whether something else has it open -- and used to get them by
|
||||
/// listing *every* session and searching the result. That is a full read of
|
||||
/// every transcript on the machine, seconds of it, to answer a question
|
||||
/// about one file; a batch of imports paid it once each. Same script, same
|
||||
/// parsing, one glob narrower.
|
||||
pub async fn find(transport: &Transport, id: &str) -> Result<Option<Importable>> {
|
||||
if !is_session_id(id) {
|
||||
return Ok(None);
|
||||
}
|
||||
let script = listing_script(r#""$HOME"/.claude/projects/*/"$1".jsonl"#);
|
||||
let launch = Launch::new(
|
||||
"sh",
|
||||
vec!["-c".to_string(), script, "sh".to_string(), id.to_string()],
|
||||
None,
|
||||
);
|
||||
Ok(parse_listing(&transport.capture(&launch).await?)?
|
||||
.into_iter()
|
||||
.find(|candidate| candidate.id == id))
|
||||
}
|
||||
|
||||
/// What the machine is asked, over whichever set of files `glob` names.
|
||||
///
|
||||
/// One script with the glob substituted rather than two that drift: the
|
||||
/// per-file half decides what a row *is*, and a row has to mean the same
|
||||
/// thing whether it arrived from a listing or from a lookup. The glob is
|
||||
/// this module's own text; the only thing that ever crosses from outside is
|
||||
/// the id, which stays an argument (`$1`) and is checked by
|
||||
/// [`is_session_id`] first.
|
||||
fn listing_script(glob: &str) -> String {
|
||||
// `replace` rather than `format!`: this is shell, so it is full of
|
||||
// braces -- `${s##*/}`, an awk program, the `{[^}]*` that finds a usage
|
||||
// record -- and every one of them would have to be doubled to survive a
|
||||
// format string. Doubling braces inside a script is exactly the kind of
|
||||
// edit that looks right and changes what the shell runs.
|
||||
SCRIPT.replace("{glob}", glob)
|
||||
}
|
||||
|
||||
const SCRIPT: &str = r#"
|
||||
if [ -d "$HOME/.claude/sessions" ]; then
|
||||
printf 'LIVEKNOWN\n'
|
||||
for s in "$HOME"/.claude/sessions/*.json; do
|
||||
@@ -180,7 +226,7 @@ if [ -d "$HOME/.claude/sessions" ]; then
|
||||
[ -n "$sid" ] && printf 'LIVE\t%s\n' "$sid"
|
||||
done
|
||||
fi
|
||||
for f in "$HOME"/.claude/projects/*/*.jsonl; do
|
||||
for f in {glob}; do
|
||||
[ -f "$f" ] || continue
|
||||
printf '%s\t%s\t%s\t%s\t%s\t' "$(stat -c %Y "$f" 2>/dev/null || echo 0)" \
|
||||
"$(wc -l < "$f")" "$(stat -c %s "$f" 2>/dev/null || echo 0)" \
|
||||
@@ -190,9 +236,9 @@ for f in "$HOME"/.claude/projects/*/*.jsonl; do
|
||||
printf '\n'
|
||||
done
|
||||
"#;
|
||||
let launch = Launch::new("sh", vec!["-c".to_string(), script.to_string()], None);
|
||||
let found = transport.capture(&launch).await?;
|
||||
|
||||
/// Rows out of what [`listing_script`] printed, with `in_use` filled in.
|
||||
fn parse_listing(found: &str) -> Result<Vec<Importable>> {
|
||||
let mut live = std::collections::HashSet::new();
|
||||
let mut checkable = false;
|
||||
for line in found.lines() {
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod driver;
|
||||
pub mod echo;
|
||||
pub mod import;
|
||||
pub mod llama;
|
||||
pub mod pending;
|
||||
pub mod process;
|
||||
pub mod transcript;
|
||||
pub mod transport;
|
||||
@@ -530,6 +531,10 @@ pub struct SessionManager {
|
||||
/// Held here rather than per session for the reason
|
||||
/// [`SessionManager::subscribe_notifications`] gives.
|
||||
notifications: broadcast::Sender<Notification>,
|
||||
/// Imports and deletes running against a machine's Claude Code
|
||||
/// sessions. Beside the notification channel above because it is the
|
||||
/// same kind of thing: state the phone reads but does not own.
|
||||
pending: Arc<pending::Registry>,
|
||||
/// What to mark sessions spawned here as -- see
|
||||
/// [`SessionManager::marking_new_sessions_throwaway`] and
|
||||
/// [`SessionConfig::throwaway`].
|
||||
@@ -585,6 +590,7 @@ impl SessionManager {
|
||||
data_dir,
|
||||
models_dir,
|
||||
notifications,
|
||||
pending: Arc::new(pending::Registry::default()),
|
||||
spawn_throwaway: false,
|
||||
inner: RwLock::new(Inner { config, live }),
|
||||
};
|
||||
@@ -979,6 +985,13 @@ impl SessionManager {
|
||||
self.notifications.subscribe()
|
||||
}
|
||||
|
||||
/// Imports and deletes running against importable sessions -- see
|
||||
/// [`pending::Registry`], which is also where the reason it lives on
|
||||
/// the server rather than in the phone is written down.
|
||||
pub fn pending(&self) -> &Arc<pending::Registry> {
|
||||
&self.pending
|
||||
}
|
||||
|
||||
pub fn session(&self, id: &str) -> Option<Arc<LiveSession>> {
|
||||
self.inner.read().unwrap().live.get(id).cloned()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
//! What is being done to a machine's Claude Code sessions right now.
|
||||
//!
|
||||
//! Importing and deleting used to be whatever the phone was in the middle
|
||||
//! of: the request was the work, so leaving the screen cancelled it and
|
||||
//! coming back showed no sign it had ever started. Sessions half-imported
|
||||
//! that way are the expensive kind of missing -- the row is back in the
|
||||
//! list looking untouched, and taking it again is the second `--resume` the
|
||||
//! whole import path exists to prevent.
|
||||
//!
|
||||
//! So the work runs here, on the server, and this is the record of it. The
|
||||
//! phone reads that record two ways, and needs both: every row of `GET
|
||||
//! /setups/{id}/importable` carries what is happening to it, which is what
|
||||
//! a phone that was asleep, out of range, or freshly opened has to go on;
|
||||
//! and [`Registry::subscribe`] is the live stream, which is what makes a
|
||||
//! screen somebody is looking at change by itself. Neither is sufficient
|
||||
//! alone -- a broadcast has no memory, and a listing is only true when it
|
||||
//! was fetched.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde::Serialize;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
/// What is being done to an importable session.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Operation {
|
||||
Importing,
|
||||
Deleting,
|
||||
}
|
||||
|
||||
impl Operation {
|
||||
/// The word a row shows while this runs. Fixed here rather than in the
|
||||
/// app so the two ends cannot disagree about what a state is called.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Importing => "importing",
|
||||
Self::Deleting => "deleting",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One change to what is in flight, as it goes out on the stream.
|
||||
///
|
||||
/// The three states are every way an operation ends, including the two that
|
||||
/// are easy to leave out: it can still be running, it can have finished,
|
||||
/// and it can have failed. There is deliberately no "unknown" -- this is
|
||||
/// the server's own work, so not knowing would be a bug rather than a
|
||||
/// state.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "state")]
|
||||
pub enum Change {
|
||||
Started {
|
||||
setup: String,
|
||||
session: String,
|
||||
operation: Operation,
|
||||
},
|
||||
Finished {
|
||||
setup: String,
|
||||
session: String,
|
||||
},
|
||||
Failed {
|
||||
setup: String,
|
||||
session: String,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Change {
|
||||
/// Which machine this is about, so a stream scoped to one can drop the
|
||||
/// rest. Every variant carries it; matching here rather than at the
|
||||
/// filter keeps that fact in one place.
|
||||
pub fn setup(&self) -> &str {
|
||||
match self {
|
||||
Self::Started { setup, .. }
|
||||
| Self::Finished { setup, .. }
|
||||
| Self::Failed { setup, .. } => setup,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything in flight, and the last failure against each session.
|
||||
#[derive(Debug)]
|
||||
pub struct Registry {
|
||||
running: Mutex<HashMap<(String, String), Operation>>,
|
||||
/// Kept after the operation ends, because a phone that was not looking
|
||||
/// when it failed has no other way to find out. Replaced when the next
|
||||
/// operation on that session starts, and dropped by [`Registry::prune`]
|
||||
/// when the session is no longer on the machine -- an error about a
|
||||
/// transcript that is gone has nothing left to be about.
|
||||
failures: Mutex<HashMap<(String, String), String>>,
|
||||
changes: broadcast::Sender<Change>,
|
||||
}
|
||||
|
||||
impl Default for Registry {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
running: Mutex::new(HashMap::new()),
|
||||
failures: Mutex::new(HashMap::new()),
|
||||
// Enough that a phone watching one screen cannot lag behind a
|
||||
// batch of any size somebody would start by hand.
|
||||
changes: broadcast::channel(256).0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Registry {
|
||||
/// Marks an operation as running and announces it.
|
||||
///
|
||||
/// The returned guard is how it stops being marked: settle it with
|
||||
/// [`InFlight::succeeded`] or [`InFlight::failed`], or drop it and it
|
||||
/// reports a failure. Dropping without settling means the task was
|
||||
/// cancelled or panicked, and a row stuck on "importing" for ever is a
|
||||
/// worse answer than one that says it did not finish.
|
||||
pub fn begin(self: &Arc<Self>, setup: &str, session: &str, operation: Operation) -> InFlight {
|
||||
let key = (setup.to_string(), session.to_string());
|
||||
self.running.lock().unwrap().insert(key.clone(), operation);
|
||||
self.failures.lock().unwrap().remove(&key);
|
||||
let _ = self.changes.send(Change::Started {
|
||||
setup: key.0.clone(),
|
||||
session: key.1.clone(),
|
||||
operation,
|
||||
});
|
||||
InFlight {
|
||||
registry: Arc::clone(self),
|
||||
key,
|
||||
settled: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// What is happening to this session, if anything is.
|
||||
pub fn running(&self, setup: &str, session: &str) -> Option<Operation> {
|
||||
let key = (setup.to_string(), session.to_string());
|
||||
self.running.lock().unwrap().get(&key).copied()
|
||||
}
|
||||
|
||||
/// How the last operation on this session failed, if it did.
|
||||
pub fn failure(&self, setup: &str, session: &str) -> Option<String> {
|
||||
let key = (setup.to_string(), session.to_string());
|
||||
self.failures.lock().unwrap().get(&key).cloned()
|
||||
}
|
||||
|
||||
/// Forgets failures against sessions the machine no longer has.
|
||||
///
|
||||
/// Called from the listing, which is the only place that knows what is
|
||||
/// still there. A deleted session's failure would otherwise outlive
|
||||
/// everything it referred to.
|
||||
pub fn prune(&self, setup: &str, present: &[String]) {
|
||||
self.failures
|
||||
.lock()
|
||||
.unwrap()
|
||||
.retain(|(kept_setup, session), _| {
|
||||
kept_setup != setup || present.iter().any(|id| id == session)
|
||||
});
|
||||
}
|
||||
|
||||
/// Every change as it happens. See the module note on why this is not
|
||||
/// the only way the phone finds out.
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<Change> {
|
||||
self.changes.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
/// An operation that is running, and its way back out of the registry.
|
||||
pub struct InFlight {
|
||||
registry: Arc<Registry>,
|
||||
key: (String, String),
|
||||
settled: bool,
|
||||
}
|
||||
|
||||
impl InFlight {
|
||||
pub fn succeeded(mut self) {
|
||||
self.settle(None);
|
||||
}
|
||||
|
||||
pub fn failed(mut self, message: String) {
|
||||
self.settle(Some(message));
|
||||
}
|
||||
|
||||
fn settle(&mut self, failure: Option<String>) {
|
||||
if self.settled {
|
||||
return;
|
||||
}
|
||||
self.settled = true;
|
||||
self.registry.running.lock().unwrap().remove(&self.key);
|
||||
let (setup, session) = (self.key.0.clone(), self.key.1.clone());
|
||||
let change = match failure {
|
||||
Some(message) => {
|
||||
self.registry
|
||||
.failures
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(self.key.clone(), message.clone());
|
||||
Change::Failed {
|
||||
setup,
|
||||
session,
|
||||
message,
|
||||
}
|
||||
}
|
||||
None => Change::Finished { setup, session },
|
||||
};
|
||||
let _ = self.registry.changes.send(change);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for InFlight {
|
||||
fn drop(&mut self) {
|
||||
self.settle(Some("the server stopped before it finished".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_operation_is_visible_while_it_runs_and_gone_after() {
|
||||
let registry = Arc::new(Registry::default());
|
||||
let mut changes = registry.subscribe();
|
||||
|
||||
let running = registry.begin("local", "abc", Operation::Importing);
|
||||
assert_eq!(registry.running("local", "abc"), Some(Operation::Importing));
|
||||
assert!(matches!(changes.try_recv(), Ok(Change::Started { .. })));
|
||||
|
||||
running.succeeded();
|
||||
assert_eq!(registry.running("local", "abc"), None);
|
||||
assert_eq!(registry.failure("local", "abc"), None);
|
||||
assert!(matches!(changes.try_recv(), Ok(Change::Finished { .. })));
|
||||
}
|
||||
|
||||
/// A failure outlives the operation, because the phone that needs it may
|
||||
/// not have been listening when it happened.
|
||||
#[test]
|
||||
fn a_failure_is_kept_until_something_replaces_or_prunes_it() {
|
||||
let registry = Arc::new(Registry::default());
|
||||
|
||||
registry
|
||||
.begin("local", "abc", Operation::Deleting)
|
||||
.failed("no such session".to_string());
|
||||
assert_eq!(registry.running("local", "abc"), None);
|
||||
assert_eq!(
|
||||
registry.failure("local", "abc").as_deref(),
|
||||
Some("no such session")
|
||||
);
|
||||
|
||||
// Still on the machine, so the failure is still about something.
|
||||
registry.prune("local", &["abc".to_string()]);
|
||||
assert!(registry.failure("local", "abc").is_some());
|
||||
|
||||
// Another machine's listing says nothing about this one's.
|
||||
registry.prune("other", &[]);
|
||||
assert!(registry.failure("local", "abc").is_some());
|
||||
|
||||
registry.prune("local", &[]);
|
||||
assert!(registry.failure("local", "abc").is_none());
|
||||
}
|
||||
|
||||
/// Trying again clears the last failure, so a row cannot show an error
|
||||
/// from before the attempt somebody is currently watching.
|
||||
#[test]
|
||||
fn starting_again_clears_the_previous_failure() {
|
||||
let registry = Arc::new(Registry::default());
|
||||
registry
|
||||
.begin("local", "abc", Operation::Importing)
|
||||
.failed("first go".to_string());
|
||||
|
||||
let second = registry.begin("local", "abc", Operation::Importing);
|
||||
assert_eq!(registry.failure("local", "abc"), None);
|
||||
second.succeeded();
|
||||
}
|
||||
|
||||
/// A task that is cancelled or panics must not leave a row saying
|
||||
/// something is still happening to it.
|
||||
#[test]
|
||||
fn dropping_an_unsettled_operation_reports_a_failure() {
|
||||
let registry = Arc::new(Registry::default());
|
||||
drop(registry.begin("local", "abc", Operation::Deleting));
|
||||
assert_eq!(registry.running("local", "abc"), None);
|
||||
assert!(registry.failure("local", "abc").is_some());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user