Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
@@ -0,0 +1,474 @@
//! Stateful fake implementation of [`ObjectClient`] for end-to-end tests
//! of cloud preferences sync.
//!
//! Unlike the auto-generated [`MockObjectClient`] (which requires each test
//! to script per-method `expect_*()` calls up front), this fake maintains
//! an in-memory store of preferences that can be read back after the
//! syncer runs. Tests use [`FakeObjectClient::seed_preference`] to add
//! cloud-side state and [`FakeObjectClient::cloud_value`] to assert on
//! the result after a sync roundtrip.
//!
//! Only the subset of [`ObjectClient`] methods actually called by the
//! cloud preferences syncer has a real implementation — every other
//! method panics with `unimplemented!()`. This is deliberate: callers
//! that hit one of these panics have likely wired a non-preferences
//! code path through the fake by mistake.
use std::{
collections::HashMap,
sync::{Arc, Mutex},
};
use anyhow::{anyhow, Result};
use async_channel::Sender;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use warp_graphql::object_permissions::AccessLevel;
use crate::{
cloud_object::{
model::{
actions::{ObjectActionHistory, ObjectActionType},
generic_string_model::GenericStringObjectId,
},
BulkCreateCloudObjectResult, BulkCreateGenericStringObjectsRequest,
CreateCloudObjectResult, CreateObjectRequest, CreatedCloudObject,
GenericStringObjectFormat, GenericStringObjectUniqueKey, JsonObjectType,
ObjectDeleteResult, ObjectIdType, ObjectMetadataUpdateResult, ObjectPermissionUpdateResult,
ObjectPermissionsUpdateData, ObjectType, ObjectsToUpdate, Owner, Revision,
RevisionAndLastEditor, ServerFolder, ServerMetadata, ServerNotebook, ServerObject,
ServerPermissions, ServerPreference, ServerWorkflow, UpdateCloudObjectResult,
},
drive::{folders::FolderId, sharing::SharingAccessLevel},
notebooks::NotebookId,
server::{
cloud_objects::{
listener::ObjectUpdateMessage,
update_manager::{GetCloudObjectResponse, InitialLoadResponse},
},
ids::{ServerId, ServerIdAndType, SyncId},
server_api::object::{GuestIdentifier, ObjectClient},
sync_queue::SerializedModel,
},
settings::cloud_preferences::{CloudPreferenceModel, Platform, Preference},
workflows::WorkflowId,
};
/// A stateful fake cloud preferences backend.
#[derive(Clone, Default)]
pub struct FakeObjectClient {
state: Arc<Mutex<FakeCloudState>>,
}
#[derive(Default)]
struct FakeCloudState {
/// Stored preferences keyed by `GenericStringObjectId`. The
/// preferences syncer addresses updates and deletes by id, so this
/// is the authoritative lookup.
preferences: HashMap<GenericStringObjectId, StoredPreference>,
/// Monotonically increasing counter used to allocate fresh
/// [`ServerId`]s for newly created preferences.
next_server_id: i64,
}
struct StoredPreference {
model: CloudPreferenceModel,
revision: Revision,
metadata_ts: chrono::DateTime<Utc>,
}
impl FakeObjectClient {
/// Seeds a preference into the cloud store as if another client had
/// already written it. Returns the allocated [`ServerId`] so tests
/// can reference it later if needed.
///
/// The `value_json` argument is the JSON-serialized setting value
/// (e.g. `"14.0"` for a float, `"true"` for a bool, `"\"Hack\""`
/// for a string). This matches the format used by
/// [`Preference::new`].
pub fn seed_preference(
&self,
storage_key: &str,
value_json: &str,
platform: Platform,
) -> ServerId {
let preference = build_preference(storage_key, value_json, platform);
let model = CloudPreferenceModel::new(preference);
let mut state = self.state.lock().unwrap();
let server_id = state.alloc_server_id();
state.preferences.insert(
GenericStringObjectId::from(server_id),
StoredPreference {
model,
revision: Revision::now(),
metadata_ts: Utc::now(),
},
);
server_id
}
/// Returns the current cloud value for the preference with the
/// given storage key and platform, or `None` if the fake has no
/// such preference.
///
/// The returned string is the JSON-serialized setting value (same
/// shape as [`Self::seed_preference`]).
pub fn cloud_value(&self, storage_key: &str, platform: Platform) -> Option<String> {
let state = self.state.lock().unwrap();
state
.preferences
.values()
.find(|stored| {
stored.model.string_model.storage_key == storage_key
&& stored.model.string_model.platform == platform
})
.map(|stored| stored.model.string_model.value.to_string())
}
/// Builds an [`InitialLoadResponse`] that reflects the current
/// state of the fake. Tests pass the return value to
/// [`UpdateManager::mock_initial_load`] to simulate the syncer's
/// initial load seeing the seeded cloud state.
pub fn snapshot_as_initial_load_response(&self) -> InitialLoadResponse {
let state = self.state.lock().unwrap();
let server_objects: Vec<Box<dyn ServerObject>> = state
.preferences
.iter()
.map(|(id, stored)| {
let metadata = ServerMetadata {
uid: ServerId::from(*id),
revision: stored.revision.clone(),
metadata_last_updated_ts: stored.metadata_ts.into(),
trashed_ts: None,
folder_id: None,
is_welcome_object: false,
creator_uid: None,
last_editor_uid: None,
current_editor_uid: None,
};
let permissions = ServerPermissions {
space: Owner::mock_current_user(),
guests: Vec::new(),
anyone_link_sharing: None,
permissions_last_updated_ts: stored.metadata_ts.into(),
};
let server_pref = ServerPreference {
id: SyncId::ServerId(ServerId::from(*id)),
model: stored.model.clone(),
metadata,
permissions,
};
Box::new(server_pref) as Box<dyn ServerObject>
})
.collect();
let mut response = InitialLoadResponse::default();
if !server_objects.is_empty() {
response.updated_generic_string_objects.insert(
GenericStringObjectFormat::Json(JsonObjectType::Preference),
server_objects,
);
}
response
}
}
impl FakeCloudState {
fn alloc_server_id(&mut self) -> ServerId {
self.next_server_id += 1;
ServerId::from(self.next_server_id)
}
}
/// Builds a [`Preference`] struct from a storage key, JSON value, and
/// explicit platform. This bypasses [`Preference::new`]'s syncing-mode
/// inference so tests can seed preferences with an arbitrary platform.
fn build_preference(storage_key: &str, value_json: &str, platform: Platform) -> Preference {
let value: serde_json::Value = serde_json::from_str(value_json)
.unwrap_or_else(|err| panic!("invalid JSON value {value_json:?}: {err}"));
Preference {
storage_key: storage_key.to_owned(),
value,
platform,
}
}
#[cfg_attr(not(target_family = "wasm"), async_trait)]
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
impl ObjectClient for FakeObjectClient {
async fn bulk_create_generic_string_objects(
&self,
_owner: Owner,
objects: &[BulkCreateGenericStringObjectsRequest],
) -> Result<BulkCreateCloudObjectResult> {
let mut state = self.state.lock().unwrap();
let mut created = Vec::with_capacity(objects.len());
for request in objects {
let serialized = request.serialized_model.model_as_str();
let model = CloudPreferenceModel::deserialize_owned(serialized)
.map_err(|err| anyhow!("fake cloud failed to deserialize preference: {err}"))?;
let server_id = state.alloc_server_id();
let now = Utc::now();
state.preferences.insert(
GenericStringObjectId::from(server_id),
StoredPreference {
model,
revision: Revision::now(),
metadata_ts: now,
},
);
created.push(CreatedCloudObject {
client_id: request.id,
revision_and_editor: RevisionAndLastEditor {
revision: Revision::now(),
last_editor_uid: None,
},
metadata_ts: now.into(),
server_id_and_type: ServerIdAndType {
id: server_id,
id_type: ObjectIdType::GenericStringObject,
},
creator_uid: None,
permissions: ServerPermissions::mock_personal(),
});
}
Ok(BulkCreateCloudObjectResult::Success {
created_cloud_objects: created,
})
}
async fn update_generic_string_object(
&self,
object_id: GenericStringObjectId,
model: SerializedModel,
_revision: Option<Revision>,
) -> Result<UpdateCloudObjectResult<Box<dyn ServerObject>>> {
let mut state = self.state.lock().unwrap();
let stored = state
.preferences
.get_mut(&object_id)
.ok_or_else(|| anyhow!("fake cloud: no preference with id {object_id:?}"))?;
stored.model = CloudPreferenceModel::deserialize_owned(model.model_as_str())
.map_err(|err| anyhow!("fake cloud failed to deserialize preference: {err}"))?;
stored.revision = Revision::now();
stored.metadata_ts = Utc::now();
Ok(UpdateCloudObjectResult::Success {
revision_and_editor: RevisionAndLastEditor {
revision: stored.revision.clone(),
last_editor_uid: None,
},
})
}
async fn delete_object(&self, id: ServerId) -> Result<ObjectDeleteResult> {
let mut state = self.state.lock().unwrap();
state.preferences.remove(&GenericStringObjectId::from(id));
Ok(ObjectDeleteResult::Success {
deleted_ids: vec![SyncId::ServerId(id)],
})
}
async fn fetch_changed_objects(
&self,
_objects_to_update: ObjectsToUpdate,
_force_refresh: bool,
) -> Result<InitialLoadResponse> {
Ok(self.snapshot_as_initial_load_response())
}
async fn fetch_environment_last_task_run_timestamps(
&self,
) -> Result<HashMap<String, DateTime<Utc>>> {
Ok(HashMap::new())
}
// ───────────────────────────────────────────────────────────────
// The methods below are not exercised by CloudPreferencesSyncer,
// so they intentionally panic. If a future change to the syncer
// starts calling one of these, the test that triggered the call
// will fail loudly with a clear message rather than silently
// misbehave.
// ───────────────────────────────────────────────────────────────
async fn create_workflow(
&self,
_request: CreateObjectRequest,
) -> Result<CreateCloudObjectResult> {
unimplemented!("FakeObjectClient::create_workflow")
}
async fn update_workflow(
&self,
_workflow_id: WorkflowId,
_data: SerializedModel,
_revision: Option<Revision>,
) -> Result<UpdateCloudObjectResult<ServerWorkflow>> {
unimplemented!("FakeObjectClient::update_workflow")
}
async fn create_generic_string_object(
&self,
_format: GenericStringObjectFormat,
_uniqueness_key: Option<GenericStringObjectUniqueKey>,
_request: CreateObjectRequest,
) -> Result<CreateCloudObjectResult> {
unimplemented!("FakeObjectClient::create_generic_string_object")
}
async fn create_notebook(
&self,
_request: CreateObjectRequest,
) -> Result<CreateCloudObjectResult> {
unimplemented!("FakeObjectClient::create_notebook")
}
async fn update_notebook(
&self,
_notebook_id: NotebookId,
_title: Option<String>,
_data: Option<SerializedModel>,
_revision: Option<Revision>,
) -> Result<UpdateCloudObjectResult<ServerNotebook>> {
unimplemented!("FakeObjectClient::update_notebook")
}
async fn create_folder(
&self,
_request: CreateObjectRequest,
) -> Result<CreateCloudObjectResult> {
unimplemented!("FakeObjectClient::create_folder")
}
async fn update_folder(
&self,
_folder_id: FolderId,
_name: SerializedModel,
) -> Result<UpdateCloudObjectResult<ServerFolder>> {
unimplemented!("FakeObjectClient::update_folder")
}
async fn grab_notebook_edit_access(&self, _notebook_id: NotebookId) -> Result<ServerMetadata> {
unimplemented!("FakeObjectClient::grab_notebook_edit_access")
}
async fn give_up_notebook_edit_access(
&self,
_notebook_id: NotebookId,
) -> Result<ServerMetadata> {
unimplemented!("FakeObjectClient::give_up_notebook_edit_access")
}
async fn get_warp_drive_updates(
&self,
_message_sender: Sender<ObjectUpdateMessage>,
_stream_ready_sender: Sender<()>,
) -> Result<()> {
unimplemented!("FakeObjectClient::get_warp_drive_updates")
}
async fn fetch_single_cloud_object(&self, _id: ServerId) -> Result<GetCloudObjectResponse> {
unimplemented!("FakeObjectClient::fetch_single_cloud_object")
}
async fn transfer_notebook_owner(
&self,
_notebook_id: NotebookId,
_owner: Owner,
) -> Result<bool> {
unimplemented!("FakeObjectClient::transfer_notebook_owner")
}
async fn transfer_workflow_owner(
&self,
_workflow_id: WorkflowId,
_owner: Owner,
) -> Result<bool> {
unimplemented!("FakeObjectClient::transfer_workflow_owner")
}
async fn transfer_generic_string_object_owner(
&self,
_id: GenericStringObjectId,
_owner: Owner,
) -> Result<bool> {
unimplemented!("FakeObjectClient::transfer_generic_string_object_owner")
}
async fn trash_object(&self, _id: ServerId) -> Result<bool> {
unimplemented!("FakeObjectClient::trash_object")
}
async fn untrash_object(&self, _id: ServerId) -> Result<ObjectMetadataUpdateResult> {
unimplemented!("FakeObjectClient::untrash_object")
}
async fn empty_trash(&self, _owner: Owner) -> Result<ObjectDeleteResult> {
unimplemented!("FakeObjectClient::empty_trash")
}
async fn move_object(
&self,
_id: ServerId,
_folder_id: Option<FolderId>,
_owner: Owner,
_object_type: ObjectType,
) -> Result<bool> {
unimplemented!("FakeObjectClient::move_object")
}
async fn record_object_action(
&self,
_id: ServerId,
_action_type: ObjectActionType,
_timestamp: DateTime<Utc>,
_data: Option<String>,
) -> Result<ObjectActionHistory> {
unimplemented!("FakeObjectClient::record_object_action")
}
async fn leave_object(&self, _id: ServerId) -> Result<ObjectDeleteResult> {
unimplemented!("FakeObjectClient::leave_object")
}
async fn set_object_link_permissions(
&self,
_object_id: ServerId,
_access_level: SharingAccessLevel,
) -> Result<ObjectPermissionUpdateResult> {
unimplemented!("FakeObjectClient::set_object_link_permissions")
}
async fn remove_object_link_permissions(
&self,
_object_id: ServerId,
) -> Result<ObjectPermissionUpdateResult> {
unimplemented!("FakeObjectClient::remove_object_link_permissions")
}
async fn add_object_guests(
&self,
_object_id: ServerId,
_guest_emails: Vec<String>,
_access_level: AccessLevel,
) -> Result<ObjectPermissionsUpdateData> {
unimplemented!("FakeObjectClient::add_object_guests")
}
async fn update_object_guests(
&self,
_object_id: ServerId,
_guest_emails: Vec<String>,
_access_level: AccessLevel,
) -> Result<ServerPermissions> {
unimplemented!("FakeObjectClient::update_object_guests")
}
async fn remove_object_guest(
&self,
_object_id: ServerId,
_guest: GuestIdentifier,
) -> Result<ServerPermissions> {
unimplemented!("FakeObjectClient::remove_object_guest")
}
}
+438
View File
@@ -0,0 +1,438 @@
use crate::network::{NetworkStatus, NetworkStatusEvent, NetworkStatusKind};
use crate::report_error;
use crate::server::{ids::ServerId, retry_strategies::LISTENER_RETRY_STRATEGY};
use crate::system::{SystemStats, SystemStatsEvent};
use crate::workspaces::{
user_profiles::UserProfileWithUID,
user_workspaces::{UserWorkspaces, UserWorkspacesEvent},
};
use crate::{
cloud_object::{
model::{
actions::ObjectActionHistory,
persistence::{CloudModel, CloudModelEvent},
},
ServerCloudObject, ServerMetadata, ServerPermissions,
},
server::server_api::object::ObjectClient,
};
use super::update_manager::UpdateManager;
use chrono::{DateTime, Utc};
use futures_util::stream::AbortHandle;
use std::time::Duration;
use warpui::r#async::Timer;
use async_channel::Sender;
use std::sync::Arc;
use warpui::{Entity, ModelContext, RequestState, SingletonEntity};
use instant::Instant;
lazy_static::lazy_static! {
/// Between successful websocket connections, we ensured at least this amount of time
/// has elapsed so that we aren't spamming the websocket server (e.g. if connections are being
/// closed quickly for any reason).
static ref WAIT_PERIOD_BETWEEN_SUCCESSFUL_RECONNECTS: Duration = Duration::from_secs(30);
}
/// If the websocket reconnects within this duration of the last disconnection, skip the
/// out-of-band refresh of cloud objects. The periodic poll will catch any missed updates.
///
/// This needs to be relatively large, due to the retry policy we use on websocket disconnection,
/// which waits between 10-40s between retries. At the very least, this should always be slightly
/// larger than the upper end of that range.
const RECONNECTION_REFRESH_THRESHOLD: Duration = Duration::from_secs(60);
/// Maximum random delay added before making an out-of-band refresh after a longer reconnection.
/// Spreading out requests across this window helps avoid a thundering herd when many clients
/// reconnect simultaneously (e.g. after a server release).
const MAX_RECONNECTION_REFRESH_DELAY: Duration = Duration::from_secs(30);
/// Describes the type of websocket connection that was just established.
enum ConnectionEvent {
/// The very first websocket connection after application startup.
InitialConnection,
/// A reconnection after a previous disconnection.
Reconnection {
/// The duration since the last websocket disconnection.
time_since_disconnection: Duration,
},
}
pub enum ListenerEvent {}
/// The Listener is responsible for listening to updates from
/// the server for cloud-object related things (e.g. a notebook was changed,
/// or edit access was taken for a workflow, etc.)
pub struct Listener {
cloud_objects_client: Arc<dyn ObjectClient>,
/// Since we only want to start websocket connections if we know the user is
/// on a team or has access to cloud objects, we keep track of whether
/// or not we should be subscribing for updates. Once we start websockets, we don't stop
/// so that the user gets a snappier experience once they start using Warp Drive.
should_subscribe_to_updates: bool,
/// Abort handle for the (retried) future that resolves when the subscription is done.
current_subscription_abort_handle: Option<AbortHandle>,
/// Channel that we send a message over each time we've successfully established a subscription.
subscription_ready_tx: Sender<()>,
/// The time at which the last websocket disconnection occurred. `None` if no disconnection
/// has occurred yet (i.e., this is the first connection attempt).
last_disconnected_at: Option<Instant>,
/// Abort handle for a pending delayed refresh spawned after a long reconnection. Tracked so
/// that it can be cancelled if the websocket disconnects again before the refresh fires.
pending_refresh_abort_handle: Option<AbortHandle>,
}
#[derive(Debug, Clone)]
#[allow(clippy::enum_variant_names)]
pub enum ObjectUpdateMessage {
ObjectMetadataChanged {
metadata: ServerMetadata,
},
ObjectPermissionsChanged,
// TODO(CLD-2425): Replace `ObjectPermissionsChanged` with this.
ObjectPermissionsChangedV2 {
object_uid: ServerId,
permissions: ServerPermissions,
user_profiles: Vec<UserProfileWithUID>,
},
ObjectContentChanged {
server_object: Box<ServerCloudObject>,
last_editor: Option<UserProfileWithUID>,
},
ObjectDeleted {
object_uid: ServerId,
},
ObjectActionOccurred {
history: ObjectActionHistory,
},
TeamMembershipsChanged,
AmbientTaskUpdated {
task_id: String,
timestamp: DateTime<Utc>,
},
}
impl ObjectUpdateMessage {
fn as_str(&self) -> &'static str {
use ObjectUpdateMessage::*;
match self {
ObjectMetadataChanged { .. } => "ObjectMetadataChanged",
ObjectPermissionsChanged => "ObjectPermissionsChanged",
ObjectPermissionsChangedV2 { .. } => "ObjectPermissionsChanged (V2)",
ObjectContentChanged { .. } => "ObjectContentChanged",
ObjectDeleted { .. } => "ObjectDeleted",
ObjectActionOccurred { .. } => "ObjectActionOccurred",
TeamMembershipsChanged => "TeamMembershipsChanged",
AmbientTaskUpdated { .. } => "AmbientTaskUpdated",
}
}
}
impl Listener {
pub fn new(cloud_objects_client: Arc<dyn ObjectClient>, ctx: &mut ModelContext<Self>) -> Self {
let (subscription_ready_tx, subscription_ready_rx) = async_channel::unbounded();
let mut listener = Self {
cloud_objects_client,
should_subscribe_to_updates: false,
current_subscription_abort_handle: None,
subscription_ready_tx,
last_disconnected_at: None,
pending_refresh_abort_handle: None,
};
// When the websocket signals readiness, decide whether to refresh cloud objects
// based on how long the connection was down.
let _ = ctx.spawn_stream_local(
subscription_ready_rx,
Self::on_subscription_ready,
|_, _| {},
);
ctx.subscribe_to_model(&SystemStats::handle(ctx), Self::handle_cpu_event);
ctx.subscribe_to_model(
&NetworkStatus::handle(ctx),
Self::handle_network_status_changed_event,
);
// To prevent creating unnecessary websockets, we only open a websocket if
// - a user is known to be part of a team
// - or a user has access to >= 1 cloud object
// In either of these cases, it's worth creating a websocket for cloud object updates.
//
// Note that we also want a websocket for CloudPreferences, but this is handled via listening
// to the cloud model for the creation of cloud preferences objects (which happens when settings sync
// is enabled for the first time).
ctx.subscribe_to_model(
&UserWorkspaces::handle(ctx),
Self::handle_user_workspaces_event,
);
ctx.subscribe_to_model(&CloudModel::handle(ctx), Self::handle_cloud_model_event);
// We need to do a one-time check of cloud objects when starting
// because the Cloud Model was initialized before this model and we could have populated
// its object cache with objects from sqlite.
if listener.has_non_welcome_cloud_objects(ctx) {
listener.start_listener(ctx);
}
listener
}
#[cfg(test)]
pub fn mock(ctx: &mut ModelContext<Self>) -> Self {
use crate::server::server_api::ServerApiProvider;
Self::new(ServerApiProvider::new_for_test().get(), ctx)
}
fn is_part_of_some_team(&self, ctx: &ModelContext<Self>) -> bool {
UserWorkspaces::as_ref(ctx).has_teams()
}
// If the user is part of a team, we should start subscribing for updates.
fn handle_user_workspaces_event(
&mut self,
event: &UserWorkspacesEvent,
ctx: &mut ModelContext<Self>,
) {
if let UserWorkspacesEvent::TeamsChanged = event {
if self.is_part_of_some_team(ctx) {
self.start_listener(ctx);
}
}
}
/// Returns true if the user has any object that is not a welcome object. If the user only has objects
/// that are welcome objects, returns false.
fn has_non_welcome_cloud_objects(&self, ctx: &ModelContext<Self>) -> bool {
CloudModel::as_ref(ctx).has_non_welcome_objects()
}
// If the user has access to >= 1 cloud objects, we should subscribe for updates.
fn handle_cloud_model_event(&mut self, _event: &CloudModelEvent, ctx: &mut ModelContext<Self>) {
if self.has_non_welcome_cloud_objects(ctx) {
self.start_listener(ctx);
}
}
// This is a workaround for an issue where the future that should resolve when the websocket
// is finished is _not_ polled when the websocket is closed by the server and the CPU is asleep.
// To get around this, we manually abort the future (effectively closing the websocket)
// when the CPU goes to sleep and restart it when it's awakened.
// https://linear.app/warpdotdev/issue/CLD-172/websocket-hangs-when-closed-during-cpu-sleep
fn handle_cpu_event(&mut self, event: &SystemStatsEvent, ctx: &mut ModelContext<Self>) {
match event {
SystemStatsEvent::CpuWasAwakened => {
if let Some(abort_handle) = self.current_subscription_abort_handle.take() {
abort_handle.abort();
}
// We intentionally do not update `last_disconnected_at` or cancel pending
// refreshes here. The paired `CpuWillSleep` event already handled both;
// this handler just restarts the websocket so that `on_subscription_ready`
// can decide whether to refresh based on the sleep-time gap.
if self.should_subscribe_to_updates {
self.get_warp_drive_updates(ctx);
}
}
SystemStatsEvent::CpuWillSleep => {
if let Some(abort_handle) = self.current_subscription_abort_handle.take() {
abort_handle.abort();
self.last_disconnected_at = Some(Instant::now());
self.cancel_pending_refresh();
}
}
}
}
fn handle_network_status_changed_event(
&mut self,
event: &NetworkStatusEvent,
ctx: &mut ModelContext<Self>,
) {
match event {
NetworkStatusEvent::NetworkStatusChanged { new_status } => match new_status {
// When coming back online, restart a websocket.
NetworkStatusKind::Online => {
if let Some(abort_handle) = self.current_subscription_abort_handle.take() {
abort_handle.abort();
}
if self.should_subscribe_to_updates {
self.get_warp_drive_updates(ctx);
}
}
// When losing connection, abort the current subscription to avoid a lingering future that doesn't resolve.
NetworkStatusKind::Offline => {
if let Some(abort_handle) = self.current_subscription_abort_handle.take() {
abort_handle.abort();
self.last_disconnected_at = Some(Instant::now());
self.cancel_pending_refresh();
}
}
},
}
}
fn start_listener(&mut self, ctx: &mut ModelContext<Self>) {
if !self.should_subscribe_to_updates {
self.should_subscribe_to_updates = true;
self.get_warp_drive_updates(ctx);
}
}
/// Cancels any pending delayed refresh that was scheduled after a reconnection.
fn cancel_pending_refresh(&mut self) {
if let Some(abort_handle) = self.pending_refresh_abort_handle.take() {
abort_handle.abort();
}
}
/// Called each time the websocket signals readiness. Decides whether to trigger an
/// out-of-band refresh of cloud objects based on how long the connection was down.
fn on_subscription_ready(&mut self, _: (), ctx: &mut ModelContext<Self>) {
// Cancel any pending refresh from a previous reconnection to avoid accumulating
// stale refresh requests if the websocket is rapidly cycling.
self.cancel_pending_refresh();
let connection_event = match self.last_disconnected_at {
None => ConnectionEvent::InitialConnection,
Some(disconnected_at) => ConnectionEvent::Reconnection {
time_since_disconnection: disconnected_at.elapsed(),
},
};
match connection_event {
ConnectionEvent::InitialConnection => {
// No out-of-band refresh needed for the initial connection. The periodic
// poll (started by TeamTesterStatus) already fetches cloud objects at
// startup, so an additional request here would be duplicative.
log::info!(
"Initial websocket connection established; skipping out-of-band refresh."
);
}
ConnectionEvent::Reconnection {
time_since_disconnection,
} if time_since_disconnection < RECONNECTION_REFRESH_THRESHOLD => {
log::info!(
"Websocket reconnected after {time_since_disconnection:?}, within the \
{RECONNECTION_REFRESH_THRESHOLD:?} refresh threshold; \
skipping out-of-band refresh.",
);
}
ConnectionEvent::Reconnection {
time_since_disconnection,
} => {
// Add a random delay to avoid a thundering herd when many clients reconnect
// simultaneously (e.g. after a server release).
let delay = MAX_RECONNECTION_REFRESH_DELAY.mul_f32(rand::random::<f32>());
log::info!(
"Websocket reconnected after {time_since_disconnection:?}; \
refreshing objects after {delay:?} delay.",
);
let handle = ctx.spawn(async move { Timer::after(delay).await }, |me, _, ctx| {
me.pending_refresh_abort_handle = None;
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
update_manager.refresh_updated_objects(ctx);
});
});
self.pending_refresh_abort_handle = Some(handle.abort_handle());
}
}
}
fn get_warp_drive_updates(&mut self, ctx: &mut ModelContext<Self>) {
let object_client = self.cloud_objects_client.clone();
let (message_sender, message_receiver) = async_channel::unbounded();
let subscription_ready_tx = self.subscription_ready_tx.clone();
// On every message we receive (over the message_receiver), send it
// to the UpdateManager.
let _ = ctx.spawn_stream_local(
message_receiver,
|_me, item: ObjectUpdateMessage, ctx| {
log::info!(
"Received {} message in CloudObjects::Listener",
item.as_str()
);
UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| {
update_manager.received_message_from_server(item, ctx);
})
},
|_, _| {},
);
// Start the future that sends messages over the message_sender stream.
// TODO: we should investigate having get_warp_drive_updates (and in turn,
// start_graphql_streaming_operation) return an `impl Stream` so that we don't
// need to spawn and then spawn_stream_local. For this, we'll need an equivalent
// spawn_stream (which is like spawn_stream_local but polls the futures in the stream
// on a background thread).
let spawn_handle = ctx.spawn_with_retry_on_error(
move || {
let object_client = object_client.clone();
let message_sender = message_sender.clone();
let subscription_ready_tx = subscription_ready_tx.clone();
async move {
let start_time = Instant::now();
log::info!("Attempting to start websocket connection in CloudObjects::Listener");
let res = object_client
.get_warp_drive_updates(
message_sender,
subscription_ready_tx,
).await;
res.map(|_| start_time.elapsed())
}
},
LISTENER_RETRY_STRATEGY,
|me, req_state, ctx| {
match req_state {
RequestState::RequestSucceeded(elapsed_time) => {
// Record the disconnection time now that a live connection has ended.
// Only set this here (not on failed retries) so that the elapsed time
// accurately reflects the full duration since the real disconnection.
me.last_disconnected_at = Some(Instant::now());
me.cancel_pending_refresh();
// The future only resolves once the stream is done, so
// at that point, we should restart the stream.
// In case the websocket was closed quickly by the server,
// let's ensure at least some time has passed before we restart the connection.
let time_to_wait = (*WAIT_PERIOD_BETWEEN_SUCCESSFUL_RECONNECTS).saturating_sub(elapsed_time);
log::info!("Websocket for CloudObjects::Listener is done; restarting after {}s.", time_to_wait.as_secs());
ctx.spawn(async move {
Timer::after(time_to_wait).await
}, |me, _, ctx| {
me.get_warp_drive_updates(ctx);
});
}
RequestState::RequestFailedRetryPending(e) => {
log::warn!("CloudObjects::Listener: websocket connection failed to connect or finished with an error; trying again: {e:#}");
}
RequestState::RequestFailed(e) => {
report_error!(e.context("CloudObjects::Listener websocket connection failed"));
}
}
},
);
self.current_subscription_abort_handle = Some(spawn_handle.abort_handle());
}
#[allow(dead_code)]
pub fn has_current_subscription_abort_handle(&self) -> bool {
self.current_subscription_abort_handle.is_some()
}
}
impl Entity for Listener {
type Event = ListenerEvent;
}
impl SingletonEntity for Listener {}
+6
View File
@@ -0,0 +1,6 @@
#[cfg(test)]
pub mod fake_object_client;
pub mod listener;
#[cfg(test)]
pub mod test_utils;
pub mod update_manager;
+123
View File
@@ -0,0 +1,123 @@
use std::{
collections::HashMap,
sync::{
mpsc::{sync_channel, Receiver},
Arc,
},
};
use settings::manager::SettingsManager;
use warp_core::execution_mode::{AppExecutionMode, ExecutionMode};
use warpui::{App, ModelHandle, SingletonEntity};
use crate::{
auth::{auth_manager::AuthManager, AuthStateProvider},
cloud_object::model::{
actions::ObjectActions,
persistence::{CloudModel, CloudModelEvent},
},
network::NetworkStatus,
persistence::ModelEvent,
server::{
server_api::{
object::{MockObjectClient, ObjectClient},
ServerApiProvider,
},
sync_queue::SyncQueue,
telemetry::context_provider::AppTelemetryContextProvider,
},
settings::{PrivacySettings, WarpDrivePrivacySettings},
workspaces::{
team_tester::TeamTesterStatus, update_manager::TeamUpdateManager,
user_profiles::UserProfiles, user_workspaces::UserWorkspaces,
},
};
use super::update_manager::UpdateManager;
/// The size of the bounded channel that we use to queue persistence/sqlite-related events.
const CHANNEL_SIZE: usize = 128;
pub struct UpdateManagerStruct {
pub update_manager: ModelHandle<UpdateManager>,
pub receiver: Receiver<ModelEvent>,
pub cloud_model_events: async_channel::Receiver<CloudModelEvent>,
}
pub fn initialize_app(app: &mut App) {
app.add_singleton_model(|ctx| AppExecutionMode::new(ExecutionMode::App, false, ctx));
app.add_singleton_model(|_| NetworkStatus::new());
app.add_singleton_model(|_| SettingsManager::default());
app.add_singleton_model(TeamTesterStatus::mock);
app.update(crate::settings::init_and_register_user_preferences);
// This ServerApiProvider is used for the PrivacySettings model, but not the UpdateManager
// under test.
app.add_singleton_model(|_| ServerApiProvider::new_for_test());
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
app.add_singleton_model(AppTelemetryContextProvider::new_context_provider);
app.add_singleton_model(AuthManager::new_for_test);
WarpDrivePrivacySettings::register(app);
app.update(PrivacySettings::register_singleton);
app.add_singleton_model(CloudModel::mock);
app.add_singleton_model(UserWorkspaces::default_mock);
app.add_singleton_model(TeamUpdateManager::mock);
app.add_singleton_model(|_| ObjectActions::new(Vec::new()));
}
pub fn create_update_manager_struct(
app: &mut App,
server_api: Arc<dyn ObjectClient>,
) -> UpdateManagerStruct {
let (sender, receiver) = sync_channel(CHANNEL_SIZE);
// the sync queue can't be mocked; needs to use the same server_api as the update_manager
app.add_singleton_model(|ctx| SyncQueue::new(Default::default(), server_api.clone(), ctx));
let update_manager =
app.add_singleton_model(|ctx| UpdateManager::new(Some(sender.clone()), server_api, ctx));
app.add_singleton_model(|_| UserProfiles::new(Vec::new()));
// set up the sync queue in a dequeueing state
SyncQueue::handle(app).update(app, |sync_queue, ctx| {
sync_queue.start_dequeueing(ctx);
});
let cloud_model_events = app.update(|ctx| {
let (tx, rx) = async_channel::unbounded();
ctx.subscribe_to_model(&CloudModel::handle(ctx), move |_, event, _| {
let _ = tx.try_send(event.clone());
});
rx
});
// The start of polling is normally triggered by authentication completion, but
// we need to do it manually for tests. We do this AFTER UpdateManager is created
// so the polling uses the correct mock.
TeamTesterStatus::handle(app).update(app, |team_tester, ctx| {
team_tester.initiate_data_pollers(false, ctx);
});
UpdateManagerStruct {
update_manager,
receiver,
cloud_model_events,
}
}
/// Creates a baseline [`MockObjectClient`] with common mocks like:
/// * The logged-in user
/// * Background polling for updated objects
pub fn mock_server_api() -> MockObjectClient {
let mut mock_object_client = MockObjectClient::new();
// Mock *failures* for background fetches. This prevents `UpdateManager` clearing out any
// objects that tests manually add to `CloudModel`.
mock_object_client
.expect_fetch_changed_objects()
.returning(|_, _| Err(anyhow::anyhow!("Ignoring background refresh in tests")));
// Mock environment timestamps fetch - return empty by default.
mock_object_client
.expect_fetch_environment_last_task_run_timestamps()
.returning(|| Ok(HashMap::new()));
mock_object_client
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff