Skip to content

Simplify ownership of the list of comms#1283

Merged
lionel- merged 2 commits into
mainfrom
task/comm-ownership
Jul 6, 2026
Merged

Simplify ownership of the list of comms#1283
lionel- merged 2 commits into
mainfrom
task/comm-ownership

Conversation

@lionel-

@lionel- lionel- commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Progress towards #689 and #691
Follow-up to #1161 (see console_comm.rs changes)

See #1075 for context: we're moving comms out of their own threads to run on the R thread, to simplify the concurrency structure of Ark and remove the risk of concurrent R evaluations which are UB (although these are now also tackled under a different angle, see #1222).

In #1161 I made progress towards phasing out unsafe mutation on Console state (see #1145) by using interior mutability in Console methods. To manage comm state in particular, I went for a take/move approach that removes a comm from the map of comms owned by the Console, and passes the moved comm by value to its handlers. This allowed data explorer comm handlers to manipulate the comm map and insert new explorer comms (when an inline explorer gets promoted to a full explorer).

Moving the comm was awkward with the UI comm though, which needed to be pinned down because its handlers might cause Console events that themselves require the UI comm (e.g. to send a message to the frontend). For this reason the UI comm used a different dispatch strategy with a shared borrow, that required ad hoc infrastructure.

The Help comm is also accessible from Console methods and is going to need a similar setup, so I thought I'd go ahead and try to simplify this:

  • All comms now live in the map, including the UI comm. But they are now wrapped in an Rc.

  • The dispatcher now holds an Rc clone of the comm rather than an exclusively owned value. The comm stays in the Console-owned map during dispatch, which is how they remain accessible for reentrancy while a handler is running. Thanks to the Rc wrapping and cloning, the comm map remains fully owned by the Console during dispatch and can be manipulated while a handler is running, allowing data explorers to clone/promote themselves.

    Note that the handler's &mut comes from interior mutability of the comm handler inside the ConsoleComm. So the one invariant we need to guarantee is that we never dispatch a comm's handler reentrantly.

  • The UI comm still gets some ad hoc handling because of its "pinned" nature (there can only be one UI comm at any time, so opening a new one drops the old comm if any).

Comment on lines +133 to +139
// Snapshot the `Rc`s so the `comms` borrow is dropped before we run any
// handler (a handler may reenter `self.comms`).
let comms: Vec<Rc<ConsoleComm>> = self.comms.borrow().values().map(Rc::clone).collect();
for comm in comms {
comm.handler
.borrow_mut()
.handle_environment(event, &comm.ctx);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, to be clear, a handler may reenter self.comms but it CANNOT call comm.handler.borrow_mut() reentrantly on itself again, right? Otherwise that's a panic at debug time, and we log at release time

Would be nice if we can avoid reentrant issues entirely, but I get it

Comment thread crates/ark/src/console/console_comm.rs Outdated
Comment on lines 156 to 180
/// Remove a comm from the map, keeping the UI index in sync.
fn remove_comm(&self, comm_id: &str) -> Option<Rc<ConsoleComm>> {
let comm = self.comms.borrow_mut().remove(comm_id)?;

/// Take the UI comm only if its `comm_id` matches. Checks and takes
/// in a single `borrow_mut()` so there is no TOCTOU gap.
fn take_ui_comm_if(&self, comm_id: &str) -> Option<ConsoleComm> {
let mut guard = self.ui_comm.borrow_mut();
if guard.as_ref().is_some_and(|ui| ui.comm_id == comm_id) {
guard.take()
} else {
None
let mut ui_comm_id = self.ui_comm_id.borrow_mut();
if ui_comm_id.as_deref() == Some(comm_id) {
*ui_comm_id = None;
}
}

fn set_ui_comm(&self, ui: ConsoleComm) {
*self.ui_comm.borrow_mut() = Some(ui);
Some(comm)
}

// -- Comms map helpers ------------------------------------------------

/// Take a comm out, call `f`, put it back.
fn with_comm_mut(&self, comm_id: &str, f: impl FnOnce(&mut ConsoleComm)) {
let Some(mut comm) = self.take_comm(comm_id) else {
log::warn!("Received message for unknown registered comm {comm_id}");
/// Close and drop the currently registered UI comm, if any.
fn close_ui_comm(&self) {
let Some(old) = self
.ui_comm_id
.borrow_mut()
.take()
.and_then(|old_id| self.comms.borrow_mut().remove(&old_id))
else {
return;
};
f(&mut comm);
self.comms.borrow_mut().insert(comm.comm_id.clone(), comm);
}

fn take_comm(&self, comm_id: &str) -> Option<ConsoleComm> {
self.comms.borrow_mut().remove(comm_id)
log::info!("Replacing an existing UI comm.");
old.handler.borrow_mut().handle_close(&old.ctx);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These don't feel quite right to me

In particular

log::info!("Replacing an existing UI comm.");

that doesn't really align with what happens. you actually remove it here and set it to None but don't replace it here. you replace it in the caller.

i kind of felt like we don't actually need this helper. like it feels like in comm_open_frontend we should do remove_comm(ui_comm_id) (or maybe comm_handle_close, so it does handle_close) followed up with setting the ui_comm_id to the new one.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good points

@lionel- lionel- force-pushed the task/comm-ownership branch from 7fb1d17 to e687df5 Compare July 6, 2026 10:01
@lionel- lionel- merged commit 97d24bf into main Jul 6, 2026
17 checks passed
@lionel- lionel- deleted the task/comm-ownership branch July 6, 2026 10:25
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 6, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants