@@ -269,3 +269,130 @@ async fn handle_set_ollama_path_accepts_empty_string_to_clear() {
269269 std:: env:: remove_var ( "OPENHUMAN_WORKSPACE" ) ;
270270 }
271271}
272+
273+ /// Regression test for the CodeRabbit #7 race on PR #1755: when two
274+ /// concurrent RPC calls (e.g. a double-click, or the auto-install firing
275+ /// alongside a manual click) hit `handle_local_ai_install_whisper` at
276+ /// the same time, only one of them must spawn a real install task. The
277+ /// other must short-circuit and return the in-flight status without
278+ /// starting a second download that would race on the same `.part` file.
279+ ///
280+ /// We exercise the actual handler — not just the slot primitive — so
281+ /// the wiring at the call site is also covered.
282+ #[ tokio:: test]
283+ async fn install_whisper_handler_serializes_concurrent_calls ( ) {
284+ let _g = ENV_LOCK . lock ( ) . unwrap_or_else ( |e| e. into_inner ( ) ) ;
285+ let tmp = TempDir :: new ( ) . unwrap ( ) ;
286+ unsafe {
287+ std:: env:: set_var ( "OPENHUMAN_WORKSPACE" , tmp. path ( ) ) ;
288+ }
289+
290+ // Pre-acquire the install slot from the test so we're guaranteed to
291+ // observe the "already in flight" code path. Holding the slot here
292+ // also means the handler under test will short-circuit immediately
293+ // rather than spawning a real install task that would try to hit
294+ // the network in CI.
295+ let slot = crate :: openhuman:: local_ai:: voice_install_common:: try_acquire_install_slot (
296+ crate :: openhuman:: local_ai:: voice_install_common:: ENGINE_WHISPER ,
297+ )
298+ . expect ( "test should be able to claim the slot first" ) ;
299+
300+ // Mark the status table as `Installing` so the handler's
301+ // short-circuit branch (which reads current status to return) sees
302+ // a coherent snapshot.
303+ crate :: openhuman:: local_ai:: voice_install_common:: write_status (
304+ crate :: openhuman:: local_ai:: voice_install_common:: VoiceInstallStatus {
305+ engine : crate :: openhuman:: local_ai:: voice_install_common:: ENGINE_WHISPER . to_string ( ) ,
306+ state : crate :: openhuman:: local_ai:: voice_install_common:: VoiceInstallState :: Installing ,
307+ progress : Some ( 0 ) ,
308+ downloaded_bytes : None ,
309+ total_bytes : None ,
310+ stage : Some ( "queued" . to_string ( ) ) ,
311+ error_detail : None ,
312+ } ,
313+ ) ;
314+
315+ // Fire two handler calls in parallel. Both must succeed and both
316+ // must return the existing `Installing` status — neither must
317+ // mutate or re-spawn. This is exactly the double-click / auto-fire
318+ // shape described in CodeRabbit #7.
319+ let ( r1, r2) = tokio:: join!(
320+ handle_local_ai_install_whisper( Map :: new( ) ) ,
321+ handle_local_ai_install_whisper( Map :: new( ) )
322+ ) ;
323+
324+ unsafe {
325+ std:: env:: remove_var ( "OPENHUMAN_WORKSPACE" ) ;
326+ }
327+ drop ( slot) ;
328+ // Clean up so other tests see Missing.
329+ crate :: openhuman:: local_ai:: voice_install_common:: reset_status (
330+ crate :: openhuman:: local_ai:: voice_install_common:: ENGINE_WHISPER ,
331+ ) ;
332+
333+ let v1 = r1. expect ( "first call ok" ) ;
334+ let v2 = r2. expect ( "second call ok" ) ;
335+ // Both calls must report the engine is already installing — proving
336+ // the handler short-circuited rather than running the spawn path.
337+ for ( label, v) in [ ( "first" , & v1) , ( "second" , & v2) ] {
338+ let state = v. get ( "state" ) . and_then ( |s| s. as_str ( ) ) ;
339+ assert_eq ! (
340+ state,
341+ Some ( "installing" ) ,
342+ "{label} concurrent call should see Installing, got {v:?}"
343+ ) ;
344+ }
345+ }
346+
347+ /// Same regression for Piper. The two handlers share the slot
348+ /// infrastructure but live in separate code paths, so the wiring needs
349+ /// independent coverage.
350+ #[ tokio:: test]
351+ async fn install_piper_handler_serializes_concurrent_calls ( ) {
352+ let _g = ENV_LOCK . lock ( ) . unwrap_or_else ( |e| e. into_inner ( ) ) ;
353+ let tmp = TempDir :: new ( ) . unwrap ( ) ;
354+ unsafe {
355+ std:: env:: set_var ( "OPENHUMAN_WORKSPACE" , tmp. path ( ) ) ;
356+ }
357+
358+ let slot = crate :: openhuman:: local_ai:: voice_install_common:: try_acquire_install_slot (
359+ crate :: openhuman:: local_ai:: voice_install_common:: ENGINE_PIPER ,
360+ )
361+ . expect ( "test should be able to claim the slot first" ) ;
362+
363+ crate :: openhuman:: local_ai:: voice_install_common:: write_status (
364+ crate :: openhuman:: local_ai:: voice_install_common:: VoiceInstallStatus {
365+ engine : crate :: openhuman:: local_ai:: voice_install_common:: ENGINE_PIPER . to_string ( ) ,
366+ state : crate :: openhuman:: local_ai:: voice_install_common:: VoiceInstallState :: Installing ,
367+ progress : Some ( 0 ) ,
368+ downloaded_bytes : None ,
369+ total_bytes : None ,
370+ stage : Some ( "queued" . to_string ( ) ) ,
371+ error_detail : None ,
372+ } ,
373+ ) ;
374+
375+ let ( r1, r2) = tokio:: join!(
376+ handle_local_ai_install_piper( Map :: new( ) ) ,
377+ handle_local_ai_install_piper( Map :: new( ) )
378+ ) ;
379+
380+ unsafe {
381+ std:: env:: remove_var ( "OPENHUMAN_WORKSPACE" ) ;
382+ }
383+ drop ( slot) ;
384+ crate :: openhuman:: local_ai:: voice_install_common:: reset_status (
385+ crate :: openhuman:: local_ai:: voice_install_common:: ENGINE_PIPER ,
386+ ) ;
387+
388+ let v1 = r1. expect ( "first call ok" ) ;
389+ let v2 = r2. expect ( "second call ok" ) ;
390+ for ( label, v) in [ ( "first" , & v1) , ( "second" , & v2) ] {
391+ let state = v. get ( "state" ) . and_then ( |s| s. as_str ( ) ) ;
392+ assert_eq ! (
393+ state,
394+ Some ( "installing" ) ,
395+ "{label} concurrent call should see Installing, got {v:?}"
396+ ) ;
397+ }
398+ }
0 commit comments