Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 9 additions & 13 deletions ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 6 additions & 5 deletions js/packages/truapi-host/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,14 +212,15 @@ and then opens one provider per product id.

## Session lifecycle

The core owns the session; the host owns persistence and drives the transitions
below. Every one of them reports the resulting `AuthState` through the `auth`
callback, including when nothing changed — so a host may await an answer at boot
rather than treating silence as "signed out".
The core owns the session; the host owns persistence. At boot the core restores
the `AuthSession` slot on its own and reports the outcome through the `auth`
callback, `Disconnected` included, so a host waits for the first
`authStateChanged` instead of treating silence as "signed out". Every
transition below reports the resulting `AuthState` the same way.

| Runtime method | Use it to |
| ------------------------------- | ---------------------------------------------------------------------------- |
| `activateStoredSession()` | Restore the session in the core's `AuthSession` slot. Await before routing. |
| `activateStoredSession()` | Await the restore of the `AuthSession` slot before opening providers. |
| `activateExternalSession(blob)` | Install a session the host holds itself, without writing it to core storage. |
| `notifySessionStoreChanged()` | Tell the core the persisted blob may have changed; it re-reads it. |
| `disconnectSession()` | Log out: clears the session and notifies the peer. |
Expand Down
12 changes: 5 additions & 7 deletions rust/crates/truapi-codegen/tests/golden/host-callbacks.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 5 additions & 7 deletions rust/crates/truapi-platform/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2540,13 +2540,11 @@ pub enum LoginFailureKind {

/// Host auth UI driven by core-owned [`AuthState`] transitions.
pub trait AuthPresenter: Send + Sync {
/// Observe an auth state change, in transition order. A pairing host's
/// session activation reports its outcome even when it is the default
/// `Disconnected`, so a host that awaits activation before routing never
/// has to read silence as "signed out". Every other emission, and every
/// emission on a host role that has no session activation, happens only
/// when the state actually changes. Default is a no-op for hosts that
/// render no auth UI.
/// Observe an auth state change, in transition order. A pairing host
/// always receives an opening state once the core has restored the
/// persisted session, `Disconnected` included; later emissions happen
/// only when the state changes. Default is a no-op for hosts that render
/// no auth UI.
fn auth_state_changed(&self, state: AuthState) {
let _ = state;
}
Expand Down
19 changes: 4 additions & 15 deletions rust/crates/truapi-server/src/host_logic/session_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
use std::sync::{Arc, Mutex};

use futures::channel::mpsc;
use futures::stream::{self, BoxStream, StreamExt};
use futures::stream::BoxStream;

/// Fan-out notifier for host session-storage change ticks.
#[derive(Default)]
Expand All @@ -30,14 +30,14 @@ impl SessionStoreChangeNotifier {
subscribers.retain(|tx| tx.unbounded_send(()).is_ok());
}

/// Subscribe to storage-change ticks, including one initial tick.
/// Subscribe to storage-change ticks.
pub fn subscribe(&self) -> BoxStream<'static, ()> {
let (tx, rx) = mpsc::unbounded();
self.subscribers
.lock()
.expect("session-store notifier mutex poisoned")
.push(tx);
Box::pin(stream::once(async {}).chain(rx))
Box::pin(rx)
}
}

Expand All @@ -47,21 +47,11 @@ mod tests {
use futures::executor::block_on;
use futures::{FutureExt, StreamExt};

#[test]
fn subscribe_emits_initial_tick() {
let notifier = SessionStoreChangeNotifier::new();
let mut ticks = notifier.subscribe();

assert!(block_on(ticks.next()).is_some());
}

#[test]
fn notify_broadcasts_to_subscribers() {
let notifier = SessionStoreChangeNotifier::new();
let mut first = notifier.subscribe();
let mut second = notifier.subscribe();
let _ = block_on(first.next());
let _ = block_on(second.next());

notifier.notify();

Expand All @@ -88,10 +78,9 @@ mod tests {
}

#[test]
fn no_tick_without_notify_after_initial() {
fn no_tick_without_notify() {
let notifier = SessionStoreChangeNotifier::new();
let mut ticks = notifier.subscribe();
let _ = block_on(ticks.next());

assert!(ticks.next().now_or_never().is_none());
}
Expand Down
10 changes: 4 additions & 6 deletions rust/crates/truapi-server/src/native.rs
Original file line number Diff line number Diff line change
Expand Up @@ -445,12 +445,10 @@ pub trait HostCallbacks: Send + Sync {
/// the pairing QR UI, `Connected`/`Disconnected` as the account badge,
/// `LoginFailed` as a retryable error unless its `kind` is
/// `NoFreeAllowanceSlots`, which is unlikely to succeed before the period
/// rolls over, so retry should not be the primary action. A pairing host's
/// session activation reports its outcome even
/// when it is the default `Disconnected`, so a host that awaits activation
/// before routing never has to read silence as "signed out". Every other
/// emission, and every emission on a host role that has no session
/// activation, happens only when the state actually changes.
/// rolls over, so retry should not be the primary action. A pairing host
/// always receives an opening state once the core has restored the
/// persisted session, `Disconnected` included; later emissions happen
/// only when the state changes.
fn auth_state_changed(&self, state: AuthState);

/// Read a core-owned host-private storage slot. `key` is a SCALE-encoded
Expand Down
137 changes: 127 additions & 10 deletions rust/crates/truapi-server/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7031,6 +7031,115 @@ mod tests {
);
}

#[test]
fn session_store_sync_announces_a_signed_out_boot() {
let platform = Arc::new(StubPlatform::default());
let (_host, pairing_host) =
ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner());

pairing_host
.clone()
.start_session_store_sync_for_tests(test_spawner());

wait_until(
|| {
!platform
.auth_states
.lock()
.expect("auth state list mutex poisoned")
.is_empty()
},
"boot reconcile did not report the signed-out state",
);
assert_eq!(
*platform
.auth_states
.lock()
.expect("auth state list mutex poisoned"),
vec![AuthState::Disconnected]
);
}

#[test]
fn session_store_sync_announces_a_restored_boot_once() {
let stored = sso_session_info();
let platform = Arc::new(StubPlatform {
session_blob: Some(crate::host_logic::session::encode_persisted_session(
&stored,
)),
..Default::default()
});
let (_host, pairing_host) =
ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner());

pairing_host
.clone()
.start_session_store_sync_for_tests(test_spawner());

wait_until(
|| {
!platform
.auth_states
.lock()
.expect("auth state list mutex poisoned")
.is_empty()
},
"boot reconcile did not report the restored session",
);
futures::executor::block_on(pairing_host.activate_stored_session())
.expect("valid stored session activates");
assert_eq!(
*platform
.auth_states
.lock()
.expect("auth state list mutex poisoned"),
vec![AuthState::Connected(connected_session_ui_info(&stored))]
);
}

#[test]
fn session_store_sync_stays_silent_on_an_unchanged_tick() {
let stored = sso_session_info();
let platform = Arc::new(StubPlatform {
session_blob: Some(crate::host_logic::session::encode_persisted_session(
&stored,
)),
..Default::default()
});
let (_host, pairing_host) =
ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner());

pairing_host
.clone()
.start_session_store_sync_for_tests(test_spawner());
wait_until(
|| {
!platform
.auth_states
.lock()
.expect("auth state list mutex poisoned")
.is_empty()
},
"boot reconcile did not report the restored session",
);

pairing_host.notify_session_store_changed();
wait_until(
|| pairing_host.session_store_change_ticks_for_tests() == 1,
"session store sync did not process the change tick",
);

// The store still holds the same session, so the tick is not a
// transition and must not repeat the opening state.
assert_eq!(
*platform
.auth_states
.lock()
.expect("auth state list mutex poisoned"),
vec![AuthState::Connected(connected_session_ui_info(&stored))]
);
}

#[test]
fn session_store_sync_replaces_valid_blob_and_broadcasts_connected() {
let mut replacement = sso_session_info();
Expand Down Expand Up @@ -7080,16 +7189,24 @@ mod tests {
);

assert!(host.test_session_state().current().is_none());
// `set_session` bypasses the auth state cell, so the cell never left
// `Disconnected` and clearing the invalid blob emits nothing. Only a
// session activation announces an unchanged state; a store-sync tick
// that finds nothing must not flash signed out at a signed-in host.
assert!(
platform
// `set_session` bypasses the auth state cell, so the clear is not a
// transition; the boot tick's announcement is the only emission.
wait_until(
|| {
!platform
.auth_states
.lock()
.expect("auth state list mutex poisoned")
.is_empty()
},
"boot reconcile did not report the cleared session",
);
assert_eq!(
*platform
.auth_states
.lock()
.expect("auth state list mutex poisoned")
.is_empty()
.expect("auth state list mutex poisoned"),
vec![AuthState::Disconnected]
);
}

Expand Down Expand Up @@ -7118,8 +7235,8 @@ mod tests {
assert_eq!(*session_clears.lock().unwrap(), 1);
}

/// A persistently failing read clears the backing store once for the
/// initial sync tick. Further clears require explicit host notifications.
/// A persistently failing read clears the backing store once at boot.
/// Further clears require explicit host notifications.
#[test]
fn session_store_sync_clears_once_on_initial_persistent_read_error() {
let session_clears = Arc::new(Mutex::new(0));
Expand Down
7 changes: 3 additions & 4 deletions rust/crates/truapi-server/src/runtime/auth_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,9 @@ use crate::runtime::login_failure::classify_login_failure;
/// observed). The cancel channel for an in-flight login lives inside the
/// in-flight login states, making its registration atomic with the transition.
///
/// Session activation calls [`AuthStateMachine::announce_current`] when it is
/// done, so an activation whose outcome changed nothing — a host that boots
/// signed out, or one whose blob failed to decode — still reports an answer the
/// host can tell apart from "no answer yet". Only the first emission can come
/// The boot-time restore and explicit session activations call
/// [`AuthStateMachine::announce_current`] when done, so the host gets an
/// opening state even when nothing changed. Only the first emission can come
/// from an announcement; everything after it is a real change.
#[derive(Clone)]
pub(crate) struct AuthStateMachine {
Expand Down
Loading