Skip to content

Commit b8ded44

Browse files
oxoxDevclaude
andauthored
fix(core): send SIGTERM before SIGKILL on sidecar shutdown (tinyhumansai#460) (tinyhumansai#495)
The Tauri shell's CoreProcessHandle::shutdown() was calling child.kill() which sends SIGKILL on Unix, instantly terminating the core process without giving it a chance to run graceful shutdown hooks. This left the autocomplete Swift overlay helper (unified_helper_bin) orphaned, causing persistent error notifications even after the app was closed. Now sends SIGTERM first and waits up to 5s for the core to exit gracefully (running shutdown hooks that stop the autocomplete engine and quit the Swift helper), then falls back to SIGKILL if still alive. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e60b9f8 commit b8ded44

3 files changed

Lines changed: 88 additions & 7 deletions

File tree

app/src-tauri/Cargo.lock

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/src-tauri/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ semver = "1"
4040
log = "0.4"
4141
env_logger = "0.11"
4242

43+
[target.'cfg(unix)'.dependencies]
44+
nix = { version = "0.29", default-features = false, features = ["signal"] }
45+
4346
[target.'cfg(target_os = "macos")'.dependencies]
4447
objc2-app-kit = "0.3.2"
4548
objc2-core-graphics = "0.3.2"

app/src-tauri/src/core_process.rs

Lines changed: 72 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -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

347412
fn is_current_exe_path(candidate: &std::path::Path) -> bool {

0 commit comments

Comments
 (0)