python-voiceio 0.9.1__tar.gz → 0.9.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_voiceio-0.9.1/python_voiceio.egg-info → python_voiceio-0.9.2}/PKG-INFO +1 -1
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/pyproject.toml +1 -1
- {python_voiceio-0.9.1 → python_voiceio-0.9.2/python_voiceio.egg-info}/PKG-INFO +1 -1
- python_voiceio-0.9.2/tests/test_ibus_ping.py +113 -0
- python_voiceio-0.9.2/voiceio/__init__.py +1 -0
- python_voiceio-0.9.2/voiceio/ibus/__init__.py +64 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/ibus/engine.py +20 -8
- python_voiceio-0.9.1/tests/test_ibus_ping.py +0 -56
- python_voiceio-0.9.1/voiceio/__init__.py +0 -1
- python_voiceio-0.9.1/voiceio/ibus/__init__.py +0 -33
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/LICENSE +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/README.md +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/python_voiceio.egg-info/SOURCES.txt +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/python_voiceio.egg-info/dependency_links.txt +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/python_voiceio.egg-info/entry_points.txt +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/python_voiceio.egg-info/requires.txt +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/python_voiceio.egg-info/top_level.txt +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/setup.cfg +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/tests/test_adjudicate.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/tests/test_app_wiring.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/tests/test_audio_quality.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/tests/test_audit.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/tests/test_autocorrect.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/tests/test_autocorrect_state.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/tests/test_backend_probes.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/tests/test_cli.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/tests/test_clipboard_read.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/tests/test_commands.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/tests/test_concurrency_lockdown.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/tests/test_config.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/tests/test_correct_batch.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/tests/test_corrections.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/tests/test_evaluate.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/tests/test_fallback.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/tests/test_health.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/tests/test_hints.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/tests/test_history.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/tests/test_ibus_pending.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/tests/test_ibus_typer.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/tests/test_llm.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/tests/test_llm_api.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/tests/test_numbers.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/tests/test_platform.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/tests/test_postcorrect.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/tests/test_postprocess.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/tests/test_prebuffer.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/tests/test_prompt.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/tests/test_recorder_integration.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/tests/test_retention.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/tests/test_robustness.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/tests/test_security_hardening.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/tests/test_snapshots.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/tests/test_streaming.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/tests/test_tokens.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/tests/test_transcriber.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/tests/test_tts.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/tests/test_vad.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/tests/test_vocabulary.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/tests/test_wizard.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/tests/test_wordfreq.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/__main__.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/app.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/audit.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/autocorrect.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/autocorrect_state.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/backends.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/cli.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/clipboard_read.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/commands.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/config.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/consent.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/corrections.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/demo.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/evaluate.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/feedback.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/health.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/hints.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/history.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/hotkeys/__init__.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/hotkeys/base.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/hotkeys/chain.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/hotkeys/evdev.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/hotkeys/pynput_backend.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/hotkeys/socket_backend.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/ibus/pending.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/llm.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/llm_api.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/models/__init__.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/models/silero_vad.onnx +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/numbers.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/pidlock.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/platform.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/postcorrect.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/postprocess.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/prompt.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/recorder.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/retention.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/service.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/snapshots.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/sounds/__init__.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/sounds/commit.wav +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/sounds/start.wav +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/sounds/stop.wav +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/streaming.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/tokens.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/transcriber.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/tray/__init__.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/tray/_icons.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/tray/_indicator.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/tray/_pystray.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/tts/__init__.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/tts/base.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/tts/chain.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/tts/edge_engine.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/tts/espeak.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/tts/piper_engine.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/tts/player.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/typers/__init__.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/typers/base.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/typers/chain.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/typers/clipboard.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/typers/ibus.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/typers/pynput_type.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/typers/wtype.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/typers/xdotool.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/typers/ydotool.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/vad.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/vocab_stats.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/vocabulary.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/wizard.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/wordfreq.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.2}/voiceio/worker.py +0 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Tests for the shared engine ping used by the daemon health watchdog and
|
|
2
|
+
the engine's own refuse-to-double-run guard (a duplicate engine binding the
|
|
3
|
+
socket path silently starves the healthy instance)."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import socket
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
import voiceio.ibus as ibus_mod
|
|
14
|
+
|
|
15
|
+
# IBus (and AF_UNIX DGRAM + Linux abstract autobind) is Linux-only.
|
|
16
|
+
pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="IBus is Linux-only")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_no_socket_returns_false(tmp_path, monkeypatch):
|
|
20
|
+
monkeypatch.setattr(ibus_mod, "SOCKET_PATH", tmp_path / "nope.sock")
|
|
21
|
+
assert ibus_mod.ping_engine(timeout=0.2) is False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_healthy_peer_answers_pong(tmp_path, monkeypatch):
|
|
25
|
+
sock_path = tmp_path / "engine.sock"
|
|
26
|
+
server = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
|
27
|
+
server.bind(str(sock_path))
|
|
28
|
+
server.settimeout(2.0)
|
|
29
|
+
|
|
30
|
+
def _serve():
|
|
31
|
+
try:
|
|
32
|
+
data, addr = server.recvfrom(64)
|
|
33
|
+
if data == b"ping" and addr:
|
|
34
|
+
server.sendto(b"pong", addr)
|
|
35
|
+
except OSError:
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
t = threading.Thread(target=_serve, daemon=True)
|
|
39
|
+
t.start()
|
|
40
|
+
monkeypatch.setattr(ibus_mod, "SOCKET_PATH", sock_path)
|
|
41
|
+
try:
|
|
42
|
+
assert ibus_mod.ping_engine(timeout=1.0) is True
|
|
43
|
+
finally:
|
|
44
|
+
t.join(timeout=2.0)
|
|
45
|
+
server.close()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_unresponsive_socket_returns_false(tmp_path, monkeypatch):
|
|
49
|
+
"""A bound-but-silent socket (starved listener) must read as dead."""
|
|
50
|
+
sock_path = tmp_path / "engine.sock"
|
|
51
|
+
server = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
|
52
|
+
server.bind(str(sock_path))
|
|
53
|
+
monkeypatch.setattr(ibus_mod, "SOCKET_PATH", sock_path)
|
|
54
|
+
try:
|
|
55
|
+
assert ibus_mod.ping_engine(timeout=0.2) is False
|
|
56
|
+
finally:
|
|
57
|
+
server.close()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class TestSingletonLock:
|
|
61
|
+
"""The atomic gate that stops a duplicate engine from stealing the socket.
|
|
62
|
+
|
|
63
|
+
ibus-daemon exec-spawns its own engine copy when the voiceio source is
|
|
64
|
+
activated; only the flock holder may bind the command socket, so the loser
|
|
65
|
+
exits instead of unlink+rebind-ing it into a zombie.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def test_second_acquire_fails_while_held(self, tmp_path, monkeypatch):
|
|
69
|
+
monkeypatch.setattr(ibus_mod, "LOCK_PATH", tmp_path / "e.lock")
|
|
70
|
+
fd = ibus_mod.acquire_singleton_lock()
|
|
71
|
+
assert fd is not None
|
|
72
|
+
try:
|
|
73
|
+
# A concurrent engine (same process here, standing in for a second
|
|
74
|
+
# process on the same file) must be refused.
|
|
75
|
+
assert ibus_mod.acquire_singleton_lock() is None
|
|
76
|
+
finally:
|
|
77
|
+
os.close(fd)
|
|
78
|
+
|
|
79
|
+
def test_reacquire_succeeds_after_holder_closes(self, tmp_path, monkeypatch):
|
|
80
|
+
"""Releasing the fd (process death) frees the lock for the next engine."""
|
|
81
|
+
monkeypatch.setattr(ibus_mod, "LOCK_PATH", tmp_path / "e.lock")
|
|
82
|
+
fd = ibus_mod.acquire_singleton_lock()
|
|
83
|
+
assert fd is not None
|
|
84
|
+
os.close(fd) # simulate the holder dying
|
|
85
|
+
fd2 = ibus_mod.acquire_singleton_lock()
|
|
86
|
+
assert fd2 is not None
|
|
87
|
+
os.close(fd2)
|
|
88
|
+
|
|
89
|
+
def test_lock_is_cross_process(self, tmp_path, monkeypatch):
|
|
90
|
+
"""flock must exclude a genuinely separate process, not just this one."""
|
|
91
|
+
import subprocess
|
|
92
|
+
import textwrap
|
|
93
|
+
|
|
94
|
+
lock_path = tmp_path / "e.lock"
|
|
95
|
+
monkeypatch.setattr(ibus_mod, "LOCK_PATH", lock_path)
|
|
96
|
+
fd = ibus_mod.acquire_singleton_lock()
|
|
97
|
+
assert fd is not None
|
|
98
|
+
try:
|
|
99
|
+
# A child process using the same flock path must be refused while we
|
|
100
|
+
# hold it. Exit code 7 = "got the lock" (bad), 0 = "refused" (good).
|
|
101
|
+
code = textwrap.dedent(f"""
|
|
102
|
+
import fcntl, os, sys
|
|
103
|
+
fd = os.open({str(lock_path)!r}, os.O_CREAT | os.O_RDWR, 0o600)
|
|
104
|
+
try:
|
|
105
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
106
|
+
sys.exit(7)
|
|
107
|
+
except OSError:
|
|
108
|
+
sys.exit(0)
|
|
109
|
+
""")
|
|
110
|
+
r = subprocess.run([sys.executable, "-c", code], timeout=5)
|
|
111
|
+
assert r.returncode == 0
|
|
112
|
+
finally:
|
|
113
|
+
os.close(fd)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.9.2"
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""IBus input method engine for VoiceIO."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
SOCKET_PATH = Path(os.environ.get("XDG_RUNTIME_DIR", "/tmp")) / "voiceio-ibus.sock"
|
|
8
|
+
READY_PATH = Path(os.environ.get("XDG_RUNTIME_DIR", "/tmp")) / "voiceio-ibus.ready"
|
|
9
|
+
# Held for the engine's lifetime so exactly one process ever owns the command
|
|
10
|
+
# socket — see acquire_singleton_lock().
|
|
11
|
+
LOCK_PATH = Path(os.environ.get("XDG_RUNTIME_DIR", "/tmp")) / "voiceio-ibus.lock"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def acquire_singleton_lock() -> int | None:
|
|
15
|
+
"""Take an exclusive, lifetime-held lock on LOCK_PATH.
|
|
16
|
+
|
|
17
|
+
Returns the open fd on success (the caller must keep it open — the lock is
|
|
18
|
+
released when the fd closes, i.e. when the process dies), or None if another
|
|
19
|
+
engine already holds it.
|
|
20
|
+
|
|
21
|
+
This is the race-free gate against duplicate engines. ibus-daemon
|
|
22
|
+
exec-spawns its own copy of the engine when something activates the voiceio
|
|
23
|
+
source (GNOME's per-window source memory / Super+Space — voiceio sits first
|
|
24
|
+
in mru-sources). A ping-then-bind check is check-then-act: two engines
|
|
25
|
+
starting close together both see "no healthy peer", then both unlink+rebind
|
|
26
|
+
the DGRAM socket in _socket_listener. The loser is left bound to an orphaned
|
|
27
|
+
inode — alive but never receiving pings ("zombie") — which the daemon's
|
|
28
|
+
health watchdog then kills and respawns, churning ~15x/day. flock is atomic:
|
|
29
|
+
the loser never touches the socket, it just exits.
|
|
30
|
+
"""
|
|
31
|
+
import fcntl
|
|
32
|
+
|
|
33
|
+
fd = os.open(str(LOCK_PATH), os.O_CREAT | os.O_RDWR, 0o600)
|
|
34
|
+
try:
|
|
35
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
36
|
+
except OSError:
|
|
37
|
+
os.close(fd)
|
|
38
|
+
return None
|
|
39
|
+
return fd
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def ping_engine(timeout: float = 2.0) -> bool:
|
|
43
|
+
"""True if a live engine answers a ping on SOCKET_PATH.
|
|
44
|
+
|
|
45
|
+
Used by the daemon's health watchdog, and by the engine itself at
|
|
46
|
+
startup to refuse to double-run: a second engine binding the socket
|
|
47
|
+
path would silently starve the healthy instance (its listener stays
|
|
48
|
+
blocked on a socket inode that no longer receives anything).
|
|
49
|
+
"""
|
|
50
|
+
import socket
|
|
51
|
+
|
|
52
|
+
if not SOCKET_PATH.exists():
|
|
53
|
+
return False
|
|
54
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
|
55
|
+
try:
|
|
56
|
+
sock.settimeout(timeout)
|
|
57
|
+
sock.bind("") # Linux autobind: gives us an address for the reply
|
|
58
|
+
sock.sendto(b"ping", str(SOCKET_PATH))
|
|
59
|
+
data, _ = sock.recvfrom(64)
|
|
60
|
+
return data == b"pong"
|
|
61
|
+
except OSError:
|
|
62
|
+
return False
|
|
63
|
+
finally:
|
|
64
|
+
sock.close()
|
|
@@ -24,10 +24,18 @@ import gi
|
|
|
24
24
|
gi.require_version("IBus", "1.0")
|
|
25
25
|
from gi.repository import GLib, GObject, IBus
|
|
26
26
|
|
|
27
|
-
from voiceio.ibus import
|
|
27
|
+
from voiceio.ibus import (
|
|
28
|
+
READY_PATH,
|
|
29
|
+
SOCKET_PATH,
|
|
30
|
+
acquire_singleton_lock,
|
|
31
|
+
)
|
|
28
32
|
from voiceio.ibus.pending import PendingBuffer
|
|
29
33
|
|
|
30
34
|
log = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
# Singleton lock fd, held for the process lifetime (see main()). Module-level so
|
|
37
|
+
# it is never garbage-collected — closing the fd would release the lock.
|
|
38
|
+
_lock_fd: int | None = None
|
|
31
39
|
ENGINE_NAME = "voiceio"
|
|
32
40
|
COMPONENT_NAME = "org.voiceio.ibus"
|
|
33
41
|
|
|
@@ -230,13 +238,17 @@ def main() -> None:
|
|
|
230
238
|
],
|
|
231
239
|
)
|
|
232
240
|
|
|
233
|
-
# Refuse to double-run. ibus-daemon
|
|
234
|
-
# engine (component XML) when something activates the voiceio source
|
|
235
|
-
#
|
|
236
|
-
#
|
|
237
|
-
#
|
|
238
|
-
|
|
239
|
-
|
|
241
|
+
# Refuse to double-run, race-free. ibus-daemon exec-spawns its own copy of
|
|
242
|
+
# this engine (component XML) when something activates the voiceio source
|
|
243
|
+
# (GNOME per-window source memory / Super+Space — voiceio is first in
|
|
244
|
+
# mru-sources). Without an atomic gate, two engines both pass a ping check
|
|
245
|
+
# then both unlink+rebind SOCKET_PATH in _socket_listener; the loser is
|
|
246
|
+
# left on an orphaned inode — a "zombie" the watchdog kills and respawns.
|
|
247
|
+
# The lock fd is held for our whole lifetime (released on process death).
|
|
248
|
+
global _lock_fd
|
|
249
|
+
_lock_fd = acquire_singleton_lock()
|
|
250
|
+
if _lock_fd is None:
|
|
251
|
+
log.info("Another VoiceIO engine holds the lock — exiting duplicate")
|
|
240
252
|
return
|
|
241
253
|
|
|
242
254
|
IBus.init()
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
"""Tests for the shared engine ping used by the daemon health watchdog and
|
|
2
|
-
the engine's own refuse-to-double-run guard (a duplicate engine binding the
|
|
3
|
-
socket path silently starves the healthy instance)."""
|
|
4
|
-
from __future__ import annotations
|
|
5
|
-
|
|
6
|
-
import socket
|
|
7
|
-
import sys
|
|
8
|
-
import threading
|
|
9
|
-
|
|
10
|
-
import pytest
|
|
11
|
-
|
|
12
|
-
import voiceio.ibus as ibus_mod
|
|
13
|
-
|
|
14
|
-
# IBus (and AF_UNIX DGRAM + Linux abstract autobind) is Linux-only.
|
|
15
|
-
pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="IBus is Linux-only")
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def test_no_socket_returns_false(tmp_path, monkeypatch):
|
|
19
|
-
monkeypatch.setattr(ibus_mod, "SOCKET_PATH", tmp_path / "nope.sock")
|
|
20
|
-
assert ibus_mod.ping_engine(timeout=0.2) is False
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def test_healthy_peer_answers_pong(tmp_path, monkeypatch):
|
|
24
|
-
sock_path = tmp_path / "engine.sock"
|
|
25
|
-
server = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
|
26
|
-
server.bind(str(sock_path))
|
|
27
|
-
server.settimeout(2.0)
|
|
28
|
-
|
|
29
|
-
def _serve():
|
|
30
|
-
try:
|
|
31
|
-
data, addr = server.recvfrom(64)
|
|
32
|
-
if data == b"ping" and addr:
|
|
33
|
-
server.sendto(b"pong", addr)
|
|
34
|
-
except OSError:
|
|
35
|
-
pass
|
|
36
|
-
|
|
37
|
-
t = threading.Thread(target=_serve, daemon=True)
|
|
38
|
-
t.start()
|
|
39
|
-
monkeypatch.setattr(ibus_mod, "SOCKET_PATH", sock_path)
|
|
40
|
-
try:
|
|
41
|
-
assert ibus_mod.ping_engine(timeout=1.0) is True
|
|
42
|
-
finally:
|
|
43
|
-
t.join(timeout=2.0)
|
|
44
|
-
server.close()
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def test_unresponsive_socket_returns_false(tmp_path, monkeypatch):
|
|
48
|
-
"""A bound-but-silent socket (starved listener) must read as dead."""
|
|
49
|
-
sock_path = tmp_path / "engine.sock"
|
|
50
|
-
server = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
|
51
|
-
server.bind(str(sock_path))
|
|
52
|
-
monkeypatch.setattr(ibus_mod, "SOCKET_PATH", sock_path)
|
|
53
|
-
try:
|
|
54
|
-
assert ibus_mod.ping_engine(timeout=0.2) is False
|
|
55
|
-
finally:
|
|
56
|
-
server.close()
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.9.1"
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
"""IBus input method engine for VoiceIO."""
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
|
|
4
|
-
import os
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
|
|
7
|
-
SOCKET_PATH = Path(os.environ.get("XDG_RUNTIME_DIR", "/tmp")) / "voiceio-ibus.sock"
|
|
8
|
-
READY_PATH = Path(os.environ.get("XDG_RUNTIME_DIR", "/tmp")) / "voiceio-ibus.ready"
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def ping_engine(timeout: float = 2.0) -> bool:
|
|
12
|
-
"""True if a live engine answers a ping on SOCKET_PATH.
|
|
13
|
-
|
|
14
|
-
Used by the daemon's health watchdog, and by the engine itself at
|
|
15
|
-
startup to refuse to double-run: a second engine binding the socket
|
|
16
|
-
path would silently starve the healthy instance (its listener stays
|
|
17
|
-
blocked on a socket inode that no longer receives anything).
|
|
18
|
-
"""
|
|
19
|
-
import socket
|
|
20
|
-
|
|
21
|
-
if not SOCKET_PATH.exists():
|
|
22
|
-
return False
|
|
23
|
-
sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
|
24
|
-
try:
|
|
25
|
-
sock.settimeout(timeout)
|
|
26
|
-
sock.bind("") # Linux autobind: gives us an address for the reply
|
|
27
|
-
sock.sendto(b"ping", str(SOCKET_PATH))
|
|
28
|
-
data, _ = sock.recvfrom(64)
|
|
29
|
-
return data == b"pong"
|
|
30
|
-
except OSError:
|
|
31
|
-
return False
|
|
32
|
-
finally:
|
|
33
|
-
sock.close()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|