python-voiceio 0.9.0__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.
Files changed (132) hide show
  1. {python_voiceio-0.9.0/python_voiceio.egg-info → python_voiceio-0.9.2}/PKG-INFO +1 -1
  2. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/pyproject.toml +1 -1
  3. {python_voiceio-0.9.0 → python_voiceio-0.9.2/python_voiceio.egg-info}/PKG-INFO +1 -1
  4. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/tests/test_correct_batch.py +19 -1
  5. python_voiceio-0.9.2/tests/test_ibus_ping.py +113 -0
  6. python_voiceio-0.9.2/voiceio/__init__.py +1 -0
  7. python_voiceio-0.9.2/voiceio/ibus/__init__.py +64 -0
  8. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/ibus/engine.py +20 -8
  9. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/service.py +8 -1
  10. python_voiceio-0.9.0/tests/test_ibus_ping.py +0 -56
  11. python_voiceio-0.9.0/voiceio/__init__.py +0 -1
  12. python_voiceio-0.9.0/voiceio/ibus/__init__.py +0 -33
  13. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/LICENSE +0 -0
  14. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/README.md +0 -0
  15. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/python_voiceio.egg-info/SOURCES.txt +0 -0
  16. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/python_voiceio.egg-info/dependency_links.txt +0 -0
  17. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/python_voiceio.egg-info/entry_points.txt +0 -0
  18. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/python_voiceio.egg-info/requires.txt +0 -0
  19. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/python_voiceio.egg-info/top_level.txt +0 -0
  20. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/setup.cfg +0 -0
  21. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/tests/test_adjudicate.py +0 -0
  22. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/tests/test_app_wiring.py +0 -0
  23. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/tests/test_audio_quality.py +0 -0
  24. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/tests/test_audit.py +0 -0
  25. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/tests/test_autocorrect.py +0 -0
  26. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/tests/test_autocorrect_state.py +0 -0
  27. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/tests/test_backend_probes.py +0 -0
  28. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/tests/test_cli.py +0 -0
  29. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/tests/test_clipboard_read.py +0 -0
  30. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/tests/test_commands.py +0 -0
  31. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/tests/test_concurrency_lockdown.py +0 -0
  32. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/tests/test_config.py +0 -0
  33. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/tests/test_corrections.py +0 -0
  34. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/tests/test_evaluate.py +0 -0
  35. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/tests/test_fallback.py +0 -0
  36. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/tests/test_health.py +0 -0
  37. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/tests/test_hints.py +0 -0
  38. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/tests/test_history.py +0 -0
  39. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/tests/test_ibus_pending.py +0 -0
  40. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/tests/test_ibus_typer.py +0 -0
  41. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/tests/test_llm.py +0 -0
  42. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/tests/test_llm_api.py +0 -0
  43. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/tests/test_numbers.py +0 -0
  44. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/tests/test_platform.py +0 -0
  45. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/tests/test_postcorrect.py +0 -0
  46. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/tests/test_postprocess.py +0 -0
  47. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/tests/test_prebuffer.py +0 -0
  48. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/tests/test_prompt.py +0 -0
  49. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/tests/test_recorder_integration.py +0 -0
  50. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/tests/test_retention.py +0 -0
  51. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/tests/test_robustness.py +0 -0
  52. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/tests/test_security_hardening.py +0 -0
  53. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/tests/test_snapshots.py +0 -0
  54. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/tests/test_streaming.py +0 -0
  55. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/tests/test_tokens.py +0 -0
  56. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/tests/test_transcriber.py +0 -0
  57. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/tests/test_tts.py +0 -0
  58. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/tests/test_vad.py +0 -0
  59. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/tests/test_vocabulary.py +0 -0
  60. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/tests/test_wizard.py +0 -0
  61. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/tests/test_wordfreq.py +0 -0
  62. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/__main__.py +0 -0
  63. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/app.py +0 -0
  64. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/audit.py +0 -0
  65. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/autocorrect.py +0 -0
  66. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/autocorrect_state.py +0 -0
  67. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/backends.py +0 -0
  68. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/cli.py +0 -0
  69. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/clipboard_read.py +0 -0
  70. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/commands.py +0 -0
  71. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/config.py +0 -0
  72. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/consent.py +0 -0
  73. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/corrections.py +0 -0
  74. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/demo.py +0 -0
  75. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/evaluate.py +0 -0
  76. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/feedback.py +0 -0
  77. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/health.py +0 -0
  78. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/hints.py +0 -0
  79. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/history.py +0 -0
  80. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/hotkeys/__init__.py +0 -0
  81. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/hotkeys/base.py +0 -0
  82. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/hotkeys/chain.py +0 -0
  83. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/hotkeys/evdev.py +0 -0
  84. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/hotkeys/pynput_backend.py +0 -0
  85. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/hotkeys/socket_backend.py +0 -0
  86. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/ibus/pending.py +0 -0
  87. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/llm.py +0 -0
  88. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/llm_api.py +0 -0
  89. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/models/__init__.py +0 -0
  90. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/models/silero_vad.onnx +0 -0
  91. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/numbers.py +0 -0
  92. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/pidlock.py +0 -0
  93. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/platform.py +0 -0
  94. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/postcorrect.py +0 -0
  95. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/postprocess.py +0 -0
  96. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/prompt.py +0 -0
  97. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/recorder.py +0 -0
  98. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/retention.py +0 -0
  99. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/snapshots.py +0 -0
  100. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/sounds/__init__.py +0 -0
  101. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/sounds/commit.wav +0 -0
  102. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/sounds/start.wav +0 -0
  103. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/sounds/stop.wav +0 -0
  104. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/streaming.py +0 -0
  105. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/tokens.py +0 -0
  106. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/transcriber.py +0 -0
  107. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/tray/__init__.py +0 -0
  108. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/tray/_icons.py +0 -0
  109. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/tray/_indicator.py +0 -0
  110. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/tray/_pystray.py +0 -0
  111. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/tts/__init__.py +0 -0
  112. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/tts/base.py +0 -0
  113. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/tts/chain.py +0 -0
  114. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/tts/edge_engine.py +0 -0
  115. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/tts/espeak.py +0 -0
  116. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/tts/piper_engine.py +0 -0
  117. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/tts/player.py +0 -0
  118. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/typers/__init__.py +0 -0
  119. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/typers/base.py +0 -0
  120. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/typers/chain.py +0 -0
  121. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/typers/clipboard.py +0 -0
  122. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/typers/ibus.py +0 -0
  123. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/typers/pynput_type.py +0 -0
  124. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/typers/wtype.py +0 -0
  125. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/typers/xdotool.py +0 -0
  126. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/typers/ydotool.py +0 -0
  127. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/vad.py +0 -0
  128. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/vocab_stats.py +0 -0
  129. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/vocabulary.py +0 -0
  130. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/wizard.py +0 -0
  131. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/wordfreq.py +0 -0
  132. {python_voiceio-0.9.0 → python_voiceio-0.9.2}/voiceio/worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 0.9.0
3
+ Version: 0.9.2
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-voiceio"
7
- version = "0.9.0"
7
+ version = "0.9.2"
8
8
  description = "Voice dictation for Linux. Speak → text, locally, instantly."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 0.9.0
3
+ Version: 0.9.2
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -8,7 +8,11 @@ from voiceio.autocorrect import AdjudicationResult, ReviewResult, SuspiciousWord
8
8
  from voiceio.autocorrect_state import load_state
9
9
  from voiceio.cli import _cmd_correct_auto
10
10
  from voiceio.corrections import CorrectionDict
11
- from voiceio.service import _correct_service_unit, _correct_timer_unit
11
+ from voiceio.service import (
12
+ _correct_service_unit,
13
+ _correct_timer_unit,
14
+ _service_unit,
15
+ )
12
16
 
13
17
 
14
18
  def _entries():
@@ -115,6 +119,20 @@ class TestBatchMode:
115
119
  assert "Grafana" in (tmp_path / "vocabulary.txt").read_text()
116
120
 
117
121
 
122
+ class TestServiceUnit:
123
+ def test_restarts_on_graphical_relogin(self):
124
+ """PartOf stops the service on logout; WantedBy must pull it back in on
125
+ the next login. Both must name graphical-session.target or the service
126
+ dies on logout and never returns until a full reboot (GNOME re-login
127
+ leaves the systemd --user manager — and default.target — untouched)."""
128
+ unit = _service_unit("/usr/bin/voiceio")
129
+ assert "PartOf=graphical-session.target" in unit
130
+ assert "WantedBy=graphical-session.target" in unit
131
+ # default.target is reached once at user-manager start and never
132
+ # re-entered on re-login, so it must NOT be the wake-up trigger.
133
+ assert "WantedBy=default.target" not in unit
134
+
135
+
118
136
  class TestTimerUnits:
119
137
  def test_service_unit_runs_batch(self):
120
138
  unit = _correct_service_unit("/usr/bin/voiceio")
@@ -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 READY_PATH, SOCKET_PATH, ping_engine
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 can exec-spawn its own copy of this
234
- # engine (component XML) when something activates the voiceio source
235
- # e.g. GNOME's per-window source memory. Binding SOCKET_PATH here would
236
- # steal it from the healthy daemon-spawned engine and silently starve it
237
- # (alive, but never sees another command or ping → zombie recovery).
238
- if ping_engine(timeout=0.5):
239
- log.info("Healthy VoiceIO engine already serving %s exiting duplicate", SOCKET_PATH)
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()
@@ -68,7 +68,7 @@ Environment=PYTHONUNBUFFERED=1
68
68
  PassEnvironment=DISPLAY WAYLAND_DISPLAY XDG_SESSION_TYPE XDG_RUNTIME_DIR
69
69
 
70
70
  [Install]
71
- WantedBy=default.target
71
+ WantedBy=graphical-session.target
72
72
  """
73
73
 
74
74
 
@@ -190,6 +190,13 @@ def install_service() -> bool:
190
190
  ["systemctl", "--user", "daemon-reload"],
191
191
  capture_output=True, timeout=5,
192
192
  )
193
+ # disable before enable so a prior [Install] target (older versions
194
+ # wired WantedBy=default.target, which never restarts the service on a
195
+ # GNOME logout/login) is unlinked instead of left as a stale want.
196
+ subprocess.run(
197
+ ["systemctl", "--user", "disable", SERVICE_NAME],
198
+ capture_output=True, timeout=5,
199
+ )
193
200
  subprocess.run(
194
201
  ["systemctl", "--user", "enable", SERVICE_NAME],
195
202
  capture_output=True, timeout=5,
@@ -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.0"
@@ -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