mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
fix(core,app,android): refresh an expired visitor session on the desktop too
The age check and the one retry behind a refresh (B22) lived only in the Android bridge. The desktop opened streams, offline copies and crawls against whatever session the share was opened with, so a window left open for a few hours failed to play or recrawl until it was restarted. The logic moves to pstr-core as SessionAges, owned by SharedLibrary and reached through SharedLibrary::reauthenticating. Both engines go through it for play, warm, offline copy and (desktop) crawl.
This commit is contained in:
1 parent
ce02bbc35a
commit
9e53a9bbdb
3 files changed
+209
-63
No files matched your search
@@ -949,12 +949,6 @@ pub struct AndroidEngine {
|
|||||||
share_generation: AtomicU64,
|
share_generation: AtomicU64,
|
||||||
/// How many Proton thumbnails may be in flight at once.
|
/// How many Proton thumbnails may be in flight at once.
|
||||||
thumbnails: tokio::sync::Semaphore,
|
thumbnails: tokio::sync::Semaphore,
|
||||||
/// When each share's visitor session was last refreshed.
|
|
||||||
///
|
|
||||||
/// Sessions are short-lived and the app is routinely backgrounded for hours
|
|
||||||
/// between one episode and the next, so age — not just a share-store
|
|
||||||
/// mutation — is a reason to reauthenticate before opening anything.
|
|
||||||
session_refreshed: Mutex<HashMap<String, std::time::Instant>>,
|
|
||||||
/// The streaming cache's budget in bytes, for the next connection and,
|
/// The streaming cache's budget in bytes, for the next connection and,
|
||||||
/// through [`AndroidEngine::set_stream_cache_budget`], the open one.
|
/// through [`AndroidEngine::set_stream_cache_budget`], the open one.
|
||||||
cache_budget: AtomicU64,
|
cache_budget: AtomicU64,
|
||||||
@@ -982,13 +976,6 @@ const OFFLINE_DOWNLOAD_CONCURRENCY: usize = 2;
|
|||||||
/// How often a download waiting for a slot asks whether it was cancelled.
|
/// How often a download waiting for a slot asks whether it was cancelled.
|
||||||
const DOWNLOAD_WAIT_POLL: std::time::Duration = std::time::Duration::from_millis(500);
|
const DOWNLOAD_WAIT_POLL: std::time::Duration = std::time::Duration::from_millis(500);
|
||||||
|
|
||||||
/// How long a visitor session is assumed good for without a refresh.
|
|
||||||
///
|
|
||||||
/// A refresh costs one round trip. Not refreshing costs an authentication
|
|
||||||
/// failure that, before this, the viewer could only clear with a manual
|
|
||||||
/// pull-to-refresh.
|
|
||||||
const SESSION_FRESH_FOR: std::time::Duration = std::time::Duration::from_secs(5 * 60);
|
|
||||||
|
|
||||||
#[uniffi::export]
|
#[uniffi::export]
|
||||||
impl AndroidEngine {
|
impl AndroidEngine {
|
||||||
#[uniffi::constructor]
|
#[uniffi::constructor]
|
||||||
@@ -1034,7 +1021,6 @@ impl AndroidEngine {
|
|||||||
share_publication: Mutex::new(()),
|
share_publication: Mutex::new(()),
|
||||||
share_generation: AtomicU64::new(0),
|
share_generation: AtomicU64::new(0),
|
||||||
thumbnails: tokio::sync::Semaphore::new(THUMBNAIL_CONCURRENCY),
|
thumbnails: tokio::sync::Semaphore::new(THUMBNAIL_CONCURRENCY),
|
||||||
session_refreshed: Mutex::new(HashMap::new()),
|
|
||||||
cache_budget: AtomicU64::new(DiskCacheConfig::DEFAULT_BUDGET_BYTES),
|
cache_budget: AtomicU64::new(DiskCacheConfig::DEFAULT_BUDGET_BYTES),
|
||||||
download_permits: Arc::new(tokio::sync::Semaphore::new(OFFLINE_DOWNLOAD_CONCURRENCY)),
|
download_permits: Arc::new(tokio::sync::Semaphore::new(OFFLINE_DOWNLOAD_CONCURRENCY)),
|
||||||
accounts,
|
accounts,
|
||||||
@@ -2281,7 +2267,6 @@ impl AndroidEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
drop(reusable);
|
drop(reusable);
|
||||||
self.session_refreshed.lock().remove(share_id);
|
|
||||||
self.share_generation.fetch_add(1, Ordering::AcqRel);
|
self.share_generation.fetch_add(1, Ordering::AcqRel);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2445,39 +2430,17 @@ impl AndroidEngine {
|
|||||||
uid: &NodeUid,
|
uid: &NodeUid,
|
||||||
copy: bool,
|
copy: bool,
|
||||||
) -> Result<VideoStream, BridgeError> {
|
) -> Result<VideoStream, BridgeError> {
|
||||||
let open = || async {
|
connection
|
||||||
if copy {
|
.library
|
||||||
connection.source.open_for_copy(share_id, uid).await
|
.reauthenticating(share_id, || async {
|
||||||
} else {
|
if copy {
|
||||||
connection.source.open(share_id, uid).await
|
connection.source.open_for_copy(share_id, uid).await
|
||||||
}
|
} else {
|
||||||
};
|
connection.source.open(share_id, uid).await
|
||||||
let stale = self
|
}
|
||||||
.session_refreshed
|
})
|
||||||
.lock()
|
.await
|
||||||
.get(share_id)
|
.map_err(BridgeError::from_display)
|
||||||
.is_none_or(|at| at.elapsed() >= SESSION_FRESH_FOR);
|
|
||||||
if stale {
|
|
||||||
self.refresh_session(connection, share_id).await;
|
|
||||||
}
|
|
||||||
match open().await {
|
|
||||||
Ok(stream) => Ok(stream),
|
|
||||||
Err(_) if !stale => {
|
|
||||||
self.refresh_session(connection, share_id).await;
|
|
||||||
open().await.map_err(BridgeError::from_display)
|
|
||||||
}
|
|
||||||
Err(error) => Err(BridgeError::from_display(error)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Best-effort: a refresh that fails leaves the open to report the real
|
|
||||||
/// problem, which is more useful than a refresh error standing in for it.
|
|
||||||
async fn refresh_session(&self, connection: &Connection, share_id: &str) {
|
|
||||||
if connection.library.refresh_session(share_id).await.is_ok() {
|
|
||||||
self.session_refreshed
|
|
||||||
.lock()
|
|
||||||
.insert(share_id.to_owned(), std::time::Instant::now());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn crawl_inner(&self, share_id: Option<String>) -> Result<(), BridgeError> {
|
async fn crawl_inner(&self, share_id: Option<String>) -> Result<(), BridgeError> {
|
||||||
@@ -2494,9 +2457,6 @@ impl AndroidEngine {
|
|||||||
.map_err(|error| BridgeError::Failure {
|
.map_err(|error| BridgeError::Failure {
|
||||||
reason: format!("refresh session for {id}: {error}"),
|
reason: format!("refresh session for {id}: {error}"),
|
||||||
})?;
|
})?;
|
||||||
self.session_refreshed
|
|
||||||
.lock()
|
|
||||||
.insert(id.clone(), std::time::Instant::now());
|
|
||||||
let before = self
|
let before = self
|
||||||
.catalog
|
.catalog
|
||||||
.lock()
|
.lock()
|
||||||
|
|||||||
@@ -1371,7 +1371,9 @@ impl Engine {
|
|||||||
// share's rows and its stale downloads, and stopping halfway through
|
// share's rows and its stale downloads, and stopping halfway through
|
||||||
// that would leave the two disagreeing.
|
// that would leave the two disagreeing.
|
||||||
let nodes = tokio::select! {
|
let nodes = tokio::select! {
|
||||||
result = library.crawl_reporting(&share_id, report) => match result {
|
result = library.reauthenticating(&share_id, || {
|
||||||
|
library.crawl_reporting(&share_id, &report)
|
||||||
|
}) => match result {
|
||||||
Ok(nodes) => nodes,
|
Ok(nodes) => nodes,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
self.fail(&format!("crawl {share_id}"), error);
|
self.fail(&format!("crawl {share_id}"), error);
|
||||||
@@ -1549,11 +1551,14 @@ impl Engine {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let Some((_, source)) = engine.ensure_connected().await else {
|
let Some((library, source)) = engine.ensure_connected().await else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let uid = node_uid(&target.volume_id, &target.link_id);
|
let uid = node_uid(&target.volume_id, &target.link_id);
|
||||||
match source.open(&target.share_id, &uid).await {
|
let opened = library
|
||||||
|
.reauthenticating(&target.share_id, || source.open(&target.share_id, &uid))
|
||||||
|
.await;
|
||||||
|
match opened {
|
||||||
Ok(stream) if stream.size() == 0 => {
|
Ok(stream) if stream.size() == 0 => {
|
||||||
engine.emit(Event::Error(format!("{} has no content", target.name)));
|
engine.emit(Event::Error(format!("{} has no content", target.name)));
|
||||||
}
|
}
|
||||||
@@ -1594,10 +1599,13 @@ impl Engine {
|
|||||||
if offline {
|
if offline {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let Some(source) = engine.opened().map(|(_, source)| source) else {
|
let Some((library, source)) = engine.opened() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
match source.open(&share_id, &uid).await {
|
let opened = library
|
||||||
|
.reauthenticating(&share_id, || source.open(&share_id, &uid))
|
||||||
|
.await;
|
||||||
|
match opened {
|
||||||
Ok(stream) => stream.warm_edges(),
|
Ok(stream) => stream.warm_edges(),
|
||||||
Err(error) => tracing::debug!(%error, "warming the next episode failed"),
|
Err(error) => tracing::debug!(%error, "warming the next episode failed"),
|
||||||
}
|
}
|
||||||
@@ -1930,14 +1938,14 @@ impl Engine {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
self.update_download(&key, |item| item.state = DownloadState::Running);
|
self.update_download(&key, |item| item.state = DownloadState::Running);
|
||||||
let Some((_, source)) = self.ensure_connected().await else {
|
let Some((library, source)) = self.ensure_connected().await else {
|
||||||
anyhow::bail!("share is not connected");
|
anyhow::bail!("share is not connected");
|
||||||
};
|
};
|
||||||
let stream = source
|
let uid = node_uid(&target.volume_id, &target.link_id);
|
||||||
.open_for_copy(
|
let stream = library
|
||||||
&target.share_id,
|
.reauthenticating(&target.share_id, || {
|
||||||
&node_uid(&target.volume_id, &target.link_id),
|
source.open_for_copy(&target.share_id, &uid)
|
||||||
)
|
})
|
||||||
.await?;
|
.await?;
|
||||||
let revision_id = stream.revision_id().to_owned();
|
let revision_id = stream.revision_id().to_owned();
|
||||||
let block_sizes = stream.block_sizes().to_vec();
|
let block_sizes = stream.block_sizes().to_vec();
|
||||||
|
|||||||
@@ -607,6 +607,75 @@ async fn open_link(url: &str, custom_password: Option<&str>) -> Result<ShareClie
|
|||||||
/// Several opened shares, presented as one library.
|
/// Several opened shares, presented as one library.
|
||||||
pub struct SharedLibrary {
|
pub struct SharedLibrary {
|
||||||
clients: BTreeMap<String, ShareClient>,
|
clients: BTreeMap<String, ShareClient>,
|
||||||
|
sessions: SessionAges,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long a visitor session is assumed good for without a refresh.
|
||||||
|
///
|
||||||
|
/// A refresh costs one round trip. Not refreshing costs an authentication
|
||||||
|
/// failure that the viewer can only clear by restarting or recrawling.
|
||||||
|
pub const SESSION_FRESH_FOR: std::time::Duration = std::time::Duration::from_secs(5 * 60);
|
||||||
|
|
||||||
|
/// When each share's visitor session was last known good.
|
||||||
|
///
|
||||||
|
/// Sessions are short-lived and an app routinely sits open, or in the
|
||||||
|
/// background, for hours between one episode and the next, so age is a reason
|
||||||
|
/// to reauthenticate before opening anything. A share with no entry is stale.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct SessionAges {
|
||||||
|
refreshed: parking_lot::Mutex<std::collections::HashMap<String, std::time::Instant>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionAges {
|
||||||
|
/// Record that `share_id`'s session was good just now.
|
||||||
|
pub fn mark(&self, share_id: &str) {
|
||||||
|
self.refreshed
|
||||||
|
.lock()
|
||||||
|
.insert(share_id.to_owned(), std::time::Instant::now());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_stale(&self, share_id: &str) -> bool {
|
||||||
|
self.refreshed
|
||||||
|
.lock()
|
||||||
|
.get(share_id)
|
||||||
|
.is_none_or(|at| at.elapsed() >= SESSION_FRESH_FOR)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `operation`, reauthenticating around it.
|
||||||
|
///
|
||||||
|
/// Two guards, because there are two ways to lose a visitor session. Age is
|
||||||
|
/// the common one, and a timestamp catches it before the request. Anything
|
||||||
|
/// else — a session dropped early, a machine that slept through a clock
|
||||||
|
/// change — only shows up as a failure, so a fresh-looking session gets one
|
||||||
|
/// retry behind a refresh. `refresh` is best-effort: when it fails the
|
||||||
|
/// operation reports the real problem, which is more useful than a refresh
|
||||||
|
/// error standing in for it.
|
||||||
|
pub async fn run<T, E, R, RF, O, OF>(
|
||||||
|
&self,
|
||||||
|
share_id: &str,
|
||||||
|
refresh: R,
|
||||||
|
operation: O,
|
||||||
|
) -> std::result::Result<T, E>
|
||||||
|
where
|
||||||
|
R: Fn() -> RF,
|
||||||
|
RF: std::future::Future<Output = bool>,
|
||||||
|
O: Fn() -> OF,
|
||||||
|
OF: std::future::Future<Output = std::result::Result<T, E>>,
|
||||||
|
{
|
||||||
|
let stale = self.is_stale(share_id);
|
||||||
|
if stale && refresh().await {
|
||||||
|
self.mark(share_id);
|
||||||
|
}
|
||||||
|
match operation().await {
|
||||||
|
Err(_) if !stale => {
|
||||||
|
if refresh().await {
|
||||||
|
self.mark(share_id);
|
||||||
|
}
|
||||||
|
operation().await
|
||||||
|
}
|
||||||
|
result => result,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One opened share, whichever kind it is.
|
/// One opened share, whichever kind it is.
|
||||||
@@ -728,6 +797,9 @@ impl SharedLibrary {
|
|||||||
) -> Result<(Self, Vec<(Share, Error)>)> {
|
) -> Result<(Self, Vec<(Share, Error)>)> {
|
||||||
let mut clients = BTreeMap::new();
|
let mut clients = BTreeMap::new();
|
||||||
let mut failures = Vec::new();
|
let mut failures = Vec::new();
|
||||||
|
// A reused client's session is of unknown age, so it is left stale; one
|
||||||
|
// opened here has just completed its handshake.
|
||||||
|
let sessions = SessionAges::default();
|
||||||
|
|
||||||
for share in store.list()? {
|
for share in store.list()? {
|
||||||
if let Some(client) = reusable.remove(&share.id) {
|
if let Some(client) = reusable.remove(&share.id) {
|
||||||
@@ -736,13 +808,14 @@ impl SharedLibrary {
|
|||||||
}
|
}
|
||||||
match store.open(&share, account).await {
|
match store.open(&share, account).await {
|
||||||
Ok(client) => {
|
Ok(client) => {
|
||||||
|
sessions.mark(&share.id);
|
||||||
clients.insert(share.id.clone(), client);
|
clients.insert(share.id.clone(), client);
|
||||||
}
|
}
|
||||||
Err(e) => failures.push((share, e)),
|
Err(e) => failures.push((share, e)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok((Self { clients }, failures))
|
Ok((Self { clients, sessions }, failures))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The opened clients, minus `dropping`, ready to hand to
|
/// The opened clients, minus `dropping`, ready to hand to
|
||||||
@@ -783,9 +856,37 @@ impl SharedLibrary {
|
|||||||
.client(share_id)
|
.client(share_id)
|
||||||
.ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))?;
|
.ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))?;
|
||||||
client.refresh_session().await?;
|
client.refresh_session().await?;
|
||||||
|
self.sessions.mark(share_id);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Run `operation` against one share, refreshing its visitor session first
|
||||||
|
/// when it is old and once more if the operation fails. See
|
||||||
|
/// [`SessionAges::run`]. Every open of something on a share goes through
|
||||||
|
/// here, so an app left running for hours does not need restarting to play.
|
||||||
|
pub async fn reauthenticating<T, E, O, OF>(
|
||||||
|
&self,
|
||||||
|
share_id: &str,
|
||||||
|
operation: O,
|
||||||
|
) -> std::result::Result<T, E>
|
||||||
|
where
|
||||||
|
O: Fn() -> OF,
|
||||||
|
OF: std::future::Future<Output = std::result::Result<T, E>>,
|
||||||
|
{
|
||||||
|
self.sessions
|
||||||
|
.run(
|
||||||
|
share_id,
|
||||||
|
|| async {
|
||||||
|
match self.client(share_id) {
|
||||||
|
Some(client) => client.refresh_session().await.is_ok(),
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
operation,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
/// Walk one share's whole subtree, level by level, yielding every node.
|
/// Walk one share's whole subtree, level by level, yielding every node.
|
||||||
///
|
///
|
||||||
/// There is no recursion helper on the visitor path — the authenticated
|
/// There is no recursion helper on the visitor path — the authenticated
|
||||||
@@ -927,6 +1028,83 @@ fn token_from_url(url: &str) -> Result<String> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
|
/// An operation that fails its first `failures` calls, counting every call.
|
||||||
|
fn flaky(
|
||||||
|
failures: usize,
|
||||||
|
calls: &AtomicUsize,
|
||||||
|
) -> impl Fn() -> std::future::Ready<std::result::Result<(), ()>> {
|
||||||
|
move || {
|
||||||
|
let call = calls.fetch_add(1, Ordering::SeqCst);
|
||||||
|
std::future::ready(if call < failures { Err(()) } else { Ok(()) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_share_never_refreshed_is_refreshed_before_the_operation() {
|
||||||
|
let ages = SessionAges::default();
|
||||||
|
let (refreshes, calls) = (AtomicUsize::new(0), AtomicUsize::new(0));
|
||||||
|
let refresh = || {
|
||||||
|
refreshes.fetch_add(1, Ordering::SeqCst);
|
||||||
|
std::future::ready(true)
|
||||||
|
};
|
||||||
|
ages.run("s", refresh, flaky(0, &calls)).await.unwrap();
|
||||||
|
assert_eq!(refreshes.load(Ordering::SeqCst), 1);
|
||||||
|
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_fresh_session_is_not_refreshed_when_the_operation_succeeds() {
|
||||||
|
let ages = SessionAges::default();
|
||||||
|
ages.mark("s");
|
||||||
|
let (refreshes, calls) = (AtomicUsize::new(0), AtomicUsize::new(0));
|
||||||
|
let refresh = || {
|
||||||
|
refreshes.fetch_add(1, Ordering::SeqCst);
|
||||||
|
std::future::ready(true)
|
||||||
|
};
|
||||||
|
ages.run("s", refresh, flaky(0, &calls)).await.unwrap();
|
||||||
|
assert_eq!(refreshes.load(Ordering::SeqCst), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A session Proton dropped early looks fresh by age, and only the failure
|
||||||
|
/// gives it away.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_fresh_session_that_fails_is_refreshed_once_and_retried() {
|
||||||
|
let ages = SessionAges::default();
|
||||||
|
ages.mark("s");
|
||||||
|
let (refreshes, calls) = (AtomicUsize::new(0), AtomicUsize::new(0));
|
||||||
|
let refresh = || {
|
||||||
|
refreshes.fetch_add(1, Ordering::SeqCst);
|
||||||
|
std::future::ready(true)
|
||||||
|
};
|
||||||
|
ages.run("s", refresh, flaky(1, &calls)).await.unwrap();
|
||||||
|
assert_eq!(refreshes.load(Ordering::SeqCst), 1);
|
||||||
|
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A refresh just happened, so a second one would learn nothing: the
|
||||||
|
/// failure is the operation's own and is reported as it is.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_stale_session_that_still_fails_after_its_refresh_is_not_retried() {
|
||||||
|
let ages = SessionAges::default();
|
||||||
|
let calls = AtomicUsize::new(0);
|
||||||
|
let result = ages
|
||||||
|
.run("s", || std::future::ready(true), flaky(5, &calls))
|
||||||
|
.await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_failed_refresh_leaves_the_session_stale() {
|
||||||
|
let ages = SessionAges::default();
|
||||||
|
let calls = AtomicUsize::new(0);
|
||||||
|
ages.run("s", || std::future::ready(false), flaky(0, &calls))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(ages.is_stale("s"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_share_url_yields_its_token() {
|
fn a_share_url_yields_its_token() {
|
||||||
|
|||||||
Reference in new issue
Block a user