nandi/jolt-nativepublic Fork 0
e86c31c
Commits
Clone
git clone https://git.rickub.com/nandi/jolt-native.git
git clone ssh://git@rickub.com/nandi/jolt-native.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Fill the slot, and do not wedge on the way out

Two things a real call found.

An :image was never drawn larger than its own pixels, which is right for a
picture in a message — enlarging a screenshot to fill a column makes it
worse — and wrong for a video tile, which is a slot whose size the layout
decided. A camera sending 480 wide into a 900-point slot sat at 480 in the
middle of it, so a window dragged wider changed nothing anyone could see.
:upscale says which kind this is.

And leaving a call hung the app. joltmoq_stop waited for the media task
while holding the slot lock, and the task's status callback locks that same
slot from a worker thread: the task could not finish, and the thread that
would have released the lock was waiting for the task to finish. The UI
thread is the one that hangs, which is every thread the caller has.

The session now comes out from under the lock before anything waits on it,
and the teardown is spawned rather than waited for — a network round trip
is not something to freeze a window for. It still unpublishes properly; an
abandoned broadcast leaves peers subscribed to someone who looks present
and is silent. There is a test that deadlocks if either goes back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-08-30T17:54:03-07:00 Browse files
e86c31c parent: 42dabb0
modified crates/jolt-moq/src/lib.rs +85 -31
@@ -72,10 +72,6 @@ struct Slot {
7272 /// The frame most recently handed out. Held so that the pixel pointer the
7373 /// caller was given stays alive until the next poll replaces it.
7474 frame: Option<(String, RgbaVideoFrame)>,
75- /// A tokio runtime of our own: the caller has no reactor to lend us. Made
76- /// once and kept, since building one per call would tear worker threads
77- /// down and up again on every join.
78- runtime: Option<tokio::runtime::Runtime>,
7975 }
8076
8177 #[derive(Clone)]
@@ -90,6 +86,26 @@ fn session_slot() -> &'static Mutex<Slot> {
9086 SLOT.get_or_init(|| Mutex::new(Slot::default()))
9187 }
9288
89+/// A tokio runtime of our own: the caller has no reactor to lend us.
90+///
91+/// Deliberately *not* in the slot. Tearing a call down needs the runtime and
92+/// must not be holding the slot lock while it does — see [`joltmoq_stop`].
93+/// Made once and kept, since building one per call would tear worker threads
94+/// down and up again on every join.
95+fn runtime() -> Option<&'static tokio::runtime::Runtime> {
96+ static RUNTIME: OnceLock<Option<tokio::runtime::Runtime>> = OnceLock::new();
97+ RUNTIME
98+ .get_or_init(|| {
99+ tokio::runtime::Builder::new_multi_thread()
100+ .enable_all()
101+ .thread_name("joltmoq")
102+ .build()
103+ .map_err(|e| log::error!("joltmoq: no runtime: {e}"))
104+ .ok()
105+ })
106+ .as_ref()
107+}
108+
93109 /// Run `f` against the one session, catching panics on the way out.
94110 ///
95111 /// `fallback` answers both a panic and a poisoned lock, and is deliberately
@@ -158,6 +174,10 @@ pub unsafe extern "C" fn joltmoq_start(
158174 let mic_id = preference(borrowed(mic_id));
159175 let speaker_id = preference(borrowed(speaker_id));
160176
177+ let Some(runtime) = runtime() else {
178+ return 0;
179+ };
180+
161181 with_slot(0, |slot| {
162182 if slot.session.is_some() {
163183 log::warn!("joltmoq: a call is already live; stop it first");
@@ -168,21 +188,6 @@ pub unsafe extern "C" fn joltmoq_start(
168188 return 0;
169189 };
170190
171- let runtime = match slot.runtime.take() {
172- Some(runtime) => runtime,
173- None => match tokio::runtime::Builder::new_multi_thread()
174- .enable_all()
175- .thread_name("joltmoq")
176- .build()
177- {
178- Ok(runtime) => runtime,
179- Err(e) => {
180- log::error!("joltmoq: no runtime: {e}");
181- return 0;
182- }
183- },
184- };
185-
186191 let config = AvMediaConfig {
187192 sfu_url: url,
188193 session_id,
@@ -220,7 +225,6 @@ pub unsafe extern "C" fn joltmoq_start(
220225
221226 slot.video = Some(session.video.clone());
222227 slot.session = Some(session);
223- slot.runtime = Some(runtime);
224228 slot.seen.clear();
225229 slot.frame = None;
226230 1
@@ -234,20 +238,40 @@ pub unsafe extern "C" fn joltmoq_start(
234238 /// it, so they see someone present and hear silence.
235239 #[no_mangle]
236240 pub extern "C" fn joltmoq_stop() {
237- with_slot((), |slot| {
238- let Some(mut session) = slot.session.take() else {
239- return;
240- };
241- match slot.runtime.as_ref() {
242- Some(runtime) => {
243- runtime.block_on(session.stop_and_wait(std::time::Duration::from_secs(2)))
244- }
245- None => session.stop(),
246- }
241+ // Take the session out from under the lock and let the lock go *before*
242+ // anything waits on it.
243+ //
244+ // Waiting while holding it deadlocks, and did: the status callback locks
245+ // this same slot from a tokio worker, so a task that still had an update
246+ // to deliver could not finish, while the thread that would have released
247+ // the lock was waiting for exactly that task to finish. The caller's UI
248+ // thread is the one that hangs, which is every thread it has.
249+ let session = with_slot(None, |slot| {
247250 slot.video = None;
248251 slot.seen.clear();
249252 slot.frame = None;
250- })
253+ slot.session.take()
254+ });
255+ let Some(mut session) = session else {
256+ return;
257+ };
258+
259+ // Not waited for either. Tearing MoQ down is a network round trip, and the
260+ // press that asked for it was on the thread that paints — half a second of
261+ // frozen window is not the answer to "leave". The teardown still runs, and
262+ // still unpublishes properly: an abandoned broadcast lingers on the SFU and
263+ // peers subscribe to it, seeing someone present who is silent.
264+ match runtime() {
265+ Some(runtime) => {
266+ runtime.spawn(async move {
267+ session
268+ .stop_and_wait(std::time::Duration::from_secs(2))
269+ .await;
270+ });
271+ }
272+ // No runtime means no call ever started; nothing to unwind gracefully.
273+ None => session.stop(),
274+ }
251275 }
252276
253277 /// 1 while a call is live on this side.
@@ -776,6 +800,36 @@ mod tests {
776800 assert_eq!(joltmoq_is_live(), 0);
777801 }
778802
803+ #[test]
804+ fn stopping_does_not_hold_the_lock_a_status_callback_needs() {
805+ // The deadlock this guards: `stop` used to wait for the media task
806+ // while holding the slot, and the task's status callback locks the
807+ // slot to deliver an update — so neither could finish. Here a thread
808+ // takes the slot the way that callback does, while the main thread
809+ // stops. If stop waits under the lock, this never returns.
810+ use std::sync::mpsc;
811+ use std::time::Duration;
812+
813+ let (tx, rx) = mpsc::channel();
814+ std::thread::spawn(move || {
815+ for _ in 0..200 {
816+ if let Ok(mut slot) = session_slot().lock() {
817+ slot.status.push(Status::Ended);
818+ slot.status.clear();
819+ }
820+ std::thread::sleep(Duration::from_millis(1));
821+ }
822+ let _ = tx.send(());
823+ });
824+
825+ for _ in 0..50 {
826+ joltmoq_stop();
827+ }
828+ assert_eq!(joltmoq_is_live(), 0);
829+ rx.recv_timeout(Duration::from_secs(10))
830+ .expect("the status thread never got the lock back");
831+ }
832+
779833 #[test]
780834 fn a_frame_poll_yields_each_feed_once_until_it_changes() {
781835 let video = VideoFrameStore::new();
@@ -72,10 +72,6 @@ struct Slot {
72 /// The frame most recently handed out. Held so that the pixel pointer the72 /// The frame most recently handed out. Held so that the pixel pointer the
73 /// caller was given stays alive until the next poll replaces it.73 /// caller was given stays alive until the next poll replaces it.
74 frame: Option<(String, RgbaVideoFrame)>,74 frame: Option<(String, RgbaVideoFrame)>,
75- /// A tokio runtime of our own: the caller has no reactor to lend us. Made
76- /// once and kept, since building one per call would tear worker threads
77- /// down and up again on every join.
78- runtime: Option<tokio::runtime::Runtime>,
79 }75 }
80 76
81 #[derive(Clone)]77 #[derive(Clone)]
@@ -90,6 +86,26 @@ fn session_slot() -> &'static Mutex<Slot> {
90 SLOT.get_or_init(|| Mutex::new(Slot::default()))86 SLOT.get_or_init(|| Mutex::new(Slot::default()))
91 }87 }
92 88
89+/// A tokio runtime of our own: the caller has no reactor to lend us.
90+///
91+/// Deliberately *not* in the slot. Tearing a call down needs the runtime and
92+/// must not be holding the slot lock while it does — see [`joltmoq_stop`].
93+/// Made once and kept, since building one per call would tear worker threads
94+/// down and up again on every join.
95+fn runtime() -> Option<&'static tokio::runtime::Runtime> {
96+ static RUNTIME: OnceLock<Option<tokio::runtime::Runtime>> = OnceLock::new();
97+ RUNTIME
98+ .get_or_init(|| {
99+ tokio::runtime::Builder::new_multi_thread()
100+ .enable_all()
101+ .thread_name("joltmoq")
102+ .build()
103+ .map_err(|e| log::error!("joltmoq: no runtime: {e}"))
104+ .ok()
105+ })
106+ .as_ref()
107+}
108+
93 /// Run `f` against the one session, catching panics on the way out.109 /// Run `f` against the one session, catching panics on the way out.
94 ///110 ///
95 /// `fallback` answers both a panic and a poisoned lock, and is deliberately111 /// `fallback` answers both a panic and a poisoned lock, and is deliberately
@@ -158,6 +174,10 @@ pub unsafe extern "C" fn joltmoq_start(
158 let mic_id = preference(borrowed(mic_id));174 let mic_id = preference(borrowed(mic_id));
159 let speaker_id = preference(borrowed(speaker_id));175 let speaker_id = preference(borrowed(speaker_id));
160 176
177+ let Some(runtime) = runtime() else {
178+ return 0;
179+ };
180+
161 with_slot(0, |slot| {181 with_slot(0, |slot| {
162 if slot.session.is_some() {182 if slot.session.is_some() {
163 log::warn!("joltmoq: a call is already live; stop it first");183 log::warn!("joltmoq: a call is already live; stop it first");
@@ -168,21 +188,6 @@ pub unsafe extern "C" fn joltmoq_start(
168 return 0;188 return 0;
169 };189 };
170 190
171- let runtime = match slot.runtime.take() {
172- Some(runtime) => runtime,
173- None => match tokio::runtime::Builder::new_multi_thread()
174- .enable_all()
175- .thread_name("joltmoq")
176- .build()
177- {
178- Ok(runtime) => runtime,
179- Err(e) => {
180- log::error!("joltmoq: no runtime: {e}");
181- return 0;
182- }
183- },
184- };
185-
186 let config = AvMediaConfig {191 let config = AvMediaConfig {
187 sfu_url: url,192 sfu_url: url,
188 session_id,193 session_id,
@@ -220,7 +225,6 @@ pub unsafe extern "C" fn joltmoq_start(
220 225
221 slot.video = Some(session.video.clone());226 slot.video = Some(session.video.clone());
222 slot.session = Some(session);227 slot.session = Some(session);
223- slot.runtime = Some(runtime);
224 slot.seen.clear();228 slot.seen.clear();
225 slot.frame = None;229 slot.frame = None;
226 1230 1
@@ -234,20 +238,40 @@ pub unsafe extern "C" fn joltmoq_start(
234 /// it, so they see someone present and hear silence.238 /// it, so they see someone present and hear silence.
235 #[no_mangle]239 #[no_mangle]
236 pub extern "C" fn joltmoq_stop() {240 pub extern "C" fn joltmoq_stop() {
237- with_slot((), |slot| {241+ // Take the session out from under the lock and let the lock go *before*
238- let Some(mut session) = slot.session.take() else {242+ // anything waits on it.
239- return;243+ //
240- };244+ // Waiting while holding it deadlocks, and did: the status callback locks
241- match slot.runtime.as_ref() {245+ // this same slot from a tokio worker, so a task that still had an update
242- Some(runtime) => {246+ // to deliver could not finish, while the thread that would have released
243- runtime.block_on(session.stop_and_wait(std::time::Duration::from_secs(2)))247+ // the lock was waiting for exactly that task to finish. The caller's UI
244- }248+ // thread is the one that hangs, which is every thread it has.
245- None => session.stop(),249+ let session = with_slot(None, |slot| {
246- }
247 slot.video = None;250 slot.video = None;
248 slot.seen.clear();251 slot.seen.clear();
249 slot.frame = None;252 slot.frame = None;
250- })253+ slot.session.take()
254+ });
255+ let Some(mut session) = session else {
256+ return;
257+ };
258+
259+ // Not waited for either. Tearing MoQ down is a network round trip, and the
260+ // press that asked for it was on the thread that paints — half a second of
261+ // frozen window is not the answer to "leave". The teardown still runs, and
262+ // still unpublishes properly: an abandoned broadcast lingers on the SFU and
263+ // peers subscribe to it, seeing someone present who is silent.
264+ match runtime() {
265+ Some(runtime) => {
266+ runtime.spawn(async move {
267+ session
268+ .stop_and_wait(std::time::Duration::from_secs(2))
269+ .await;
270+ });
271+ }
272+ // No runtime means no call ever started; nothing to unwind gracefully.
273+ None => session.stop(),
274+ }
251 }275 }
252 276
253 /// 1 while a call is live on this side.277 /// 1 while a call is live on this side.
@@ -776,6 +800,36 @@ mod tests {
776 assert_eq!(joltmoq_is_live(), 0);800 assert_eq!(joltmoq_is_live(), 0);
777 }801 }
778 802
803+ #[test]
804+ fn stopping_does_not_hold_the_lock_a_status_callback_needs() {
805+ // The deadlock this guards: `stop` used to wait for the media task
806+ // while holding the slot, and the task's status callback locks the
807+ // slot to deliver an update — so neither could finish. Here a thread
808+ // takes the slot the way that callback does, while the main thread
809+ // stops. If stop waits under the lock, this never returns.
810+ use std::sync::mpsc;
811+ use std::time::Duration;
812+
813+ let (tx, rx) = mpsc::channel();
814+ std::thread::spawn(move || {
815+ for _ in 0..200 {
816+ if let Ok(mut slot) = session_slot().lock() {
817+ slot.status.push(Status::Ended);
818+ slot.status.clear();
819+ }
820+ std::thread::sleep(Duration::from_millis(1));
821+ }
822+ let _ = tx.send(());
823+ });
824+
825+ for _ in 0..50 {
826+ joltmoq_stop();
827+ }
828+ assert_eq!(joltmoq_is_live(), 0);
829+ rx.recv_timeout(Duration::from_secs(10))
830+ .expect("the status thread never got the lock back");
831+ }
832+
779 #[test]833 #[test]
780 fn a_frame_poll_yields_each_feed_once_until_it_changes() {834 fn a_frame_poll_yields_each_feed_once_until_it_changes() {
781 let video = VideoFrameStore::new();835 let video = VideoFrameStore::new();
modified crates/jolt-vidya/include/vidya_tree.h +7 -0
@@ -57,6 +57,13 @@ extern "C" {
5757 * status label, live (bool)
5858 */
5959
60+/* An `:image` is bounded by `max-width` / `max-height` and, by default, never
61+ * drawn larger than its own pixels — enlarging a screenshot to fill a column
62+ * makes it worse. `upscale` says otherwise, for a picture whose size the
63+ * layout decided rather than the file: a video tile is a slot, and a camera
64+ * sending 480 wide into a 900-point slot should fill it.
65+ */
66+
6067 /* The window node, created on first use. Mount everything under it. */
6168 VIDYA_API int vidya_tree_root(void);
6269
@@ -57,6 +57,13 @@ extern "C" {
57 * status label, live (bool)57 * status label, live (bool)
58 */58 */
59 59
60+/* An `:image` is bounded by `max-width` / `max-height` and, by default, never
61+ * drawn larger than its own pixels — enlarging a screenshot to fill a column
62+ * makes it worse. `upscale` says otherwise, for a picture whose size the
63+ * layout decided rather than the file: a video tile is a slot, and a camera
64+ * sending 480 wide into a 900-point slot should fill it.
65+ */
66+
60 /* The window node, created on first use. Mount everything under it. */67 /* The window node, created on first use. Mount everything under it. */
61 VIDYA_API int vidya_tree_root(void);68 VIDYA_API int vidya_tree_root(void);
62 69
modified crates/jolt-vidya/src/tree.rs +17 -1
@@ -1299,7 +1299,23 @@ impl Tree {
12991299 } else {
13001300 ui.available_width()
13011301 };
1302- let scale = (avail / size.x).min(max_height / size.y).min(1.0);
1302+ // A picture in a message is never enlarged past its own
1303+ // pixels: blowing up a screenshot to fill a column makes it
1304+ // worse, and the reader can open it if they want it bigger.
1305+ //
1306+ // `:upscale` says this one is different. A video tile is a
1307+ // *slot* whose size the layout decided — how many people are
1308+ // in the call, how big the window is — and a camera sending
1309+ // 480 wide into a 900-point slot should fill it, the way every
1310+ // other video surface does. Left off, the picture would sit at
1311+ // its own size in the middle of a space reserved for it and
1312+ // the layout would look broken.
1313+ let scale = (avail / size.x).min(max_height / size.y);
1314+ let scale = if props.bool("upscale", false) {
1315+ scale
1316+ } else {
1317+ scale.min(1.0)
1318+ };
13031319 // Clickable whether or not the caller listens: the tree does
13041320 // not know which nodes have handlers, and an unheard event
13051321 // costs a queue slot.
@@ -1299,7 +1299,23 @@ impl Tree {
1299 } else {1299 } else {
1300 ui.available_width()1300 ui.available_width()
1301 };1301 };
1302- let scale = (avail / size.x).min(max_height / size.y).min(1.0);1302+ // A picture in a message is never enlarged past its own
1303+ // pixels: blowing up a screenshot to fill a column makes it
1304+ // worse, and the reader can open it if they want it bigger.
1305+ //
1306+ // `:upscale` says this one is different. A video tile is a
1307+ // *slot* whose size the layout decided — how many people are
1308+ // in the call, how big the window is — and a camera sending
1309+ // 480 wide into a 900-point slot should fill it, the way every
1310+ // other video surface does. Left off, the picture would sit at
1311+ // its own size in the middle of a space reserved for it and
1312+ // the layout would look broken.
1313+ let scale = (avail / size.x).min(max_height / size.y);
1314+ let scale = if props.bool("upscale", false) {
1315+ scale
1316+ } else {
1317+ scale.min(1.0)
1318+ };
1303 // Clickable whether or not the caller listens: the tree does1319 // Clickable whether or not the caller listens: the tree does
1304 // not know which nodes have handlers, and an unheard event1320 // not know which nodes have handlers, and an unheard event
1305 // costs a queue slot.1321 // costs a queue slot.