Let a second copy yield to the instance already running

listen() was the whole of the single-instance check and it cannot be one:
a Windows named pipe takes a second server on the same name rather than
refusing it, and everywhere else removeServer() first takes the live socket
away from the instance holding it. Starting Dikte over a running Dikte then
left two whole copies up, two tray icons and all, and the newer one's
sweep() killed the whisper the older one was answering dictations with. On
a machine that sleeps instead of logging out, that is one Start Menu click
away, and it cost a real dictation before it was understood.

A QLockFile in the data directory closes the race on all three systems,
taken before the QApplication is even built, and behind it the probe is the
side-effect-free status verb: the Settings window opens as the sign of life
only when a bare second start deliberately asks for it, not as a byproduct
of a probe racing a forwarded toggle. The two Windows relaunch dances
collapse into one ipc.respawn.

The checkout installer and the packaged setup each kept an autostart the
other could not see, so a machine that tried both started two copies at
every sign-in: each autostart now removes the other's entry, the silent
every-start repair backs off from a Run value whose target still exists,
and each uninstaller deletes the shared dikte.cmd only when the shim
names its own install.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
huseyin-emre-tigci
2026-08-22 23:09:05 +03:00
co-authored by Claude Fable 5
parent 0a70484d84
commit 6f601ab969
7 changed files with 308 additions and 17 deletions
+9
View File
@@ -1122,6 +1122,15 @@ def _stay_out_of_the_dock():
def run_app(args): def run_app(args):
command = args[0] if args else "" command = args[0] if args else ""
# One Dikte per user, checked by asking rather than by listening: see
# ipc.already_serving. Before the QApplication, so a second copy costs a
# moment and not a second tray icon.
if ipc.already_serving():
print("dikte: already running; its Settings window has the attention")
if command:
ipc.send(command)
return 0
app = QApplication(sys.argv) app = QApplication(sys.argv)
app.setApplicationName("Dikte") app.setApplicationName("Dikte")
app.setDesktopFileName("dikte") app.setDesktopFileName("dikte")
+58 -11
View File
@@ -56,6 +56,17 @@ def packaged():
return bool(getattr(sys, "frozen", False)) return bool(getattr(sys, "frozen", False))
def windowed_executable(executable=None):
"""The windowed executable installed beside this one, or None.
Beside rather than at a known place, because the setup program lays the two
executables into the same directory wherever that directory was put: asking
from either of them finds the other without knowing where the install is.
"""
windowed = pathlib.Path(executable or sys.executable).with_name(WINDOWS_APP)
return windowed if windowed.is_file() else None
def target(): def target():
"""The file a launcher has to name to start this build again. """The file a launcher has to name to start this build again.
@@ -74,8 +85,8 @@ def target():
# The windowed executable, whichever of the two is running: the console # The windowed executable, whichever of the two is running: the console
# one is what the `dikte` command names, and a sign-in that started # one is what the `dikte` command names, and a sign-in that started
# that one would open a console window nobody asked for. # that one would open a console window nobody asked for.
windowed = executable.with_name(WINDOWS_APP) windowed = windowed_executable(executable)
if windowed.is_file(): if windowed is not None:
return windowed return windowed
return executable return executable
@@ -572,24 +583,60 @@ def _run_entry_name():
return f"HKCU\\{RUN_KEY}\\{RUN_VALUE}" return f"HKCU\\{RUN_KEY}\\{RUN_VALUE}"
def _run_target(value):
"""The executable a Run value names, out of the quoting the setup wrote.
Only the first word matters here: it is the file whose existence says
whether the entry still starts anything.
"""
if value.startswith('"'):
closing = value.find('"', 1)
return value[1:closing] if closing > 0 else ""
return value.split(" ", 1)[0]
def _startup_shortcut():
"""Where install.ps1 -Autostart puts a checkout's sign-in entry."""
appdata = os.environ.get("APPDATA")
if not appdata:
return None
return (pathlib.Path(appdata) / "Microsoft" / "Windows" / "Start Menu"
/ "Programs" / "Startup" / "Dikte.lnk")
def _windows_install(app, force=False): def _windows_install(app, force=False):
"""Point the autostart entry at this build. What changed. """Point the autostart entry at this build. What changed.
Only `force`, which is what typing `dikte integrate` means, creates one. Only `force`, which is what typing `dikte integrate` means, creates one.
The call on every start repairs an entry that is already there and names an The call on every start repairs an entry that is already there and names an
executable somewhere else, which is what an installation moved to another executable that is gone, which is what an installation moved to another
drive or reinstalled into another directory leaves behind; somebody who drive or reinstalled into another directory leaves behind. An entry naming
unticked the box in the wizard, or turned it off since, is not asked again an executable that still exists is another installation that still works,
by every start. and is stood aside for the way the Linux half stands aside for another
menu entry; somebody who unticked the box in the wizard, or turned it off
since, is not asked again by every start either.
""" """
command = f'"{app}"' command = f'"{app}"'
current = _run_entry() current = _run_entry()
changed = []
if force:
# install.ps1 -Autostart wrote this for a checkout. The Run value
# written below replaces it, and both left in place would be two
# Diktes at every sign-in. Only on force: the silent call on every
# start has not been asked to move the machine off its checkout.
shortcut = _startup_shortcut()
if shortcut is not None and shortcut.is_file():
shortcut.unlink()
changed.append(shortcut)
if not current and not force: if not current and not force:
return [] return changed
if current == command: if current != command:
return [] theirs = _run_target(current) if current else ""
_write_run_entry(command) if not force and theirs and theirs != str(app) and os.path.exists(theirs):
return [_run_entry_name()] return changed
_write_run_entry(command)
changed.append(_run_entry_name())
return changed
def _windows_remove(): def _windows_remove():
+60 -4
View File
@@ -11,11 +11,14 @@ shortcut may still send.
import json import json
import os import os
import shlex import shlex
import subprocess
import sys import sys
from PyQt6.QtCore import QLockFile
from PyQt6.QtNetwork import QLocalSocket from PyQt6.QtNetwork import QLocalSocket
from . import integrate from . import integrate
from . import paths
SERVER_NAME = "dikte-" + ( SERVER_NAME = "dikte-" + (
str(os.getuid()) if hasattr(os, "getuid") str(os.getuid()) if hasattr(os, "getuid")
@@ -54,10 +57,9 @@ def launcher():
if not getattr(sys, "frozen", False): if not getattr(sys, "frozen", False):
return [sys.executable, script_path()] return [sys.executable, script_path()]
if sys.platform == "win32": if sys.platform == "win32":
windowed = os.path.join(os.path.dirname(sys.executable), windowed = integrate.windowed_executable()
integrate.WINDOWS_APP) if windowed is not None:
if os.path.isfile(windowed): return [str(windowed)]
return [windowed]
return [os.environ.get("APPIMAGE") or sys.executable] return [os.environ.get("APPIMAGE") or sys.executable]
@@ -72,6 +74,60 @@ def command_for(verb):
return shlex.join(launcher() + ([verb] if verb else [])) return shlex.join(launcher() + ([verb] if verb else []))
def already_serving():
"""Whether a running instance answers on this user's name.
Asked before an instance opens a server of its own, because listen() is
not the check: a Windows named pipe takes a second server on the same name
rather than refusing it, and everywhere else removeServer() would first
take the live socket away from the instance holding it. Either way two
whole Diktes then run, and the newer one's sweep() kills the whisper the
older one is answering dictations with. The probe is "status" and nothing
else: a verb with a side effect here would fire it during the relaunch a
slow-to-answer instance provokes, on top of the verb being forwarded.
"""
return send("status") is not None
def instance_lock():
"""This user's one-Dikte lock, taken before anything else is built.
The probe above has a hole: two copies started in the same moment both ask
before either listens, and both come up. A lock file closes it, and
QLockFile writes the holder's pid into it, so a lock a killed instance
left behind identifies itself as stale and clears. None when the data
directory cannot be made, which a start should survive: the probe still
stands guard, just without the simultaneous-start case.
"""
try:
paths.DATA_DIR.mkdir(parents=True, exist_ok=True)
except OSError:
return None
lock = QLockFile(str(paths.DATA_DIR / "dikte.lock"))
# Never presume a lock is stale by age alone; the pid check is the truth.
lock.setStaleLockTime(0)
return lock
def respawn(arguments):
"""Start this installation again with `arguments`, leaving this process.
execv everywhere it works the way it says: the new process takes this
pid and nothing is left behind. On Windows execv mangles arguments with
spaces and leaves the two processes sharing a console, so the replacement
is started detached instead and the caller exits on its own.
"""
args = launcher() + list(arguments)
if sys.platform == "win32":
# By value where the names are missing, so the Windows half of this is
# testable from the suite's other platforms too.
detached = (getattr(subprocess, "DETACHED_PROCESS", 0x00000008)
| getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200))
subprocess.Popen(args, creationflags=detached, close_fds=True)
return
os.execv(args[0], args)
def send(cmd, wait=False, timeout=0, **args): def send(cmd, wait=False, timeout=0, **args):
"""Send one request; the reply, or None when no instance is running. """Send one request; the reply, or None when no instance is running.
+21 -1
View File
@@ -23,9 +23,20 @@ $autostartLink = Join-Path $startup "Dikte.lnk"
$cmdShim = Join-Path $env:LOCALAPPDATA "Microsoft\WindowsApps\dikte.cmd" $cmdShim = Join-Path $env:LOCALAPPDATA "Microsoft\WindowsApps\dikte.cmd"
if ($Uninstall) { if ($Uninstall) {
foreach ($path in @($shortcut, $autostartLink, $cmdShim)) { foreach ($path in @($shortcut, $autostartLink)) {
if (Test-Path $path) { Remove-Item $path -Force; Write-Host "removed: $path" } if (Test-Path $path) { Remove-Item $path -Force; Write-Host "removed: $path" }
} }
# The packaged install writes the same shim, naming its own dikte-cli.exe.
# Only the one naming this checkout is ours to delete; taking the other
# would break the `dikte` command of an install this script never made.
if (Test-Path $cmdShim) {
if ((Get-Content $cmdShim -Raw).Contains($entry)) {
Remove-Item $cmdShim -Force
Write-Host "removed: $cmdShim"
} else {
Write-Host "left alone: $cmdShim (it names another install, not this checkout)"
}
}
Write-Host "Dikte's shortcuts are gone. The repository and your settings are not." Write-Host "Dikte's shortcuts are gone. The repository and your settings are not."
exit 0 exit 0
} }
@@ -65,6 +76,15 @@ foreach ($path in @($shortcut) + $(if ($Autostart) { @($autostartLink) } else {
Write-Host "shortcut: $path" Write-Host "shortcut: $path"
} }
if ($Autostart) {
# The packaged build keeps its sign-in entry in the registry. Left there
# beside the shortcut written above, both would start a Dikte at sign-in,
# and this install is the one being asked for.
Remove-ItemProperty -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run" `
-Name "Dikte" -ErrorAction SilentlyContinue
Write-Host "autostart: the Startup shortcut replaces any registry Run entry a packaged install left"
}
# --- the dikte command ------------------------------------------------------ # --- the dikte command ------------------------------------------------------
# The interpreter by its full path rather than by name: the one checked above is # The interpreter by its full path rather than by name: the one checked above is
# the one the command line should run, whatever a later PATH change puts first. # the one the command line should run, whatever a later PATH change puts first.
+15 -1
View File
@@ -59,6 +59,13 @@ Name: "autostart"; Description: "Start Dikte when I sign in"
[Files] [Files]
Source: "{#Source}\*"; DestDir: "{app}"; Flags: recursesubdirs ignoreversion Source: "{#Source}\*"; DestDir: "{app}"; Flags: recursesubdirs ignoreversion
[InstallDelete]
; A checkout's install.ps1 -Autostart is a shortcut in the Startup folder. The
; registry entry this setup writes replaces it, and both left in place would be
; two Diktes at every sign-in. Only under the autostart task: somebody who
; unticked the box has not asked for their checkout's entry to go.
Type: files; Name: "{userstartup}\Dikte.lnk"; Tasks: autostart
[Icons] [Icons]
Name: "{autoprograms}\Dikte"; Filename: "{app}\Dikte.exe" Name: "{autoprograms}\Dikte"; Filename: "{app}\Dikte.exe"
@@ -111,7 +118,14 @@ begin
end; end;
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
var
Shim: AnsiString;
begin begin
{ Only the shim this setup wrote, which is the one naming its dikte-cli.exe.
install.ps1 writes the same file for a checkout, naming that checkout's
Python, and a shim somebody else wrote is not this uninstaller's to take. }
if CurUninstallStep = usUninstall then if CurUninstallStep = usUninstall then
DeleteFile(ShimPath()); if LoadStringFromFile(ShimPath(), Shim)
and (Pos(ExpandConstant('{app}\dikte-cli.exe'), Shim) > 0) then
DeleteFile(ShimPath());
end; end;
+75
View File
@@ -475,6 +475,17 @@ class Windows(unittest.TestCase):
self.installed = pathlib.Path(self.tmp.name).resolve() self.installed = pathlib.Path(self.tmp.name).resolve()
self.app = self.installed / "Dikte.exe" self.app = self.installed / "Dikte.exe"
self.app.write_text("") self.app.write_text("")
# APPDATA pointed into the sandbox, so that the Startup folder these
# tests delete from is never the machine's own.
appdata = mock.patch.dict(os.environ,
{"APPDATA": str(self.installed / "Roaming")})
appdata.start()
self.addCleanup(appdata.stop)
def startup_shortcut(self):
"""Where install.ps1 -Autostart puts a checkout's sign-in entry."""
return (self.installed / "Roaming" / "Microsoft" / "Windows"
/ "Start Menu" / "Programs" / "Startup" / "Dikte.lnk")
def _write(self, command): def _write(self, command):
self.value = command self.value = command
@@ -510,6 +521,45 @@ class Windows(unittest.TestCase):
self.assertEqual(len(self.install()), 1) self.assertEqual(len(self.install()), 1)
self.assertEqual(self.value, f'"{self.app}"') self.assertEqual(self.value, f'"{self.app}"')
def test_an_entry_for_another_working_install_is_left_alone(self):
"""The same courtesy the Linux half pays another menu entry: an entry
naming an executable that still exists is an installation that still
works, and a start of this one has no business redirecting it."""
other = self.installed / "Elsewhere" / "Dikte.exe"
other.parent.mkdir()
other.write_text("")
self.value = f'"{other}"'
self.assertEqual(self.install(), [])
self.assertEqual(self.value, f'"{other}"')
def test_asking_outright_overrules_a_working_other_install(self):
other = self.installed / "Elsewhere" / "Dikte.exe"
other.parent.mkdir()
other.write_text("")
self.value = f'"{other}"'
self.assertEqual(self.install(force=True), [integrate._run_entry_name()])
self.assertEqual(self.value, f'"{self.app}"')
def test_typing_it_sweeps_away_a_checkout_startup_shortcut(self):
"""install.ps1 -Autostart writes it, the Run value replaces it, and
both left in place would be two Diktes at every sign-in."""
shortcut = self.startup_shortcut()
shortcut.parent.mkdir(parents=True)
shortcut.write_text("")
changed = self.install(force=True)
self.assertIn(shortcut, changed)
self.assertFalse(shortcut.exists())
def test_a_start_leaves_a_checkout_startup_shortcut_alone(self):
"""The silent call on every start has not been asked to move the
machine off its checkout."""
shortcut = self.startup_shortcut()
shortcut.parent.mkdir(parents=True)
shortcut.write_text("")
self.value = f'"{self.app}"'
self.assertEqual(self.install(), [])
self.assertTrue(shortcut.exists())
def test_running_it_again_changes_nothing(self): def test_running_it_again_changes_nothing(self):
self.install(force=True) self.install(force=True)
self.assertEqual(self.install(), []) self.assertEqual(self.install(), [])
@@ -521,6 +571,31 @@ class Windows(unittest.TestCase):
self.assertEqual(self.remove(), []) self.assertEqual(self.remove(), [])
class WindowedExecutable(unittest.TestCase):
"""The windowed executable, looked up beside whichever one is running.
Beside rather than at a known place: the setup lays both executables into
one directory wherever that directory was put, so either can find the
other without knowing where the install is.
"""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.installed = pathlib.Path(self.tmp.name).resolve()
def test_found_beside_the_named_executable(self):
windowed = self.installed / "Dikte.exe"
windowed.write_text("")
self.assertEqual(
integrate.windowed_executable(str(self.installed / "dikte-cli.exe")),
windowed)
def test_none_when_no_setup_installed_one(self):
self.assertIsNone(
integrate.windowed_executable(str(self.installed / "dikte-cli.exe")))
class WindowsExecutableNames(unittest.TestCase): class WindowsExecutableNames(unittest.TestCase):
"""The two Windows executables, read out of the files that name them. """The two Windows executables, read out of the files that name them.
+70
View File
@@ -14,6 +14,7 @@ import unittest
from unittest import mock from unittest import mock
from dikte import ipc from dikte import ipc
from tests.support import DikteTest
class FakeSocket: class FakeSocket:
@@ -185,5 +186,74 @@ class Send(unittest.TestCase):
self.assertTrue(sock.disconnected) self.assertTrue(sock.disconnected)
class AlreadyServing(unittest.TestCase):
"""The single-instance check, which listen() cannot be: a Windows pipe
takes a second server on the same name rather than refusing it."""
def probe(self, socket):
with mock.patch.object(ipc, "QLocalSocket", return_value=socket):
return ipc.already_serving()
def test_nothing_running_means_go_ahead(self):
self.assertFalse(self.probe(FakeSocket(connected=False)))
def test_an_answer_means_yield(self):
self.assertTrue(self.probe(FakeSocket(reply=b'{"ok": true}\n')))
def test_the_probe_has_no_side_effect(self):
"""A probe that opened a window would open it during the relaunch a
slow instance provokes, on top of the verb being forwarded."""
sock = FakeSocket(reply=b'{"ok": true}\n')
self.probe(sock)
self.assertEqual(sock.written.decode("utf-8").strip(), "status")
def test_an_instance_too_old_to_answer_still_counts_as_running(self):
self.assertTrue(self.probe(FakeSocket(reply=b"")))
class InstanceLock(DikteTest):
def setUp(self):
super().setUp()
# The lock derives its home from paths, which DikteTest's cfg patches
# do not cover; without this the test would write into the real one.
from dikte import paths
self.patch_attr(paths, "DATA_DIR", self.path("data"))
def test_one_holder_at_a_time(self):
first = ipc.instance_lock()
self.assertIsNotNone(first)
self.assertTrue(first.tryLock(0))
second = ipc.instance_lock()
self.assertFalse(second.tryLock(0))
first.unlock()
self.assertTrue(second.tryLock(0))
second.unlock()
def test_the_lock_lives_in_the_data_directory(self):
from dikte import paths
lock = ipc.instance_lock()
self.assertTrue(lock.tryLock(0))
self.assertTrue((paths.DATA_DIR / "dikte.lock").exists())
lock.unlock()
class Respawn(unittest.TestCase):
def test_windows_starts_a_detached_process_and_returns(self):
with mock.patch.object(sys, "platform", "win32"), \
mock.patch.object(ipc, "launcher", return_value=["py", "x"]), \
mock.patch.object(ipc.subprocess, "Popen") as popen:
ipc.respawn(["--gui"])
self.assertEqual(popen.call_args.args[0], ["py", "x", "--gui"])
self.assertEqual(popen.call_args.kwargs["creationflags"],
0x00000008 | 0x00000200)
def test_everywhere_else_the_process_is_replaced(self):
with mock.patch.object(sys, "platform", "linux"), \
mock.patch.object(ipc, "launcher", return_value=["py", "x"]), \
mock.patch.object(ipc.os, "execv") as execv:
ipc.respawn(["toggle", "--gui"])
execv.assert_called_once_with("py", ["py", "x", "toggle", "--gui"])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()