mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
Never let an install or a sweep destroy what still works
install_program deleted the working install before the download had even started, so a network failure, or the running server's own locked DLLs, left "whisper.cpp is not installed" behind on a machine where it had been. The order is now: download, unpack beside, stop the server whose binary lives there, swap, so the outage is the swap and not the whole transfer, and a download that fails never takes the server down at all. A re-downloaded model the server still holds open no longer costs the finished download; the .part survives and the message says who is holding the file. sweep() forgot the pid file before verifying or killing, so one transient error orphaned a loaded model forever; it verifies, kills, then forgets, and a verification that could not run leaves the file for the next start. On Windows the ownership check was the executable's basename, which a recycled pid could satisfy with somebody else's server; it is the full image path now, read into a buffer that grows past 260 characters. stop() takes the launch lock, so stopping during a start kills the server the start was making rather than missing it, and _forget only removes a record that is still its own. The relaunch retry stopped reading English out of the log tail: the retryable failure is a child that exited without ever listening, and that is what is tested, along with the child still being alive once its port answers. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
420cda9376
commit
197f5385ee
+211
-51
@@ -63,6 +63,10 @@ MODELS_DIR = DATA_DIR / "models"
|
|||||||
# Loading a large model onto a GPU is the slow part of a start, and on a cold
|
# Loading a large model onto a GPU is the slow part of a start, and on a cold
|
||||||
# page cache a large LLM read from a spinning disk is slower still.
|
# page cache a large LLM read from a spinning disk is slower still.
|
||||||
STARTUP_TIMEOUT = 180.0
|
STARTUP_TIMEOUT = 180.0
|
||||||
|
# A child that loses the bind race fails and exits at once; a model that fails
|
||||||
|
# to load takes longer than this to be read in first. The line between "worth
|
||||||
|
# another port" and "would fail the same way again" is drawn on time.
|
||||||
|
EARLY_EXIT_WINDOW = 5.0
|
||||||
DOWNLOAD_CHUNK = 1 << 20
|
DOWNLOAD_CHUNK = 1 << 20
|
||||||
|
|
||||||
# `health` is the path that answers only once the model is in memory. whisper
|
# `health` is the path that answers only once the model is in memory. whisper
|
||||||
@@ -193,7 +197,16 @@ def download(item, target, on_progress=None, should_stop=None, require_hash=True
|
|||||||
part.unlink(missing_ok=True)
|
part.unlink(missing_ok=True)
|
||||||
raise LocalError(t("{name} does not match its published checksum. "
|
raise LocalError(t("{name} does not match its published checksum. "
|
||||||
"Nothing was installed.", name=item.name))
|
"Nothing was installed.", name=item.name))
|
||||||
part.replace(target)
|
try:
|
||||||
|
part.replace(target)
|
||||||
|
except PermissionError as exc:
|
||||||
|
# Windows refuses to replace a file something has open, and a
|
||||||
|
# running server holds its model and its binary open. The bytes
|
||||||
|
# are complete and verified: keeping the .part costs a retry,
|
||||||
|
# deleting it costs the whole download again.
|
||||||
|
raise LocalError(t("{name} downloaded, but the old file is held "
|
||||||
|
"open by the running server. Stop it and try "
|
||||||
|
"again.", name=item.name)) from exc
|
||||||
return True
|
return True
|
||||||
except urllib.error.HTTPError as exc:
|
except urllib.error.HTTPError as exc:
|
||||||
part.unlink(missing_ok=True)
|
part.unlink(missing_ok=True)
|
||||||
@@ -268,22 +281,23 @@ def _install_record(program):
|
|||||||
return BIN_DIR / program.name / "installed.json"
|
return BIN_DIR / program.name / "installed.json"
|
||||||
|
|
||||||
|
|
||||||
def installed_program(program):
|
def _read_record(program):
|
||||||
"""The binary Dikte downloaded, or "" when there is none that still runs."""
|
"""The install record, or {} however it fails to read."""
|
||||||
try:
|
try:
|
||||||
record = json.loads(_install_record(program).read_text(encoding="utf-8"))
|
record = json.loads(_install_record(program).read_text(encoding="utf-8"))
|
||||||
path = record.get("binary") or ""
|
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
return ""
|
return {}
|
||||||
|
return record if isinstance(record, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def installed_program(program):
|
||||||
|
"""The binary Dikte downloaded, or "" when there is none that still runs."""
|
||||||
|
path = _read_record(program).get("binary") or ""
|
||||||
return path if os.path.isfile(path) and os.access(path, os.X_OK) else ""
|
return path if os.path.isfile(path) and os.access(path, os.X_OK) else ""
|
||||||
|
|
||||||
|
|
||||||
def installed_version(program):
|
def installed_version(program):
|
||||||
try:
|
return _read_record(program).get("tag") or ""
|
||||||
record = json.loads(_install_record(program).read_text(encoding="utf-8"))
|
|
||||||
return record.get("tag") or ""
|
|
||||||
except (OSError, ValueError):
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def program_path(program, custom=""):
|
def program_path(program, custom=""):
|
||||||
@@ -339,6 +353,19 @@ def _extract(archive, into):
|
|||||||
name=os.path.basename(str(archive)), error=exc)) from exc
|
name=os.path.basename(str(archive)), error=exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _under(path, root):
|
||||||
|
"""Whether `path` lies inside `root`, symlinks and case resolved.
|
||||||
|
|
||||||
|
Resolved on both sides, because the same directory can be reached under
|
||||||
|
two spellings and this answer decides whether a server gets stopped.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
pathlib.Path(path).resolve().relative_to(pathlib.Path(root).resolve())
|
||||||
|
return True
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def install_program(program, tag="", on_progress=None, should_stop=None,
|
def install_program(program, tag="", on_progress=None, should_stop=None,
|
||||||
refresh=False):
|
refresh=False):
|
||||||
"""Fetch and unpack a release. The path to the binary, or "" when stopped.
|
"""Fetch and unpack a release. The path to the binary, or "" when stopped.
|
||||||
@@ -374,17 +401,52 @@ def install_program(program, tag="", on_progress=None, should_stop=None,
|
|||||||
repo=program.repo, tag=tag))
|
repo=program.repo, tag=tag))
|
||||||
|
|
||||||
into = BIN_DIR / program.name / tag
|
into = BIN_DIR / program.name / tag
|
||||||
shutil.rmtree(into, ignore_errors=True)
|
fresh = into.with_name(tag + ".new")
|
||||||
archive = BIN_DIR / program.name / item.name
|
archive = BIN_DIR / program.name / item.name
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not download(item, archive, on_progress, should_stop):
|
if not download(item, archive, on_progress, should_stop):
|
||||||
return ""
|
return ""
|
||||||
_extract(archive, into)
|
try:
|
||||||
binary = _find_binary(into, _binary_file(program))
|
# Unpacked into a sibling and swapped in only once the binary is
|
||||||
if binary is None:
|
# known to be inside: a failure anywhere in here leaves the
|
||||||
raise LocalError(t("{name} was not in the download.",
|
# previous install, and its record, exactly as they were.
|
||||||
name=program.binary))
|
shutil.rmtree(fresh, ignore_errors=True)
|
||||||
binary.chmod(binary.stat().st_mode | 0o111)
|
_extract(archive, fresh)
|
||||||
|
binary = _find_binary(fresh, _binary_file(program))
|
||||||
|
if binary is None:
|
||||||
|
raise LocalError(t("{name} was not in the download.",
|
||||||
|
name=program.binary))
|
||||||
|
binary.chmod(binary.stat().st_mode | 0o111)
|
||||||
|
# A running server holds its binary open, and Windows will not
|
||||||
|
# delete an open file: whichever of our servers runs out of this
|
||||||
|
# program's directory is stopped here, after the download and the
|
||||||
|
# unpack are known good, so the outage is the swap and not the
|
||||||
|
# whole transfer.
|
||||||
|
for server in SERVERS:
|
||||||
|
current = program_path(server.program,
|
||||||
|
server.settings().get("binary", ""))
|
||||||
|
if current and _under(current, BIN_DIR / program.name):
|
||||||
|
server.stop()
|
||||||
|
if into.exists():
|
||||||
|
try:
|
||||||
|
shutil.rmtree(into)
|
||||||
|
except OSError as exc:
|
||||||
|
# Not ignore_errors: silently losing this would rename the
|
||||||
|
# new version somewhere it can never land, and the user can
|
||||||
|
# actually fix it by closing whatever holds the directory.
|
||||||
|
raise LocalError(t(
|
||||||
|
"Could not replace {path}: a file in it is still "
|
||||||
|
"open: {error}", path=into, error=exc)) from exc
|
||||||
|
fresh.rename(into)
|
||||||
|
except BaseException:
|
||||||
|
# Half an unpacked sibling is not worth keeping, and the swap
|
||||||
|
# never ran, so the previous install is still whole.
|
||||||
|
shutil.rmtree(fresh, ignore_errors=True)
|
||||||
|
raise
|
||||||
|
# Found under the sibling, run from the final directory.
|
||||||
|
binary = into / binary.relative_to(fresh)
|
||||||
|
# Written last, so the record never points at anything half-made.
|
||||||
_install_record(program).write_text(
|
_install_record(program).write_text(
|
||||||
json.dumps({"tag": tag, "binary": str(binary)}), encoding="utf-8")
|
json.dumps({"tag": tag, "binary": str(binary)}), encoding="utf-8")
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
@@ -404,8 +466,15 @@ def _drop_old_versions(program, keep):
|
|||||||
root = BIN_DIR / program.name
|
root = BIN_DIR / program.name
|
||||||
try:
|
try:
|
||||||
for path in root.iterdir():
|
for path in root.iterdir():
|
||||||
if path.is_dir() and path.name != keep:
|
if not path.is_dir() or path.name == keep:
|
||||||
shutil.rmtree(path, ignore_errors=True)
|
continue
|
||||||
|
# A ".new" sibling belongs to an install mid-swap; housekeeping
|
||||||
|
# must not pull it out from under it.
|
||||||
|
if path.name.endswith(".new"):
|
||||||
|
continue
|
||||||
|
# ignore_errors on purpose: this is housekeeping, and a locked old
|
||||||
|
# version is a little wasted disk rather than a failed install.
|
||||||
|
shutil.rmtree(path, ignore_errors=True)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -538,7 +607,12 @@ def _tail(path, lines=3):
|
|||||||
|
|
||||||
|
|
||||||
def _win_image_name(pid):
|
def _win_image_name(pid):
|
||||||
"""The lower-cased file name of the process's executable, or ''."""
|
"""The full, lower-cased path of the process's executable, or ''.
|
||||||
|
|
||||||
|
The full path rather than the base name, because the name alone is anyone's
|
||||||
|
whisper-server.exe and this answer decides what gets killed. MAX_PATH is a
|
||||||
|
convention rather than a limit, so the buffer grows until the query fits.
|
||||||
|
"""
|
||||||
import ctypes
|
import ctypes
|
||||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||||
kernel32.OpenProcess.restype = ctypes.c_void_p
|
kernel32.OpenProcess.restype = ctypes.c_void_p
|
||||||
@@ -548,11 +622,18 @@ def _win_image_name(pid):
|
|||||||
if not handle:
|
if not handle:
|
||||||
return ""
|
return ""
|
||||||
try:
|
try:
|
||||||
buffer = ctypes.create_unicode_buffer(260)
|
length = 260
|
||||||
size = ctypes.c_uint32(len(buffer))
|
while length <= 32768:
|
||||||
ok = kernel32.QueryFullProcessImageNameW(
|
buffer = ctypes.create_unicode_buffer(length)
|
||||||
ctypes.c_void_p(handle), 0, buffer, ctypes.byref(size))
|
size = ctypes.c_uint32(len(buffer))
|
||||||
return os.path.basename(buffer.value).lower() if ok else ""
|
ok = kernel32.QueryFullProcessImageNameW(
|
||||||
|
ctypes.c_void_p(handle), 0, buffer, ctypes.byref(size))
|
||||||
|
if ok:
|
||||||
|
return buffer.value.lower()
|
||||||
|
if ctypes.get_last_error() != 122: # ERROR_INSUFFICIENT_BUFFER
|
||||||
|
return ""
|
||||||
|
length *= 2
|
||||||
|
return ""
|
||||||
finally:
|
finally:
|
||||||
kernel32.CloseHandle(handle)
|
kernel32.CloseHandle(handle)
|
||||||
|
|
||||||
@@ -578,6 +659,9 @@ class Server:
|
|||||||
self._port = 0
|
self._port = 0
|
||||||
self._log = ""
|
self._log = ""
|
||||||
self._key = None
|
self._key = None
|
||||||
|
# The pid this instance last wrote to its pid file, so _forget never
|
||||||
|
# removes a file some other Dikte wrote after us.
|
||||||
|
self._pid = 0
|
||||||
|
|
||||||
# ---- settings --------------------------------------------------------
|
# ---- settings --------------------------------------------------------
|
||||||
|
|
||||||
@@ -626,7 +710,9 @@ class Server:
|
|||||||
ready = self._current_url()
|
ready = self._current_url()
|
||||||
if ready:
|
if ready:
|
||||||
return ready
|
return ready
|
||||||
self.stop()
|
# _stop_now rather than stop(): this thread already holds
|
||||||
|
# _starting, and the public stop() waits for it.
|
||||||
|
self._stop_now()
|
||||||
with self._lock:
|
with self._lock:
|
||||||
settings, key = dict(self._settings), self._settings_key()
|
settings, key = dict(self._settings), self._settings_key()
|
||||||
proc, port, log = self._launch(settings)
|
proc, port, log = self._launch(settings)
|
||||||
@@ -659,7 +745,7 @@ class Server:
|
|||||||
stdout=sink, stderr=subprocess.STDOUT,
|
stdout=sink, stderr=subprocess.STDOUT,
|
||||||
stdin=subprocess.DEVNULL,
|
stdin=subprocess.DEVNULL,
|
||||||
# No console window of its own on Windows.
|
# No console window of its own on Windows.
|
||||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
creationflags=paths.NO_WINDOW,
|
||||||
)
|
)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
raise LocalError(t("Could not start {name}: {error}",
|
raise LocalError(t("Could not start {name}: {error}",
|
||||||
@@ -668,8 +754,9 @@ class Server:
|
|||||||
# Written before it is ready rather than after, so that a kill
|
# Written before it is ready rather than after, so that a kill
|
||||||
# during the model load leaves something for the sweep to find.
|
# during the model load leaves something for the sweep to find.
|
||||||
self._remember(proc.pid)
|
self._remember(proc.pid)
|
||||||
|
began = time.monotonic()
|
||||||
try:
|
try:
|
||||||
ready = self._wait_ready(proc, port)
|
reason, listened = self._wait_ready(proc, port)
|
||||||
except BaseException:
|
except BaseException:
|
||||||
# Whatever went wrong while waiting, the process is ours and
|
# Whatever went wrong while waiting, the process is ours and
|
||||||
# nothing else is left holding a reference to it. Leaving it
|
# nothing else is left holding a reference to it. Leaving it
|
||||||
@@ -679,31 +766,52 @@ class Server:
|
|||||||
self._kill(proc)
|
self._kill(proc)
|
||||||
self._forget()
|
self._forget()
|
||||||
raise
|
raise
|
||||||
if ready:
|
if reason == "ready":
|
||||||
return proc, port, str(log)
|
return proc, port, str(log)
|
||||||
last = _tail(log)
|
last = _tail(log)
|
||||||
self._forget()
|
self._forget()
|
||||||
# A port taken between the probe and the bind is the one failure
|
# Losing the port between the probe and the bind is the one
|
||||||
# worth another go; anything else will fail the same way again.
|
# failure another port fixes, and it has a shape rather than a
|
||||||
if "address" not in last.lower() and "bind" not in last.lower():
|
# message: the child died at once without the port ever having
|
||||||
|
# answered as its own. Grepping the log for "bind" would tie this
|
||||||
|
# to one program's wording in one language.
|
||||||
|
early = time.monotonic() - began < EARLY_EXIT_WINDOW
|
||||||
|
if reason != "exited" or listened or not early:
|
||||||
break
|
break
|
||||||
raise LocalError(t("{name} did not start: {error}",
|
raise LocalError(t("{name} did not start: {error}",
|
||||||
name=self.program.binary, error=last or t("no output")))
|
name=self.program.binary, error=last or t("no output")))
|
||||||
|
|
||||||
def _wait_ready(self, proc, port):
|
def _wait_ready(self, proc, port):
|
||||||
|
"""("ready" | "exited" | "timeout", whether the port answered as ours).
|
||||||
|
|
||||||
|
whisper binds after the model is loaded, so the open port is the
|
||||||
|
answer; llama binds first and answers /health with 503 until it is
|
||||||
|
ready. For the health-less case the open port alone is not proof: a
|
||||||
|
child that lost the bind race exits at once while the winner keeps the
|
||||||
|
port open, so "ready" also wants our child alive a beat after the port
|
||||||
|
was first seen open.
|
||||||
|
"""
|
||||||
deadline = time.monotonic() + STARTUP_TIMEOUT
|
deadline = time.monotonic() + STARTUP_TIMEOUT
|
||||||
|
seen_open = False
|
||||||
|
listened = False
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
if proc.poll() is not None:
|
if proc.poll() is not None:
|
||||||
return False
|
return "exited", listened
|
||||||
|
if seen_open:
|
||||||
|
# Port open on the last pass and our child still alive now:
|
||||||
|
# an imposter's port would have left our child dead by here.
|
||||||
|
return "ready", True
|
||||||
if _listening(port):
|
if _listening(port):
|
||||||
# whisper binds after the model is loaded, so the open port is
|
if self.program.health:
|
||||||
# the answer. llama binds first and answers /health with 503
|
# llama did the binding itself, so the port is its.
|
||||||
# until it is ready.
|
listened = True
|
||||||
if not self.program.health or _healthy(port, self.program.health):
|
if _healthy(port, self.program.health):
|
||||||
return True
|
return "ready", True
|
||||||
|
else:
|
||||||
|
seen_open = True
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
self._kill(proc)
|
self._kill(proc)
|
||||||
return False
|
return "timeout", listened
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _kill(proc, gently=False):
|
def _kill(proc, gently=False):
|
||||||
@@ -724,6 +832,14 @@ class Server:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
|
# Taking _starting means a stop cannot slide past a launch in flight:
|
||||||
|
# serve() finishes registering its child first, and the child is then
|
||||||
|
# killed here rather than surviving the shutdown unowned.
|
||||||
|
with self._starting:
|
||||||
|
self._stop_now()
|
||||||
|
|
||||||
|
def _stop_now(self):
|
||||||
|
"""stop() for a thread that already holds _starting."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
proc, self._proc = self._proc, None
|
proc, self._proc = self._proc, None
|
||||||
self._port, self._log, self._key = 0, "", None
|
self._port, self._log, self._key = 0, "", None
|
||||||
@@ -737,6 +853,8 @@ class Server:
|
|||||||
return DATA_DIR / f"{self.program.name}-server.pid"
|
return DATA_DIR / f"{self.program.name}-server.pid"
|
||||||
|
|
||||||
def _remember(self, pid):
|
def _remember(self, pid):
|
||||||
|
with self._lock:
|
||||||
|
self._pid = pid
|
||||||
try:
|
try:
|
||||||
path = self._pid_file()
|
path = self._pid_file()
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -744,28 +862,62 @@ class Server:
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass # the sweep is a safety net, not something to fail a run over
|
pass # the sweep is a safety net, not something to fail a run over
|
||||||
|
|
||||||
def _forget(self):
|
def _forget(self, pid=None):
|
||||||
|
"""Remove the pid file, but only while it still holds our own pid.
|
||||||
|
|
||||||
|
Another Dikte started after us writes its pid over ours, and removing
|
||||||
|
that file would hide its server from every future sweep.
|
||||||
|
"""
|
||||||
|
if pid is None:
|
||||||
|
with self._lock:
|
||||||
|
pid = self._pid
|
||||||
try:
|
try:
|
||||||
self._pid_file().unlink()
|
if int(self._pid_file().read_text().strip()) == pid:
|
||||||
except OSError:
|
self._pid_file().unlink()
|
||||||
|
except (OSError, ValueError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _is_ours(self, pid):
|
def _is_ours(self, pid):
|
||||||
"""Whether that pid is still the server this Dikte started.
|
"""True: still our server. False: definitely not. None: cannot tell now.
|
||||||
|
|
||||||
Asked because pids are handed out again: by the time anyone looks, the
|
Asked because pids are handed out again: by the time anyone looks, the
|
||||||
number could belong to something else entirely, and killing it would be
|
number could belong to something else entirely, and killing it would be
|
||||||
a good deal worse than the leak being cleaned up. The program name alone
|
a good deal worse than the leak being cleaned up. The tri-state matters
|
||||||
could be somebody else's copy; the name together with Dikte's own data
|
for the pid file: a definitive "not ours" means the file is stale and
|
||||||
directory on the command line could not. Windows offers no command line
|
safe to drop, while "cannot tell" means it has to stay so a later start
|
||||||
to read, so the executable's name is the whole of the answer there.
|
can ask again.
|
||||||
|
|
||||||
|
On Linux the program name alone could be somebody else's copy; the name
|
||||||
|
together with Dikte's own data directory on the command line could not.
|
||||||
|
Windows offers no command line to read, so the executable's full path
|
||||||
|
is the answer there: under our bin directory, or exactly the binary the
|
||||||
|
settings point this server at. Never the base name alone, which is
|
||||||
|
anyone's whisper-server.exe.
|
||||||
"""
|
"""
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
return _win_image_name(pid) == _binary_file(self.program).lower()
|
path = _win_image_name(pid)
|
||||||
|
if not path:
|
||||||
|
# OpenProcess said nothing: the process may be gone or merely
|
||||||
|
# unreadable from here, and the difference decides whether the
|
||||||
|
# pid file may be dropped, so no verdict rather than a wrong one.
|
||||||
|
return None
|
||||||
|
path = os.path.normcase(path)
|
||||||
|
if path.startswith(os.path.normcase(str(BIN_DIR)) + os.sep):
|
||||||
|
return True
|
||||||
|
with self._lock:
|
||||||
|
custom = self._settings.get("binary", "")
|
||||||
|
configured = program_path(self.program, custom)
|
||||||
|
if configured:
|
||||||
|
resolved = os.path.normcase(str(pathlib.Path(configured).resolve()))
|
||||||
|
if path == resolved:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
try:
|
try:
|
||||||
blob = pathlib.Path(f"/proc/{pid}/cmdline").read_bytes()
|
blob = pathlib.Path(f"/proc/{pid}/cmdline").read_bytes()
|
||||||
|
except (FileNotFoundError, ProcessLookupError):
|
||||||
|
return False # the process is definitively gone
|
||||||
except OSError:
|
except OSError:
|
||||||
return False
|
return None # /proc would not answer just now
|
||||||
return (self.program.binary.encode() in blob
|
return (self.program.binary.encode() in blob
|
||||||
and str(DATA_DIR).encode() in blob)
|
and str(DATA_DIR).encode() in blob)
|
||||||
|
|
||||||
@@ -781,13 +933,21 @@ class Server:
|
|||||||
pid = int(self._pid_file().read_text().strip())
|
pid = int(self._pid_file().read_text().strip())
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
return False
|
return False
|
||||||
self._forget()
|
owned = self._is_ours(pid)
|
||||||
if not self._is_ours(pid):
|
if owned is None:
|
||||||
|
# Could not be verified rather than known stale: the file stays,
|
||||||
|
# so the next start asks again instead of losing track of a server
|
||||||
|
# that may still be holding a model.
|
||||||
|
return False
|
||||||
|
if not owned:
|
||||||
|
self._forget(pid)
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
os.kill(pid, signal.SIGTERM)
|
os.kill(pid, signal.SIGTERM)
|
||||||
except OSError:
|
except OSError:
|
||||||
|
self._forget(pid)
|
||||||
return False
|
return False
|
||||||
|
self._forget(pid)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+242
-2
@@ -9,6 +9,7 @@ import contextlib
|
|||||||
import hashlib
|
import hashlib
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
|
import pathlib
|
||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
import tarfile
|
import tarfile
|
||||||
@@ -179,6 +180,21 @@ class Download(Local):
|
|||||||
ggml.download(item("m.bin", b"x"), target)
|
ggml.download(item("m.bin", b"x"), target)
|
||||||
self.assertFalse(target.exists())
|
self.assertFalse(target.exists())
|
||||||
|
|
||||||
|
def test_a_target_held_open_keeps_the_finished_download(self):
|
||||||
|
# Windows refuses to replace a file a running server holds open. The
|
||||||
|
# bytes are complete and verified by then, so the .part must survive
|
||||||
|
# the failure rather than being deleted with everything else.
|
||||||
|
data = b"a finished, verified download"
|
||||||
|
target = self.path("data", "models", "m.bin")
|
||||||
|
with fake_urlopen(body(data)):
|
||||||
|
with mock.patch.object(pathlib.Path, "replace",
|
||||||
|
side_effect=PermissionError(13, "in use")):
|
||||||
|
with self.assertRaises(ggml.LocalError) as caught:
|
||||||
|
ggml.download(item("m.bin", data), target)
|
||||||
|
self.assertIn("held open", str(caught.exception))
|
||||||
|
self.assertEqual(target.with_name("m.bin.part").read_bytes(), data)
|
||||||
|
self.assertFalse(target.exists())
|
||||||
|
|
||||||
|
|
||||||
# --- installing a program -------------------------------------------------
|
# --- installing a program -------------------------------------------------
|
||||||
|
|
||||||
@@ -275,6 +291,70 @@ class InstallProgram(Local):
|
|||||||
self.install("whisper-bin-ubuntu-x64.tar.gz", archive=empty)
|
self.install("whisper-bin-ubuntu-x64.tar.gz", archive=empty)
|
||||||
self.assertIn("whisper-server", str(caught.exception))
|
self.assertIn("whisper-server", str(caught.exception))
|
||||||
|
|
||||||
|
def test_a_failed_update_leaves_the_working_install_alone(self):
|
||||||
|
# The old install used to be deleted before the new bytes had even
|
||||||
|
# arrived, so a bad download left no local server at all.
|
||||||
|
path, _ = self.install("whisper-bin-ubuntu-x64.tar.gz")
|
||||||
|
with self.assertRaises(ggml.LocalError):
|
||||||
|
self.install("whisper-bin-ubuntu-x64.tar.gz",
|
||||||
|
archive=b"not an archive at all")
|
||||||
|
self.assertEqual(ggml.installed_program(ggml.WHISPER), path)
|
||||||
|
self.assertTrue(os.path.isfile(path))
|
||||||
|
self.assertEqual(ggml.installed_version(ggml.WHISPER), "v1.9.1")
|
||||||
|
# And the half-made sibling did not linger either.
|
||||||
|
left = list(self.path("data", "bin", "whisper").glob("*.new"))
|
||||||
|
self.assertEqual(left, [])
|
||||||
|
|
||||||
|
def test_an_update_that_never_downloaded_leaves_the_install_alone(self):
|
||||||
|
path, _ = self.install("whisper-bin-ubuntu-x64.tar.gz")
|
||||||
|
listing = self.release("whisper-bin-ubuntu-x64.tar.gz")
|
||||||
|
with serving(listing, self.archive):
|
||||||
|
with mock.patch.object(ggml, "download",
|
||||||
|
side_effect=ggml.LocalError("no route")):
|
||||||
|
with self.assertRaises(ggml.LocalError):
|
||||||
|
ggml.install_program(ggml.WHISPER)
|
||||||
|
self.assertEqual(ggml.installed_program(ggml.WHISPER), path)
|
||||||
|
self.assertTrue(os.path.isfile(path))
|
||||||
|
|
||||||
|
class StubServer:
|
||||||
|
"""Owns the installed binary, remembers when it was told to stop."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.program = ggml.WHISPER
|
||||||
|
self.stops = 0
|
||||||
|
self.new_version_was_ready = False
|
||||||
|
|
||||||
|
def settings(self):
|
||||||
|
return {"binary": ""}
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.stops += 1
|
||||||
|
self.new_version_was_ready = any(
|
||||||
|
(ggml.BIN_DIR / "whisper").glob("*.new"))
|
||||||
|
|
||||||
|
def test_the_running_server_is_stopped_only_for_the_swap(self):
|
||||||
|
# The outage is the swap, not the transfer: the server keeps answering
|
||||||
|
# through a download that can take minutes, and is stopped only once
|
||||||
|
# the replacement is unpacked next door and known to be whole.
|
||||||
|
self.install("whisper-bin-ubuntu-x64.tar.gz")
|
||||||
|
server = self.StubServer()
|
||||||
|
with mock.patch.object(ggml, "SERVERS", (server,)):
|
||||||
|
self.install("whisper-bin-ubuntu-x64.tar.gz")
|
||||||
|
self.assertEqual(server.stops, 1)
|
||||||
|
self.assertTrue(server.new_version_was_ready)
|
||||||
|
|
||||||
|
def test_a_download_that_fails_never_stops_the_server(self):
|
||||||
|
self.install("whisper-bin-ubuntu-x64.tar.gz")
|
||||||
|
server = self.StubServer()
|
||||||
|
listing = self.release("whisper-bin-ubuntu-x64.tar.gz")
|
||||||
|
with mock.patch.object(ggml, "SERVERS", (server,)):
|
||||||
|
with serving(listing, self.archive):
|
||||||
|
with mock.patch.object(ggml, "download",
|
||||||
|
side_effect=ggml.LocalError("no route")):
|
||||||
|
with self.assertRaises(ggml.LocalError):
|
||||||
|
ggml.install_program(ggml.WHISPER)
|
||||||
|
self.assertEqual(server.stops, 0)
|
||||||
|
|
||||||
|
|
||||||
def test_a_release_without_a_published_checksum_is_refused(self):
|
def test_a_release_without_a_published_checksum_is_refused(self):
|
||||||
# GitHub did not always publish one, and whisper.cpp v1.8.0 and older
|
# GitHub did not always publish one, and whisper.cpp v1.8.0 and older
|
||||||
@@ -456,12 +536,14 @@ STAND_IN = textwrap.dedent("""
|
|||||||
def opt(name, default=""):
|
def opt(name, default=""):
|
||||||
return args[args.index(name) + 1] if name in args else default
|
return args[args.index(name) + 1] if name in args else default
|
||||||
|
|
||||||
|
time.sleep(float(opt("--wait", "0")))
|
||||||
|
|
||||||
|
# After the sleep, so that --wait plus --die is a program that runs for a
|
||||||
|
# while and then crashes, the way a bad model dies mid-load.
|
||||||
if "--die" in args:
|
if "--die" in args:
|
||||||
print("could not load model: no such file")
|
print("could not load model: no such file")
|
||||||
sys.exit(2)
|
sys.exit(2)
|
||||||
|
|
||||||
time.sleep(float(opt("--wait", "0")))
|
|
||||||
|
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
healthy_after = float(opt("--healthy-after", "0"))
|
healthy_after = float(opt("--healthy-after", "0"))
|
||||||
|
|
||||||
@@ -539,6 +621,14 @@ class Servers(Local):
|
|||||||
self.assertTrue(server.running)
|
self.assertTrue(server.running)
|
||||||
self.assertTrue(second)
|
self.assertTrue(second)
|
||||||
|
|
||||||
|
def count_ports(self):
|
||||||
|
"""Record every port handed to a launch, one per attempt."""
|
||||||
|
ports = []
|
||||||
|
real = ggml._free_port
|
||||||
|
self.patch_attr(ggml, "_free_port",
|
||||||
|
lambda: ports.append(real()) or ports[-1])
|
||||||
|
return ports
|
||||||
|
|
||||||
def test_a_program_that_dies_reports_what_it_printed(self):
|
def test_a_program_that_dies_reports_what_it_printed(self):
|
||||||
server = self.server(extra=["--die"])
|
server = self.server(extra=["--die"])
|
||||||
with self.assertRaises(ggml.LocalError) as caught:
|
with self.assertRaises(ggml.LocalError) as caught:
|
||||||
@@ -546,6 +636,46 @@ class Servers(Local):
|
|||||||
self.assertIn("no such file", str(caught.exception))
|
self.assertIn("no such file", str(caught.exception))
|
||||||
self.assertFalse(server.running)
|
self.assertFalse(server.running)
|
||||||
|
|
||||||
|
def test_an_early_death_that_never_listened_is_retried(self):
|
||||||
|
# A child that loses the bind race fails and exits at once, and which
|
||||||
|
# port was lost cannot be told from the log: the shape of the failure,
|
||||||
|
# not its wording, is what earns another port.
|
||||||
|
ports = self.count_ports()
|
||||||
|
server = self.server(extra=["--die"])
|
||||||
|
with self.assertRaises(ggml.LocalError):
|
||||||
|
server.serve()
|
||||||
|
self.assertEqual(len(ports), 3)
|
||||||
|
|
||||||
|
def test_a_late_crash_is_not_retried(self):
|
||||||
|
# A program that ran for a while before dying was not a bind race: it
|
||||||
|
# would die the same way on any port.
|
||||||
|
self.patch_attr(ggml, "EARLY_EXIT_WINDOW", 0.2)
|
||||||
|
ports = self.count_ports()
|
||||||
|
server = self.server(extra=["--wait", "0.5", "--die"])
|
||||||
|
with self.assertRaises(ggml.LocalError) as caught:
|
||||||
|
server.serve()
|
||||||
|
self.assertEqual(len(ports), 1)
|
||||||
|
self.assertIn("no such file", str(caught.exception))
|
||||||
|
|
||||||
|
def test_an_open_port_with_a_dead_child_is_not_ready(self):
|
||||||
|
# Another process winning the bind race leaves the port open while our
|
||||||
|
# child exits: the open port alone must not be read as ready.
|
||||||
|
polls = iter([None, 2])
|
||||||
|
proc = mock.Mock()
|
||||||
|
proc.poll = lambda: next(polls)
|
||||||
|
self.patch_attr(ggml, "_listening", lambda port: True)
|
||||||
|
reason, listened = self.server()._wait_ready(proc, 1)
|
||||||
|
self.assertEqual(reason, "exited")
|
||||||
|
self.assertFalse(listened)
|
||||||
|
|
||||||
|
def test_an_open_port_with_a_child_that_outlives_it_a_beat_is_ready(self):
|
||||||
|
proc = mock.Mock()
|
||||||
|
proc.poll = lambda: None
|
||||||
|
self.patch_attr(ggml, "_listening", lambda port: True)
|
||||||
|
reason, listened = self.server()._wait_ready(proc, 1)
|
||||||
|
self.assertEqual(reason, "ready")
|
||||||
|
self.assertTrue(listened)
|
||||||
|
|
||||||
def test_a_model_that_is_still_loading_is_not_ready_yet(self):
|
def test_a_model_that_is_still_loading_is_not_ready_yet(self):
|
||||||
# llama binds its port first and answers /health with 503 until the
|
# llama binds its port first and answers /health with 503 until the
|
||||||
# model is in memory, so the open port on its own is not the signal.
|
# model is in memory, so the open port on its own is not the signal.
|
||||||
@@ -614,6 +744,72 @@ class Servers(Local):
|
|||||||
def test_no_pid_file_is_nothing_to_sweep(self):
|
def test_no_pid_file_is_nothing_to_sweep(self):
|
||||||
self.assertFalse(self.server().sweep())
|
self.assertFalse(self.server().sweep())
|
||||||
|
|
||||||
|
def test_an_unverifiable_pid_is_kept_for_a_later_sweep(self):
|
||||||
|
# Could not be checked is not the same as known stale: dropping the
|
||||||
|
# file here would lose track of a server that may still hold a model.
|
||||||
|
server = self.server()
|
||||||
|
server._remember(4242)
|
||||||
|
with mock.patch.object(server, "_is_ours", return_value=None):
|
||||||
|
self.assertFalse(server.sweep())
|
||||||
|
self.assertTrue(server._pid_file().exists())
|
||||||
|
|
||||||
|
def test_a_pid_known_stale_is_forgotten(self):
|
||||||
|
server = self.server()
|
||||||
|
server._remember(4242)
|
||||||
|
with mock.patch.object(server, "_is_ours", return_value=False):
|
||||||
|
self.assertFalse(server.sweep())
|
||||||
|
self.assertFalse(server._pid_file().exists())
|
||||||
|
|
||||||
|
def test_the_pid_file_goes_once_the_kill_was_attempted(self):
|
||||||
|
server = self.server()
|
||||||
|
server._remember(4242)
|
||||||
|
with mock.patch.object(server, "_is_ours", return_value=True):
|
||||||
|
with mock.patch.object(ggml.os, "kill") as kill:
|
||||||
|
self.assertTrue(server.sweep())
|
||||||
|
kill.assert_called_once_with(4242, signal.SIGTERM)
|
||||||
|
self.assertFalse(server._pid_file().exists())
|
||||||
|
|
||||||
|
def test_forget_leaves_a_pid_file_that_is_no_longer_ours(self):
|
||||||
|
server = self.server()
|
||||||
|
server._remember(111)
|
||||||
|
# A Dikte started after us wrote its own server's pid over ours;
|
||||||
|
# removing the file would hide that server from every future sweep.
|
||||||
|
server._pid_file().write_text("222")
|
||||||
|
server._forget()
|
||||||
|
self.assertEqual(server._pid_file().read_text(), "222")
|
||||||
|
|
||||||
|
def test_forget_removes_the_pid_it_remembered(self):
|
||||||
|
server = self.server()
|
||||||
|
server._remember(111)
|
||||||
|
server._forget()
|
||||||
|
self.assertFalse(server._pid_file().exists())
|
||||||
|
|
||||||
|
def test_stop_waits_out_a_start_in_flight_and_kills_it(self):
|
||||||
|
# stop_all on quit must not slide past a launch that is mid-load: the
|
||||||
|
# child would then survive Dikte with nothing left that knows its pid.
|
||||||
|
children = []
|
||||||
|
real = ggml.subprocess.Popen
|
||||||
|
|
||||||
|
def popen(*args, **kwargs):
|
||||||
|
proc = real(*args, **kwargs)
|
||||||
|
children.append(proc)
|
||||||
|
return proc
|
||||||
|
|
||||||
|
self.patch_attr(ggml.subprocess, "Popen", popen)
|
||||||
|
server = self.server(extra=["--wait", "0.5"])
|
||||||
|
thread = threading.Thread(target=server.serve)
|
||||||
|
thread.start()
|
||||||
|
try:
|
||||||
|
deadline = time.monotonic() + 10
|
||||||
|
while not children and time.monotonic() < deadline:
|
||||||
|
time.sleep(0.01) # until the launch is truly in flight
|
||||||
|
server.stop()
|
||||||
|
finally:
|
||||||
|
thread.join(timeout=10)
|
||||||
|
self.assertEqual(len(children), 1)
|
||||||
|
self.assertIsNotNone(children[0].poll())
|
||||||
|
self.assertFalse(server.running)
|
||||||
|
|
||||||
def test_a_start_that_goes_wrong_takes_its_process_with_it(self):
|
def test_a_start_that_goes_wrong_takes_its_process_with_it(self):
|
||||||
started = []
|
started = []
|
||||||
|
|
||||||
@@ -752,6 +948,10 @@ class InstallOnWindows(Local):
|
|||||||
super().setUp()
|
super().setUp()
|
||||||
self.patch_attr(sys, "platform", "win32")
|
self.patch_attr(sys, "platform", "win32")
|
||||||
self.patch_attr(ggml, "_arch", lambda: "x64")
|
self.patch_attr(ggml, "_arch", lambda: "x64")
|
||||||
|
# shutil.which cannot be allowed through to the real one: standing on
|
||||||
|
# win32 from another system, Python 3.12's Windows branch of which()
|
||||||
|
# reaches for the nt module that is not there.
|
||||||
|
self.enterContext(mock.patch("shutil.which", return_value=None))
|
||||||
self.archive = zipball({
|
self.archive = zipball({
|
||||||
"Release/whisper-server.exe": b"MZ not really a program",
|
"Release/whisper-server.exe": b"MZ not really a program",
|
||||||
"Release/whisper.dll": b"not really a library",
|
"Release/whisper.dll": b"not really a library",
|
||||||
@@ -785,3 +985,43 @@ class InstallOnWindows(Local):
|
|||||||
with self.assertRaises(ggml.LocalError) as caught:
|
with self.assertRaises(ggml.LocalError) as caught:
|
||||||
ggml.install_program(ggml.WHISPER)
|
ggml.install_program(ggml.WHISPER)
|
||||||
self.assertIn("this machine", str(caught.exception))
|
self.assertIn("this machine", str(caught.exception))
|
||||||
|
|
||||||
|
|
||||||
|
class WindowsOwnership(Local):
|
||||||
|
"""Sweeping on Windows goes by the executable's full path, never its name:
|
||||||
|
the base name alone is anyone's whisper-server.exe, and this answer decides
|
||||||
|
what gets killed."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.patch_attr(sys, "platform", "win32")
|
||||||
|
# See InstallOnWindows: the real which() on win32 wants the nt module.
|
||||||
|
self.enterContext(mock.patch("shutil.which", return_value=None))
|
||||||
|
self.made = ggml.Server(ggml.WHISPER, lambda values: [], {"binary": ""})
|
||||||
|
|
||||||
|
def image(self, path):
|
||||||
|
self.patch_attr(ggml, "_win_image_name", lambda pid: path)
|
||||||
|
|
||||||
|
def test_a_binary_under_our_bin_directory_is_ours(self):
|
||||||
|
self.image(str(ggml.BIN_DIR / "whisper" / "v1.9.1" / "whisper-server.exe"))
|
||||||
|
self.assertIs(self.made._is_ours(1234), True)
|
||||||
|
|
||||||
|
def test_the_configured_binary_is_ours_wherever_it_lives(self):
|
||||||
|
mine = self.path("elsewhere", "whisper-server.exe")
|
||||||
|
mine.parent.mkdir(parents=True)
|
||||||
|
mine.write_bytes(b"MZ")
|
||||||
|
mine.chmod(0o755)
|
||||||
|
self.made._settings["binary"] = str(mine)
|
||||||
|
self.image(str(mine.resolve()))
|
||||||
|
self.assertIs(self.made._is_ours(1234), True)
|
||||||
|
|
||||||
|
def test_the_same_name_somewhere_else_is_not_ours(self):
|
||||||
|
self.image(str(self.path("theirs", "whisper-server.exe")))
|
||||||
|
with mock.patch("shutil.which", return_value=None):
|
||||||
|
self.assertIs(self.made._is_ours(1234), False)
|
||||||
|
|
||||||
|
def test_a_process_that_cannot_be_read_is_no_verdict(self):
|
||||||
|
# OpenProcess answering nothing covers both "gone" and "not readable
|
||||||
|
# from here", and only one of those makes the pid file safe to drop.
|
||||||
|
self.image("")
|
||||||
|
self.assertIsNone(self.made._is_ours(1234))
|
||||||
|
|||||||
Reference in New Issue
Block a user