@@ -314,26 +314,35 @@ impl CoreProcessHandle {
314314
315315 /// Stop the core process this handle spawned (child or in-process task). Safe to call if
316316 /// nothing was spawned or core was already external.
317+ ///
318+ /// On Unix, sends SIGTERM first so the core process can run its graceful
319+ /// shutdown hooks (e.g. stopping the autocomplete engine and its Swift
320+ /// overlay helper). Falls back to SIGKILL after a timeout.
317321 pub async fn shutdown ( & self ) {
318322 let mut child_guard = self . child . lock ( ) . await ;
319323 if let Some ( mut child) = child_guard. take ( ) {
320324 log:: info!( "[core] terminating child core process on app shutdown" ) ;
321- if let Err ( e) = child. kill ( ) . await {
322- log:: warn!( "[core] failed to kill child core process: {e}" ) ;
325+
326+ let exited = self . try_graceful_terminate ( & child) . await ;
327+
328+ if !exited {
329+ log:: info!( "[core] graceful shutdown timed out, sending SIGKILL" ) ;
330+ if let Err ( e) = child. kill ( ) . await {
331+ log:: warn!( "[core] failed to kill child core process: {e}" ) ;
332+ }
323333 }
334+
324335 // Wait for the process to exit so the RPC listen socket is released before restart
325336 // checks the port (otherwise we can spuriously hit "port still in use").
326337 match timeout ( Duration :: from_secs ( 12 ) , child. wait ( ) ) . await {
327338 Ok ( Ok ( status) ) => {
328- log:: debug!( "[core] child core process reaped after kill : {status}" ) ;
339+ log:: debug!( "[core] child core process reaped after shutdown : {status}" ) ;
329340 }
330341 Ok ( Err ( e) ) => {
331- log:: warn!( "[core] wait on child core process after kill : {e}" ) ;
342+ log:: warn!( "[core] wait on child core process after shutdown : {e}" ) ;
332343 }
333344 Err ( _) => {
334- log:: warn!(
335- "[core] timed out waiting for child core process to exit after kill (12s)"
336- ) ;
345+ log:: warn!( "[core] timed out waiting for child core process to exit (12s)" ) ;
337346 }
338347 }
339348 }
@@ -342,6 +351,62 @@ impl CoreProcessHandle {
342351 task. abort ( ) ;
343352 }
344353 }
354+
355+ /// Send SIGTERM to the child and wait up to 5 seconds for it to exit.
356+ /// Returns `true` if the process exited gracefully, `false` if it's still
357+ /// alive (caller should escalate to SIGKILL).
358+ async fn try_graceful_terminate ( & self , child : & Child ) -> bool {
359+ #[ cfg( unix) ]
360+ {
361+ use nix:: sys:: signal:: { self , Signal } ;
362+ use nix:: unistd:: Pid ;
363+
364+ let Some ( pid) = child. id ( ) else {
365+ log:: debug!( "[core] child has no PID (already exited?)" ) ;
366+ return true ;
367+ } ;
368+
369+ log:: info!( "[core] sending SIGTERM to core process (pid={pid})" ) ;
370+ if let Err ( e) = signal:: kill ( Pid :: from_raw ( pid as i32 ) , Signal :: SIGTERM ) {
371+ log:: warn!( "[core] failed to send SIGTERM: {e}" ) ;
372+ return false ;
373+ }
374+
375+ // Poll for exit for up to 5 seconds.
376+ const GRACE_PERIOD : Duration = Duration :: from_secs ( 5 ) ;
377+ const POLL_INTERVAL : Duration = Duration :: from_millis ( 100 ) ;
378+ let start = tokio:: time:: Instant :: now ( ) ;
379+
380+ while start. elapsed ( ) < GRACE_PERIOD {
381+ // Check if process is still alive (signal 0 = existence check).
382+ match signal:: kill ( Pid :: from_raw ( pid as i32 ) , None ) {
383+ Err ( nix:: errno:: Errno :: ESRCH ) => {
384+ log:: info!(
385+ "[core] core process exited gracefully after SIGTERM ({}ms)" ,
386+ start. elapsed( ) . as_millis( )
387+ ) ;
388+ return true ;
389+ }
390+ _ => { }
391+ }
392+ tokio:: time:: sleep ( POLL_INTERVAL ) . await ;
393+ }
394+
395+ log:: warn!(
396+ "[core] core process still alive after {}s grace period" ,
397+ GRACE_PERIOD . as_secs( )
398+ ) ;
399+ false
400+ }
401+
402+ #[ cfg( not( unix) ) ]
403+ {
404+ // On non-Unix platforms, there is no SIGTERM equivalent; the caller
405+ // will use `child.kill()` directly.
406+ let _ = child;
407+ false
408+ }
409+ }
345410}
346411
347412fn is_current_exe_path ( candidate : & std:: path:: Path ) -> bool {
0 commit comments