Zonza ne se lance plus qu'une fois a la fois
Deux bundles portant l'identifiant cloud.mrtechlab.zonza etaient enregistres aupres de LaunchServices : /Applications/Zonza.app et un fantome laisse dans la Corbeille le 13 juin, dont le stub visait l'ancien chemin du depot — valide jusqu'a aujourd'hui. Les deux etaient donc lancables simultanement. Chaque instance chargeait son propre modele MLX-Whisper medium et captait la MEME hotkey globale : un seul appui declenchait deux transcriptions, la machine saturait. Le fantome est desenregistre, mais la classe de bug revient au moindre bundle egare. D'ou un verrou exclusif (flock non bloquant) pris au demarrage : la seconde instance renonce et sort. Le noyau libere le verrou meme apres un plantage, donc pas d'orphelin. Prouve avec le vrai binaire : 2e lancement direct -> "[garde] une autre instance tourne deja", 1 seul process ; et controle negatif, seule elle demarre bien. 27 tests passent (22 + 5 nouveaux). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8a9a6de98c
commit
3ad274a774
9
app.py
9
app.py
@ -28,7 +28,7 @@ from PyObjCTools import AppHelper
|
|||||||
from pynput import keyboard
|
from pynput import keyboard
|
||||||
|
|
||||||
from controller import ZonzaController
|
from controller import ZonzaController
|
||||||
from core import CONFIG, validate_config
|
from core import CONFIG, acquire_single_instance_lock, validate_config
|
||||||
from pulse_window import PulseWindow
|
from pulse_window import PulseWindow
|
||||||
|
|
||||||
_STATE_TITLES = {
|
_STATE_TITLES = {
|
||||||
@ -127,6 +127,13 @@ class ZonzaApp(NSObject):
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
# une seule instance a la fois : sinon deux modeles en RAM et la meme
|
||||||
|
# hotkey captee deux fois (cf acquire_single_instance_lock).
|
||||||
|
lock = acquire_single_instance_lock()
|
||||||
|
if lock is None:
|
||||||
|
print("[garde] une autre instance de Zonza tourne deja — arret.")
|
||||||
|
return
|
||||||
|
main._lock = lock # garder le verrou vivant tant que le process vit
|
||||||
validate_config(CONFIG)
|
validate_config(CONFIG)
|
||||||
# si l'Accessibilité n'est pas accordée, macOS affiche sa popup officielle
|
# si l'Accessibilité n'est pas accordée, macOS affiche sa popup officielle
|
||||||
# (« Zonza souhaite contrôler cet ordinateur ») et ajoute l'app à la liste —
|
# (« Zonza souhaite contrôler cet ordinateur ») et ajoute l'app à la liste —
|
||||||
|
|||||||
31
core.py
31
core.py
@ -3,6 +3,8 @@
|
|||||||
Tout ce qui est ici est testable sans micro, sans clavier, sans modèle.
|
Tout ce qui est ici est testable sans micro, sans clavier, sans modèle.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import fcntl
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
|
||||||
CONFIG = {
|
CONFIG = {
|
||||||
@ -44,3 +46,32 @@ def is_long_enough(num_samples, sample_rate, min_duration_s):
|
|||||||
def clean_transcript(text):
|
def clean_transcript(text):
|
||||||
"""Nettoie le texte de Whisper : trim + espaces/retours multiples écrasés."""
|
"""Nettoie le texte de Whisper : trim + espaces/retours multiples écrasés."""
|
||||||
return re.sub(r"\s+", " ", text).strip()
|
return re.sub(r"\s+", " ", text).strip()
|
||||||
|
|
||||||
|
|
||||||
|
LOCK_PATH = os.path.expanduser("~/Library/Application Support/Zonza/instance.lock")
|
||||||
|
|
||||||
|
|
||||||
|
def acquire_single_instance_lock(path=LOCK_PATH):
|
||||||
|
"""Prend un verrou exclusif non bloquant ; renvoie le fichier, ou None si une
|
||||||
|
autre instance le detient deja.
|
||||||
|
|
||||||
|
Pourquoi : deux bundles Zonza de meme identifiant (dont un fantome reste dans
|
||||||
|
la Corbeille mais toujours enregistre aupres de LaunchServices) pouvaient
|
||||||
|
tourner en meme temps. Chacun chargeait son propre modele MLX-Whisper et
|
||||||
|
captait la MEME hotkey globale : un appui, deux transcriptions, machine
|
||||||
|
saturee. Purger le fantome corrige l'incident ; ce verrou corrige la classe.
|
||||||
|
|
||||||
|
Le handle doit rester vivant tant que l'app tourne : le verrou tombe a la
|
||||||
|
fermeture du fichier, et le noyau le libere si le processus meurt brutalement
|
||||||
|
(donc pas de verrou orphelin apres un plantage).
|
||||||
|
"""
|
||||||
|
parent = os.path.dirname(path)
|
||||||
|
if parent:
|
||||||
|
os.makedirs(parent, exist_ok=True)
|
||||||
|
handle = open(path, "a")
|
||||||
|
try:
|
||||||
|
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except OSError:
|
||||||
|
handle.close()
|
||||||
|
return None
|
||||||
|
return handle
|
||||||
|
|||||||
51
tests/test_single_instance.py
Normal file
51
tests/test_single_instance.py
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
"""Garde d'instance unique.
|
||||||
|
|
||||||
|
Contexte (bug du 2026-08-21) : deux bundles Zonza portant le MÊME identifiant
|
||||||
|
(`/Applications/Zonza.app` et un fantôme resté dans la Corbeille, encore
|
||||||
|
enregistré auprès de LaunchServices) pouvaient tourner simultanément. Chaque
|
||||||
|
instance chargeait son propre modèle MLX-Whisper `medium` et captait la même
|
||||||
|
hotkey globale : un seul appui déclenchait deux transcriptions, la machine
|
||||||
|
saturait.
|
||||||
|
|
||||||
|
Purger le fantôme corrige l'incident du jour ; cette garde corrige la CLASSE de
|
||||||
|
bug — quel que soit le nombre de bundles présents, la 2e instance renonce.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from core import acquire_single_instance_lock
|
||||||
|
|
||||||
|
|
||||||
|
def _tmp_lock():
|
||||||
|
return os.path.join(tempfile.mkdtemp(), "sous-dossier", "zonza.lock")
|
||||||
|
|
||||||
|
|
||||||
|
def test_premiere_instance_obtient_le_verrou():
|
||||||
|
handle = acquire_single_instance_lock(_tmp_lock())
|
||||||
|
assert handle is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_deuxieme_instance_est_refusee():
|
||||||
|
path = _tmp_lock()
|
||||||
|
premier = acquire_single_instance_lock(path)
|
||||||
|
assert premier is not None
|
||||||
|
assert acquire_single_instance_lock(path) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_verrou_repris_apres_liberation():
|
||||||
|
path = _tmp_lock()
|
||||||
|
premier = acquire_single_instance_lock(path)
|
||||||
|
premier.close()
|
||||||
|
assert acquire_single_instance_lock(path) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_cree_le_dossier_parent_manquant():
|
||||||
|
path = _tmp_lock()
|
||||||
|
assert not os.path.isdir(os.path.dirname(path))
|
||||||
|
acquire_single_instance_lock(path)
|
||||||
|
assert os.path.isdir(os.path.dirname(path))
|
||||||
|
|
||||||
|
|
||||||
|
def test_deux_chemins_differents_ne_se_genent_pas():
|
||||||
|
assert acquire_single_instance_lock(_tmp_lock()) is not None
|
||||||
|
assert acquire_single_instance_lock(_tmp_lock()) is not None
|
||||||
Loading…
Reference in New Issue
Block a user