Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -0,0 +1,6 @@
|
||||
#[cfg(test)]
|
||||
pub mod fake_object_client;
|
||||
pub mod listener;
|
||||
#[cfg(test)]
|
||||
pub mod test_utils;
|
||||
pub mod update_manager;
|
||||
@@ -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
Reference in New Issue
Block a user