fix: prevent multiple instances from running simultaneously (#245)

* fix: prevent multiple instances from running simultaneously

Add single-instance enforcement using file locking to prevent duplicate
instances when auto-start is enabled on boot (issue #244).

Changes:
- Add single_instance.py module with fcntl-based file locking
- Check for existing instance early in main() before any initialization
- Register atexit handler to release lock on normal exit
- Lock file located at ~/.local/share/vocalinux/instance.lock

If another instance is already running, the app exits gracefully with
a helpful error message telling the user how to resolve the situation.

* fix: add GTK notification when duplicate instance detected

Show a system notification to the user when another instance is already
running, in addition to the existing stderr/log output.

Changes:
- Import Notify from gi.repository when duplicate instance detected
- Show notification with error icon and message
- Give notification time to display before exiting (0.5s sleep)
- Fallback to silent exit if notification fails (e.g., headless)
This commit is contained in:
Jatin K Malik
2026-02-21 15:35:52 -08:00
committed by GitHub
parent 9886e8cb9d
commit b0565b36ad
2 changed files with 126 additions and 0 deletions
+28
View File
@@ -4,6 +4,7 @@ Main entry point for Vocalinux application.
"""
import argparse
import atexit
import logging
import sys
@@ -136,6 +137,33 @@ def check_dependencies():
def main():
"""Main entry point for the application."""
# Check for single instance BEFORE any initialization
from . import single_instance
if not single_instance.acquire_lock():
# Another instance is already running - show notification and exit
try:
import time
from gi.repository import Notify
Notify.init("Vocalinux")
notification = Notify.Notification.new(
"Vocalinux",
"Another instance is already running. Only one instance is allowed at a time.",
"dialog-error",
)
notification.show()
# Give notification time to display before exiting
time.sleep(0.5)
except Exception:
# Fallback if notification fails (e.g., no display)
pass
sys.exit(1)
# Register cleanup to release lock on exit
atexit.register(single_instance.release_lock)
args = parse_arguments()
# Configure debug logging if requested
+98
View File
@@ -0,0 +1,98 @@
"""
Single instance enforcement for Vocalinux.
Uses file locking to ensure only one instance runs at a time.
Works across sessions and handles stale locks from crashed processes.
"""
import fcntl
import logging
import os
import sys
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
# Lock file location: ~/.local/share/vocalinux/
LOCK_FILE_DIR = Path.home() / ".local" / "share" / "vocalinux"
LOCK_FILE_PATH = LOCK_FILE_DIR / "instance.lock"
# Global lock file handle
_lock_file: Optional[int] = None
def _get_lock_file_fd() -> int:
"""Get or create the lock file descriptor."""
global _lock_file
if _lock_file is not None:
return _lock_file
# Ensure directory exists
LOCK_FILE_DIR.mkdir(parents=True, exist_ok=True)
# Open lock file (create if doesn't exist)
# O_RDWR: read/write, O_CREAT: create if not exists
fd = os.open(str(LOCK_FILE_PATH), os.O_RDWR | os.O_CREAT, 0o644)
_lock_file = fd
return fd
def acquire_lock() -> bool:
"""
Acquire the single instance lock.
Returns:
True if lock acquired (first instance), False if another instance is running.
If another instance is running, logs a message and returns False.
"""
try:
fd = _get_lock_file_fd()
# Try to acquire exclusive lock (non-blocking)
# LOCK_EX: exclusive lock
# LOCK_NB: non-blocking (fail immediately if lock held by another process)
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except (OSError, IOError):
# Lock is held by another process
logger.warning("Another instance of Vocalinux is already running. Exiting.")
print("Another instance of Vocalinux is already running.", file=sys.stderr)
print(
f"If you're sure no other instance is running, delete: {LOCK_FILE_PATH}",
file=sys.stderr,
)
return False
# Lock acquired - write our PID for debugging purposes
os.ftruncate(fd, 0)
os.write(fd, f"{os.getpid()}\n".encode())
os.lseek(fd, 0, os.SEEK_SET)
logger.info("Single instance lock acquired")
return True
except Exception as e:
# Unexpected error - log and allow startup (fail open but warn)
logger.error(f"Error acquiring single instance lock: {e}")
return True
def release_lock():
"""Release the single instance lock."""
global _lock_file
if _lock_file is not None:
try:
# Release lock
fcntl.flock(_lock_file, fcntl.LOCK_UN)
os.close(_lock_file)
_lock_file = None
logger.debug("Single instance lock released")
except (OSError, IOError) as e:
logger.warning(f"Failed to release lock: {e}")
# Ensure clean state
_lock_file = None