Skip to content
Merged
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
245 changes: 200 additions & 45 deletions crates/recent_projects/src/remote_connections.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,57 +144,62 @@ pub async fn open_remote_project(
.await;

if let Some((existing_window, existing_workspace)) = existing {
let remote_connection = cx
.update(|cx| {
existing_workspace
.read(cx)
.project()
.read(cx)
.remote_client()
.and_then(|client| client.read(cx).remote_connection())
})
.ok_or_else(|| anyhow::anyhow!("no remote connection for existing remote workspace"))?;

let (resolved_paths, paths_with_positions) =
determine_paths_with_positions(&remote_connection, paths).await;
let remote_connection = cx.update(|cx| {
existing_workspace
.read(cx)
.project()
.read(cx)
.remote_client()
.and_then(|client| client.read(cx).remote_connection())
});

let open_results = existing_window
.update(cx, |multi_workspace, window, cx| {
window.activate_window();
multi_workspace.activate(existing_workspace.clone(), cx);
existing_workspace.update(cx, |workspace, cx| {
workspace.open_paths(
resolved_paths,
OpenOptions {
visible: Some(open_visible),
..Default::default()
},
None,
window,
cx,
)
})
})?
.await;
if let Some(remote_connection) = remote_connection {
let (resolved_paths, paths_with_positions) =
determine_paths_with_positions(&remote_connection, paths).await;

let open_results = existing_window
.update(cx, |multi_workspace, window, cx| {
window.activate_window();
multi_workspace.activate(existing_workspace.clone(), cx);
existing_workspace.update(cx, |workspace, cx| {
workspace.open_paths(
resolved_paths,
OpenOptions {
visible: Some(open_visible),
..Default::default()
},
None,
window,
cx,
)
})
})?
.await;

_ = existing_window.update(cx, |multi_workspace, _, cx| {
let workspace = multi_workspace.workspace().clone();
workspace.update(cx, |workspace, cx| {
for item in open_results.iter().flatten() {
if let Err(e) = item {
workspace.show_error(&e, cx);
_ = existing_window.update(cx, |multi_workspace, _, cx| {
let workspace = multi_workspace.workspace().clone();
workspace.update(cx, |workspace, cx| {
for item in open_results.iter().flatten() {
if let Err(e) = item {
workspace.show_error(&e, cx);
}
}
}
});
});
});

let items = open_results
.into_iter()
.map(|r| r.and_then(|r| r.ok()))
.collect::<Vec<_>>();
navigate_to_positions(&existing_window, items, &paths_with_positions, cx);
let items = open_results
.into_iter()
.map(|r| r.and_then(|r| r.ok()))
.collect::<Vec<_>>();
navigate_to_positions(&existing_window, items, &paths_with_positions, cx);

return Ok(());
return Ok(());
}
// If the remote connection is dead (e.g. server not running after failed reconnect),
// fall through to establish a fresh connection instead of showing an error.
log::info!(
"existing remote workspace found but connection is dead, starting fresh connection"
);
}

let (window, initial_workspace) = if let Some(window) = open_options.replace_window {
Expand Down Expand Up @@ -722,6 +727,156 @@ mod tests {
);
}

#[gpui::test]
async fn test_reconnect_when_server_not_running(
cx: &mut TestAppContext,
server_cx: &mut TestAppContext,
) {
let app_state = init_test(cx);
let executor = cx.executor();

cx.update(|cx| {
release_channel::init(semver::Version::new(0, 0, 0), cx);
});
server_cx.update(|cx| {
release_channel::init(semver::Version::new(0, 0, 0), cx);
});

let (opts, server_session, connect_guard) = RemoteClient::fake_server(cx, server_cx);

let remote_fs = FakeFs::new(server_cx.executor());
remote_fs
.insert_tree(
path!("/project"),
json!({
"src": {
"main.rs": "fn main() {}",
},
}),
)
.await;

server_cx.update(HeadlessProject::init);
let http_client = Arc::new(BlockedHttpClient);
let node_runtime = NodeRuntime::unavailable();
let languages = Arc::new(language::LanguageRegistry::new(server_cx.executor()));
let proxy = Arc::new(ExtensionHostProxy::new());

let _headless = server_cx.new(|cx| {
HeadlessProject::new(
HeadlessAppState {
session: server_session,
fs: remote_fs.clone(),
http_client: http_client.clone(),
node_runtime: node_runtime.clone(),
languages: languages.clone(),
extension_host_proxy: proxy.clone(),
},
false,
cx,
)
});

drop(connect_guard);

// Open the remote project normally.
let paths = vec![PathBuf::from(path!("/project"))];
let mut async_cx = cx.to_async();
open_remote_project(
opts.clone(),
paths.clone(),
app_state.clone(),
workspace::OpenOptions::default(),
&mut async_cx,
)
.await
.expect("initial open should succeed");

executor.run_until_parked();

assert_eq!(cx.update(|cx| cx.windows().len()), 1);
let window = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());

// Force the remote client into ServerNotRunning state (simulates the
// scenario where the remote server died and reconnection failed).
window
.update(cx, |multi_workspace, _, cx| {
let workspace = multi_workspace.workspace().clone();
workspace.update(cx, |workspace, cx| {
let client = workspace
.project()
.read(cx)
.remote_client()
.expect("should have remote client");
client.update(cx, |client, cx| {
client.force_server_not_running(cx);
});
});
})
.unwrap();

executor.run_until_parked();

// Register a new mock server under the same options so the reconnect
// path can establish a fresh connection.
let (server_session_2, connect_guard_2) =
RemoteClient::fake_server_with_opts(&opts, cx, server_cx);

let _headless_2 = server_cx.new(|cx| {
HeadlessProject::new(
HeadlessAppState {
session: server_session_2,
fs: remote_fs.clone(),
http_client,
node_runtime,
languages,
extension_host_proxy: proxy,
},
false,
cx,
)
});

drop(connect_guard_2);

// Simulate clicking "Reconnect": calls open_remote_project with
// replace_window pointing to the existing window.
let result = open_remote_project(
opts,
paths,
app_state,
workspace::OpenOptions {
replace_window: Some(window),
..Default::default()
},
&mut async_cx,
)
.await;

executor.run_until_parked();

assert!(
result.is_ok(),
"reconnect should succeed but got: {:?}",
result.err()
);

// Should still be a single window with a working remote project.
assert_eq!(cx.update(|cx| cx.windows().len()), 1);

window
.update(cx, |multi_workspace, _, cx| {
let workspace = multi_workspace.workspace().clone();
workspace.update(cx, |workspace, cx| {
assert!(
workspace.project().read(cx).is_remote(),
"project should be remote after reconnect"
);
});
})
.unwrap();
}

fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
cx.update(|cx| {
let state = AppState::test(cx);
Expand Down
23 changes: 23 additions & 0 deletions crates/remote/src/remote_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1055,6 +1055,11 @@ impl RemoteClient {
}
}

#[cfg(any(test, feature = "test-support"))]
pub fn force_server_not_running(&mut self, cx: &mut Context<Self>) {
self.set_state(State::ServerNotRunning, cx);
}

#[cfg(any(test, feature = "test-support"))]
pub fn simulate_disconnect(&self, client_cx: &mut App) -> Task<()> {
let opts = self.connection_options();
Expand Down Expand Up @@ -1100,6 +1105,24 @@ impl RemoteClient {
(opts.into(), server_client, connect_guard)
}

/// Registers a new mock server for existing connection options.
///
/// Use this to simulate reconnection: after forcing a disconnect, register
/// a new server so the next `connect()` call succeeds.
#[cfg(any(test, feature = "test-support"))]
pub fn fake_server_with_opts(
opts: &RemoteConnectionOptions,
client_cx: &mut gpui::TestAppContext,
server_cx: &mut gpui::TestAppContext,
) -> (AnyProtoClient, ConnectGuard) {
use crate::transport::mock::MockConnection;
let mock_opts = match opts {
RemoteConnectionOptions::Mock(mock_opts) => mock_opts.clone(),
_ => panic!("fake_server_with_opts requires Mock connection options"),
};
MockConnection::new_with_opts(mock_opts, client_cx, server_cx)
}

/// Creates a `RemoteClient` connected to a mock server.
///
/// Call `fake_server` first to get the connection options, set up the
Expand Down
16 changes: 15 additions & 1 deletion crates/remote/src/transport/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,21 @@ impl MockConnection {
static NEXT_ID: AtomicU64 = AtomicU64::new(0);
let id = NEXT_ID.fetch_add(1, Ordering::SeqCst);
let opts = MockConnectionOptions { id };
let (server_client, connect_guard) =
Self::new_with_opts(opts.clone(), client_cx, server_cx);
(opts, server_client, connect_guard)
}

/// Creates a mock connection pair for existing `MockConnectionOptions`.
///
/// This is useful when simulating reconnection: after a connection is torn
/// down, register a new mock server under the same options so the next
/// `ConnectionPool::connect` call finds it.
pub(crate) fn new_with_opts(
opts: MockConnectionOptions,
client_cx: &mut TestAppContext,
server_cx: &mut TestAppContext,
) -> (AnyProtoClient, ConnectGuard) {
let (outgoing_tx, _) = mpsc::unbounded::<Envelope>();
let (_, incoming_rx) = mpsc::unbounded::<Envelope>();
let server_client = server_cx
Expand All @@ -165,7 +179,7 @@ impl MockConnection {
.insert(opts.id, (rx, connection));
});

(opts, server_client.into(), tx)
(server_client.into(), tx)
}
}

Expand Down