mirror of
https://github.com/baketnk/frame-yap.git
synced 2026-10-04 22:00:03 +02:00
Add bounded dictation state and persistent offline Redux worker
This commit is contained in:
1 parent
f79db54ca9
commit
0cb2991f02
13 files changed
+1132
No files matched your search
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 FrameYap contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1 @@
|
||||
"""Standalone FrameYap offline CPU worker (no model imports on package import)."""
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Pinned public moondream/parakeet-redux artifacts; weights are never in Git."""
|
||||
REVISION = "fad622f25f303105c20d70e201bcc477c88b620c"
|
||||
FILES = {
|
||||
"model.safetensors": (177774490, "78ec25733ee0d0c1586d1346fc86db9d0c2e436e3a8ab1d32a82d1bb8f848d21"),
|
||||
"config.json": (12988, "503c653b2e3bb788adbcb04f5abdee532d958686564081baeed133ff10143f6e"),
|
||||
"ternary.json": (57970, "1221c6d3ce901ffe09c089da758a8db8b76189f80cff41c5afc244fc61e2051d"),
|
||||
"tokenizer.json": (1159960, "bd321b096832a3f270bd3b2a88823957920f1a5c5ada71114a26ea729d0cbe91"),
|
||||
"README.md": (8533, "a8b327f983a8b8ff262ff7bead3a791fbed9350632002af8db85ab5cd84cdaa5"),
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Persistent local Redux worker. No imports of model dependencies until explicit start.
|
||||
|
||||
Wire protocol: unsigned LE32 payload length (max 65536), type byte, unsigned
|
||||
LE64 request id for T/R/E; Y (ready) and F (load failure) have no id. T refers
|
||||
to the fixed clip.raw in the private directory. R/E carry <=4096 UTF-8 bytes.
|
||||
Only one T may be outstanding. No stdout other than frames; stderr is suppressed
|
||||
by the native launcher. This module never captures audio or delivers input.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import struct
|
||||
import sys
|
||||
|
||||
MAX_FRAME = 65536
|
||||
MAX_TEXT = 4096
|
||||
MIN_SAMPLES = 3200
|
||||
MAX_SAMPLES = 320000
|
||||
try:
|
||||
from .model_files import REVISION, FILES
|
||||
except ImportError: # direct executable script
|
||||
from model_files import REVISION, FILES
|
||||
|
||||
|
||||
def read_exact(fd, count):
|
||||
parts = bytearray()
|
||||
while len(parts) < count:
|
||||
part = os.read(fd, count - len(parts))
|
||||
if not part:
|
||||
if not parts:
|
||||
return None
|
||||
raise ValueError("truncated worker frame")
|
||||
parts.extend(part)
|
||||
return bytes(parts)
|
||||
|
||||
|
||||
def read_frame(fd):
|
||||
header = read_exact(fd, 4)
|
||||
if header is None:
|
||||
return None
|
||||
size, = struct.unpack("<I", header)
|
||||
if not 1 <= size <= MAX_FRAME:
|
||||
raise ValueError("invalid frame length")
|
||||
payload = read_exact(fd, size)
|
||||
if payload is None:
|
||||
raise ValueError("truncated frame")
|
||||
return payload
|
||||
|
||||
|
||||
def send_frame(fd, kind, data=b""):
|
||||
payload = kind + data
|
||||
if len(payload) > MAX_FRAME:
|
||||
raise ValueError("oversize response")
|
||||
frame = struct.pack("<I", len(payload)) + payload
|
||||
while frame:
|
||||
written = os.write(fd, frame)
|
||||
if written <= 0:
|
||||
raise OSError("closed worker pipe")
|
||||
frame = frame[written:]
|
||||
|
||||
|
||||
def private_dir(path):
|
||||
st = os.lstat(path)
|
||||
if not stat.S_ISDIR(st.st_mode) or st.st_uid != os.geteuid() or st.st_mode & 0o077:
|
||||
raise ValueError("clip directory must be private and owned by current user")
|
||||
|
||||
|
||||
def local_model(path):
|
||||
"""Require a real local directory with weight file(s), never a hub identifier."""
|
||||
root = Path(path)
|
||||
if not root.is_absolute() or not root.is_dir() or root.is_symlink():
|
||||
raise ValueError("absolute local model directory required")
|
||||
for name, (size, expected) in FILES.items():
|
||||
file = root / name
|
||||
if not file.is_file() or file.is_symlink() or file.stat().st_size != size:
|
||||
raise ValueError("pinned local Redux model weights missing or incomplete")
|
||||
digest = hashlib.sha256()
|
||||
with file.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
if digest.hexdigest() != expected:
|
||||
raise ValueError("local Redux model does not match pinned revision")
|
||||
return str(root)
|
||||
|
||||
|
||||
def load_model(path, threads):
|
||||
# Set before importing moondream/torch/huggingface dependencies, even when
|
||||
# invoked directly without the native adapter.
|
||||
os.environ["HF_HUB_OFFLINE"] = "1"
|
||||
os.environ["TRANSFORMERS_OFFLINE"] = "1"
|
||||
os.environ["HF_DATASETS_OFFLINE"] = "1"
|
||||
for key in ("OMP_NUM_THREADS", "MKL_NUM_THREADS", "OPENBLAS_NUM_THREADS"):
|
||||
os.environ[key] = str(threads)
|
||||
os.environ["CUDA_VISIBLE_DEVICES"] = ""
|
||||
os.environ["TOKENIZERS_PARALLELISM"] = "false"
|
||||
directory = local_model(path)
|
||||
import torch
|
||||
torch.set_num_threads(threads)
|
||||
torch.set_num_interop_threads(1)
|
||||
import moondream as md # noqa: explicit lazy import
|
||||
return md.photon("moondream/parakeet-redux", model_path=directory,
|
||||
device="cpu", cpu_threads=threads)
|
||||
|
||||
|
||||
def read_clip(directory):
|
||||
# No symlinks; the parent wrote an exclusive, mode-0600 file. Keep private
|
||||
# audio out of logs and do not follow a swapped filename.
|
||||
fd = os.open(os.path.join(directory, "clip.raw"), os.O_RDONLY | os.O_NOFOLLOW)
|
||||
try:
|
||||
st = os.fstat(fd)
|
||||
if not stat.S_ISREG(st.st_mode) or st.st_uid != os.geteuid() or st.st_mode & 0o077:
|
||||
raise ValueError("unsafe clip")
|
||||
if not MIN_SAMPLES * 4 <= st.st_size <= MAX_SAMPLES * 4 or st.st_size % 4:
|
||||
raise ValueError("invalid clip length")
|
||||
raw = read_exact(fd, st.st_size)
|
||||
if raw is None or os.read(fd, 1):
|
||||
raise ValueError("clip changed during read")
|
||||
finally:
|
||||
os.close(fd)
|
||||
import numpy as np # deferred until the explicit request
|
||||
pcm = np.frombuffer(raw, dtype="<f4")
|
||||
if not np.isfinite(pcm).all():
|
||||
raise ValueError("invalid PCM")
|
||||
return pcm
|
||||
|
||||
|
||||
def run(model, directory, input_fd=0, output_fd=1):
|
||||
private_dir(directory)
|
||||
send_frame(output_fd, b"Y")
|
||||
while True:
|
||||
frame = read_frame(input_fd)
|
||||
if frame is None:
|
||||
return
|
||||
if len(frame) != 9 or frame[0:1] != b"T":
|
||||
raise ValueError("invalid request")
|
||||
request_id = frame[1:9]
|
||||
try:
|
||||
result = model.transcribe(audio=read_clip(directory), sample_rate=16000)
|
||||
text = result["text"]
|
||||
if not isinstance(text, str):
|
||||
raise ValueError("invalid model response")
|
||||
encoded = text.encode("utf-8", errors="strict")
|
||||
if len(encoded) > MAX_TEXT:
|
||||
raise ValueError("transcript exceeds 4096 bytes")
|
||||
send_frame(output_fd, b"R", request_id + encoded)
|
||||
except Exception:
|
||||
# Model exceptions may include audio or transcripts. Do not log or
|
||||
# forward them to the overlay; request-local failure only.
|
||||
send_frame(output_fd, b"E", request_id + b"transcription failed")
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description="FrameYap local offline Redux worker")
|
||||
parser.add_argument("--model", required=True)
|
||||
parser.add_argument("--threads", type=int, default=2)
|
||||
parser.add_argument("--clip-dir", required=True)
|
||||
args = parser.parse_args(argv)
|
||||
if not 1 <= args.threads <= 64:
|
||||
parser.error("threads must be 1..64")
|
||||
# Native libraries sometimes print directly to fd 1. Keep those bytes out of
|
||||
# the framed channel, not merely Python's sys.stdout wrapper.
|
||||
protocol_fd = os.dup(1)
|
||||
os.set_inheritable(protocol_fd, False)
|
||||
with open(os.devnull, "wb") as null:
|
||||
os.dup2(null.fileno(), 1)
|
||||
os.dup2(null.fileno(), 2)
|
||||
try:
|
||||
try:
|
||||
private_dir(args.clip_dir)
|
||||
# The full hash validation occurs exactly once in load_model.
|
||||
if not Path(args.model).is_dir():
|
||||
raise ValueError("missing model")
|
||||
except Exception:
|
||||
send_frame(protocol_fd, b"F", b"M")
|
||||
return 1
|
||||
try:
|
||||
model = load_model(args.model, args.threads)
|
||||
except Exception:
|
||||
send_frame(protocol_fd, b"F", b"D")
|
||||
return 1
|
||||
run(model, args.clip_dir, output_fd=protocol_fd)
|
||||
return 0
|
||||
finally:
|
||||
os.close(protocol_fd)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Opt-in local CPU/IPC benchmark of a supplied nonprivate WAV. Never captures or types."""
|
||||
import argparse
|
||||
import array
|
||||
import os
|
||||
from pathlib import Path
|
||||
import select
|
||||
import statistics
|
||||
import struct
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import wave
|
||||
|
||||
|
||||
def receive(fd, timeout):
|
||||
end = time.monotonic() + timeout
|
||||
def exact(count):
|
||||
data = bytearray()
|
||||
while len(data) < count:
|
||||
remaining = end - time.monotonic()
|
||||
if remaining <= 0 or not select.select([fd], [], [], remaining)[0]:
|
||||
raise TimeoutError("worker deadline")
|
||||
chunk = os.read(fd, count - len(data))
|
||||
if not chunk:
|
||||
raise RuntimeError("worker exited")
|
||||
data.extend(chunk)
|
||||
return bytes(data)
|
||||
size, = struct.unpack("<I", exact(4))
|
||||
if not 1 <= size <= 65536:
|
||||
raise ValueError("bad frame")
|
||||
return exact(size)
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument("--python", required=True)
|
||||
p.add_argument("--worker", type=Path, required=True)
|
||||
p.add_argument("--model", type=Path, required=True)
|
||||
p.add_argument("--wav", type=Path, required=True, help="nonprivate PCM16 mono 16kHz WAV, <=20s")
|
||||
p.add_argument("--threads", type=int, choices=[2, 4], default=2)
|
||||
p.add_argument("--repeats", type=int, default=5)
|
||||
p.add_argument("--show-text", action="store_true", help="explicitly print this nonprivate fixture's transcript")
|
||||
args = p.parse_args()
|
||||
if not 1 <= args.repeats <= 20:
|
||||
p.error("repeats must be 1..20")
|
||||
with wave.open(str(args.wav), "rb") as wav:
|
||||
if (wav.getnchannels(), wav.getsampwidth(), wav.getframerate()) != (1, 2, 16000) or not 3200 <= wav.getnframes() <= 320000:
|
||||
p.error("expected 0.2..20s mono PCM16 at 16kHz")
|
||||
raw = wav.readframes(wav.getnframes())
|
||||
pcm = array.array("f", (x[0] / 32768.0 for x in struct.iter_unpack("<h", raw)))
|
||||
if __import__("sys").byteorder != "little":
|
||||
pcm.byteswap()
|
||||
runtime = os.environ.get("XDG_RUNTIME_DIR")
|
||||
if not runtime:
|
||||
p.error("XDG_RUNTIME_DIR required")
|
||||
with tempfile.TemporaryDirectory(prefix="frameyap-benchmark-", dir=runtime) as directory:
|
||||
start = time.monotonic()
|
||||
child = subprocess.Popen([args.python, str(args.worker.absolute()), "--model", str(args.model.absolute()),
|
||||
"--threads", str(args.threads), "--clip-dir", directory],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
||||
try:
|
||||
ready = receive(child.stdout.fileno(), 120)
|
||||
if ready != b"Y":
|
||||
raise RuntimeError(f"worker warmup rejected: {ready!r}")
|
||||
print(f"load_seconds={time.monotonic() - start:.3f}", flush=True)
|
||||
times = []
|
||||
for ident in range(1, args.repeats + 1):
|
||||
clip = Path(directory) / "clip.raw"
|
||||
fd = os.open(clip, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
with os.fdopen(fd, "wb") as output:
|
||||
output.write(pcm.tobytes())
|
||||
start = time.monotonic()
|
||||
child.stdin.write(struct.pack("<IcQ", 9, b"T", ident))
|
||||
child.stdin.flush()
|
||||
reply = receive(child.stdout.fileno(), 60)
|
||||
elapsed = time.monotonic() - start
|
||||
if len(reply) < 9 or reply[:1] != b"R" or struct.unpack("<Q", reply[1:9])[0] != ident:
|
||||
raise RuntimeError("worker transcription failed or wrong correlation")
|
||||
text = reply[9:].decode("utf-8")
|
||||
clip.unlink()
|
||||
times.append(elapsed)
|
||||
print(f"request={ident} seconds={elapsed:.3f} bytes={len(reply)-9}", flush=True)
|
||||
if args.show_text and ident == 1:
|
||||
print(f"public_fixture_transcript={text}", flush=True)
|
||||
print(f"threads={args.threads} median_seconds={statistics.median(times):.3f} max_seconds={max(times):.3f}")
|
||||
finally:
|
||||
child.stdin.close()
|
||||
try:
|
||||
child.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
child.terminate()
|
||||
try:
|
||||
child.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
child.kill()
|
||||
child.wait()
|
||||
child.stdout.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Explicit setup only: fetch the pinned ~178 MB Redux weights; never used by runtime."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.request
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "python"))
|
||||
from frameyap.model_files import FILES, REVISION
|
||||
|
||||
|
||||
def matches(path, size, digest):
|
||||
if not path.is_file() or path.is_symlink() or path.stat().st_size != size:
|
||||
return False
|
||||
with path.open("rb") as stream:
|
||||
return hashlib.file_digest(stream, "sha256").hexdigest() == digest
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--destination", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
dest = args.destination.expanduser().absolute()
|
||||
if dest.is_symlink():
|
||||
parser.error("destination must not be a symlink")
|
||||
dest.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
for name, (size, digest) in FILES.items():
|
||||
target = dest / name
|
||||
if matches(target, size, digest):
|
||||
continue
|
||||
if target.exists() or target.is_symlink():
|
||||
parser.error(f"existing mismatched file: {target}; move it aside explicitly")
|
||||
url = f"https://huggingface.co/moondream/parakeet-redux/resolve/{REVISION}/{name}"
|
||||
fd, temp = tempfile.mkstemp(prefix=".download-", dir=dest)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as output, urllib.request.urlopen(url, timeout=60) as source:
|
||||
total = 0
|
||||
while chunk := source.read(1024 * 1024):
|
||||
total += len(chunk)
|
||||
if total > size:
|
||||
raise ValueError("download exceeded pinned size")
|
||||
output.write(chunk)
|
||||
if not matches(Path(temp), size, digest):
|
||||
raise ValueError(f"pinned SHA-256/size mismatch: {name}")
|
||||
os.replace(temp, target)
|
||||
finally:
|
||||
Path(temp).unlink(missing_ok=True)
|
||||
print(f"Pinned Redux {REVISION} verified in {dest}")
|
||||
print("Model attribution: moondream/parakeet-redux, CC-BY-4.0; see downloaded README.md.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,60 @@
|
||||
#include "core.hpp"
|
||||
#include <stdexcept>
|
||||
|
||||
namespace frameyap {
|
||||
std::string literal_text(std::string_view input) {
|
||||
if (input.size() > 4096) throw std::runtime_error("Transcript exceeds 4096 bytes");
|
||||
std::string out;
|
||||
for (std::size_t i = 0; i < input.size();) {
|
||||
const auto start = i;
|
||||
const auto first = static_cast<unsigned char>(input[i++]);
|
||||
uint32_t cp = first;
|
||||
int continuation = 0;
|
||||
uint32_t minimum = 0;
|
||||
if (first >= 0xc2 && first <= 0xdf) { cp = first & 31; continuation = 1; minimum = 0x80; }
|
||||
else if (first >= 0xe0 && first <= 0xef) { cp = first & 15; continuation = 2; minimum = 0x800; }
|
||||
else if (first >= 0xf0 && first <= 0xf4) { cp = first & 7; continuation = 3; minimum = 0x10000; }
|
||||
else if (first >= 0x80) throw std::runtime_error("Invalid UTF-8 transcript");
|
||||
while (continuation--) {
|
||||
if (i == input.size()) throw std::runtime_error("Truncated UTF-8 transcript");
|
||||
auto byte = static_cast<unsigned char>(input[i++]);
|
||||
if ((byte & 0xc0) != 0x80) throw std::runtime_error("Invalid UTF-8 transcript");
|
||||
cp = (cp << 6) | (byte & 63);
|
||||
}
|
||||
if (cp < minimum || cp > 0x10ffff || (cp >= 0xd800 && cp <= 0xdfff))
|
||||
throw std::runtime_error("Invalid UTF-8 codepoint");
|
||||
if (cp == '\n' || cp == '\r' || cp == '\t' || cp == 0x2028 || cp == 0x2029) {
|
||||
out += ' ';
|
||||
} else {
|
||||
if (cp < 32 || (cp >= 0x7f && cp <= 0x9f))
|
||||
throw std::runtime_error("Control characters rejected");
|
||||
out.append(input.substr(start, i - start));
|
||||
}
|
||||
}
|
||||
if (out.find_first_not_of(' ') == std::string::npos) out.clear();
|
||||
return out;
|
||||
}
|
||||
void Session::ready() { if (state_ == State::Warming) state_ = State::Ready; }
|
||||
bool Session::record() {
|
||||
if (state_ != State::Ready && state_ != State::Queued) return false;
|
||||
++id_; text_.clear(); state_ = State::Recording; return true;
|
||||
}
|
||||
bool Session::finish(std::size_t samples) {
|
||||
if (state_ != State::Recording) return false;
|
||||
if (samples < 3200 || samples > 320000) { cancel(); return false; }
|
||||
state_ = State::Transcribing; return true;
|
||||
}
|
||||
bool Session::reply(uint64_t id, std::string_view text) {
|
||||
if (id != id_ || state_ != State::Transcribing) return false;
|
||||
text_ = literal_text(text);
|
||||
state_ = text_.empty() ? State::Ready : State::Review;
|
||||
return true;
|
||||
}
|
||||
std::optional<std::string> Session::take_insert() {
|
||||
if (state_ != State::Review) return std::nullopt;
|
||||
auto text = std::move(text_); text_.clear(); state_ = State::Queued;
|
||||
return text;
|
||||
}
|
||||
void Session::cancel() { ++id_; text_.clear(); state_ = State::Ready; }
|
||||
void Session::fail() { ++id_; text_.clear(); state_ = State::Error; }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace frameyap {
|
||||
// Throws on malformed UTF-8, controls, or >4096 bytes. Never interprets commands.
|
||||
std::string literal_text(std::string_view input);
|
||||
enum class State { Warming, Ready, Recording, Transcribing, Review, Queued, Error };
|
||||
// Hardware-free delivery ledger. A request can be consumed only once, including
|
||||
// ambiguous transport failures. Explicit Insert authorizes CURRENT seat focus.
|
||||
class Session {
|
||||
public:
|
||||
State state() const { return state_; }
|
||||
const std::string& text() const { return text_; }
|
||||
uint64_t id() const { return id_; }
|
||||
void ready();
|
||||
bool record();
|
||||
bool finish(std::size_t samples);
|
||||
bool reply(uint64_t id, std::string_view text);
|
||||
std::optional<std::string> take_insert();
|
||||
void cancel();
|
||||
void fail();
|
||||
private:
|
||||
State state_ = State::Warming;
|
||||
uint64_t id_ = 0;
|
||||
std::string text_;
|
||||
};
|
||||
}
|
||||
+296
@@ -0,0 +1,296 @@
|
||||
#include "worker.hpp"
|
||||
|
||||
#include <array>
|
||||
#include <cerrno>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <stdexcept>
|
||||
#include <string_view>
|
||||
#include <system_error>
|
||||
#include <utility>
|
||||
#include <fcntl.h>
|
||||
#include <pthread.h>
|
||||
#include <signal.h>
|
||||
#include <spawn.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
|
||||
extern char** environ;
|
||||
|
||||
namespace frameyap {
|
||||
namespace {
|
||||
using Clock = std::chrono::steady_clock;
|
||||
constexpr size_t max_frame = 65536;
|
||||
constexpr size_t max_text = 4096;
|
||||
|
||||
void close_fd(int& fd) { if (fd >= 0) { ::close(fd); fd = -1; } }
|
||||
void put32(unsigned char* p, uint32_t n) {
|
||||
for (int i = 0; i < 4; ++i) p[i] = static_cast<unsigned char>(n >> (8 * i));
|
||||
}
|
||||
void put64(unsigned char* p, uint64_t n) {
|
||||
for (int i = 0; i < 8; ++i) p[i] = static_cast<unsigned char>(n >> (8 * i));
|
||||
}
|
||||
uint32_t get32(const unsigned char* p) {
|
||||
uint32_t n = 0;
|
||||
for (int i = 0; i < 4; ++i) n |= uint32_t(p[i]) << (8 * i);
|
||||
return n;
|
||||
}
|
||||
uint64_t get64(const unsigned char* p) {
|
||||
uint64_t n = 0;
|
||||
for (int i = 0; i < 8; ++i) n |= uint64_t(p[i]) << (8 * i);
|
||||
return n;
|
||||
}
|
||||
bool valid_utf8(std::string_view text) {
|
||||
for (size_t i = 0; i < text.size();) {
|
||||
unsigned char first = static_cast<unsigned char>(text[i]);
|
||||
if (first == 0) return false;
|
||||
if (first < 0x80) { ++i; continue; }
|
||||
unsigned count = first >= 0xc2 && first <= 0xdf ? 2 :
|
||||
first >= 0xe0 && first <= 0xef ? 3 :
|
||||
first >= 0xf0 && first <= 0xf4 ? 4 : 0;
|
||||
if (!count || i + count > text.size()) return false;
|
||||
for (unsigned j = 1; j < count; ++j)
|
||||
if ((static_cast<unsigned char>(text[i + j]) & 0xc0) != 0x80) return false;
|
||||
unsigned char second = static_cast<unsigned char>(text[i + 1]);
|
||||
if ((first == 0xe0 && second < 0xa0) || (first == 0xed && second >= 0xa0) ||
|
||||
(first == 0xf0 && second < 0x90) || (first == 0xf4 && second >= 0x90)) return false;
|
||||
i += count;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
void check_runtime(const char* path) {
|
||||
if (!path || path[0] != '/') throw std::runtime_error("XDG_RUNTIME_DIR must be an absolute private directory");
|
||||
struct stat st{};
|
||||
if (::lstat(path, &st) || !S_ISDIR(st.st_mode) || st.st_uid != ::geteuid() || (st.st_mode & 0077))
|
||||
throw std::runtime_error("XDG_RUNTIME_DIR must be owned by this user and inaccessible to others (no symlinks)");
|
||||
}
|
||||
// Blocking SIGPIPE only on this thread avoids changing the host application's signal disposition.
|
||||
ssize_t safe_write(int fd, const void* data, size_t size) {
|
||||
sigset_t mask{}, prior{}, pending{};
|
||||
sigemptyset(&mask); sigaddset(&mask, SIGPIPE);
|
||||
if (pthread_sigmask(SIG_BLOCK, &mask, &prior)) throw std::runtime_error("cannot block SIGPIPE");
|
||||
sigpending(&pending);
|
||||
bool was_pending = sigismember(&pending, SIGPIPE);
|
||||
ssize_t result = ::write(fd, data, size);
|
||||
int saved = errno;
|
||||
if (result < 0 && saved == EPIPE && !was_pending) {
|
||||
timespec zero{};
|
||||
::sigtimedwait(&mask, nullptr, &zero);
|
||||
}
|
||||
pthread_sigmask(SIG_SETMASK, &prior, nullptr);
|
||||
errno = saved;
|
||||
return result;
|
||||
}
|
||||
void write_all(int fd, const unsigned char* data, size_t size) {
|
||||
while (size) {
|
||||
ssize_t n = safe_write(fd, data, size);
|
||||
if (n < 0 && errno == EINTR) continue;
|
||||
if (n <= 0) throw std::runtime_error("worker pipe write failed");
|
||||
data += n; size -= static_cast<size_t>(n);
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
struct Worker::State {
|
||||
pid_t pid = -1;
|
||||
int to_child = -1, from_child = -1;
|
||||
std::string dir;
|
||||
bool loaded = false;
|
||||
std::optional<uint64_t> pending;
|
||||
Clock::time_point deadline{};
|
||||
std::vector<unsigned char> input;
|
||||
std::chrono::milliseconds warmup_timeout{120000}, request_timeout{60000};
|
||||
};
|
||||
|
||||
Worker::Worker(std::chrono::milliseconds warmup, std::chrono::milliseconds request)
|
||||
: state_(std::make_unique<State>()) {
|
||||
if (warmup.count() <= 0 || request.count() <= 0) throw std::invalid_argument("positive worker deadlines required");
|
||||
state_->warmup_timeout = warmup;
|
||||
state_->request_timeout = request;
|
||||
}
|
||||
Worker::~Worker() { stop(); }
|
||||
|
||||
void Worker::stop() {
|
||||
auto& s = *state_;
|
||||
close_fd(s.to_child);
|
||||
close_fd(s.from_child);
|
||||
if (s.pid > 0) {
|
||||
::kill(s.pid, SIGTERM); // Only our direct child, never a process group.
|
||||
auto until = Clock::now() + std::chrono::milliseconds(500);
|
||||
int status = 0;
|
||||
while (::waitpid(s.pid, &status, WNOHANG) == 0 && Clock::now() < until)
|
||||
::usleep(10000);
|
||||
if (::waitpid(s.pid, &status, WNOHANG) == 0) {
|
||||
::kill(s.pid, SIGKILL);
|
||||
while (::waitpid(s.pid, &status, 0) < 0 && errno == EINTR) {}
|
||||
}
|
||||
s.pid = -1;
|
||||
}
|
||||
if (!s.dir.empty()) {
|
||||
::unlink((s.dir + "/clip.raw").c_str());
|
||||
::rmdir(s.dir.c_str());
|
||||
s.dir.clear();
|
||||
}
|
||||
s.input.clear(); s.pending.reset(); s.loaded = false;
|
||||
}
|
||||
|
||||
void Worker::start(const std::string& python, const std::string& script,
|
||||
const std::string& model, int threads) {
|
||||
if (state_->pid > 0) throw std::logic_error("worker already started");
|
||||
if (python.empty() || script.empty() || model.empty() || threads < 1 || threads > 64)
|
||||
throw std::invalid_argument("python, script, local model and 1..64 threads required");
|
||||
check_runtime(::getenv("XDG_RUNTIME_DIR"));
|
||||
try {
|
||||
// The model's internal files are checked by the child before loading, never fetched.
|
||||
std::string pattern = std::string(::getenv("XDG_RUNTIME_DIR")) + "/frameyap-XXXXXX";
|
||||
std::vector<char> tmp(pattern.begin(), pattern.end()); tmp.push_back('\0');
|
||||
if (!::mkdtemp(tmp.data())) throw std::runtime_error("cannot create private clip directory");
|
||||
state_->dir = tmp.data();
|
||||
::chmod(state_->dir.c_str(), 0700);
|
||||
int in[2]{-1,-1}, out[2]{-1,-1};
|
||||
if (::pipe2(in, O_CLOEXEC) || ::pipe2(out, O_CLOEXEC)) {
|
||||
close_fd(in[0]); close_fd(in[1]); close_fd(out[0]); close_fd(out[1]);
|
||||
throw std::runtime_error("cannot create worker pipes");
|
||||
}
|
||||
std::string thread_arg = std::to_string(threads);
|
||||
// OpenVR/SDL already have threads. posix_spawn avoids allocations and
|
||||
// setenv in a forked child of a multithreaded process.
|
||||
std::vector<std::string> environment;
|
||||
for (char** e = environ; *e; ++e) {
|
||||
std::string_view entry(*e);
|
||||
if (entry.starts_with("PYTHONDONTWRITEBYTECODE=")) continue;
|
||||
environment.emplace_back(*e);
|
||||
}
|
||||
environment.emplace_back("PYTHONDONTWRITEBYTECODE=1");
|
||||
std::vector<char*> envp;
|
||||
for (auto& value : environment) envp.push_back(value.data());
|
||||
envp.push_back(nullptr);
|
||||
const char* args[] = {python.c_str(), script.c_str(), "--model", model.c_str(),
|
||||
"--threads", thread_arg.c_str(), "--clip-dir", state_->dir.c_str(), nullptr};
|
||||
posix_spawn_file_actions_t actions;
|
||||
int error = posix_spawn_file_actions_init(&actions);
|
||||
if (error) {
|
||||
close_fd(in[0]); close_fd(in[1]); close_fd(out[0]); close_fd(out[1]);
|
||||
throw std::runtime_error("cannot initialize worker spawn");
|
||||
}
|
||||
error = posix_spawn_file_actions_adddup2(&actions, in[0], STDIN_FILENO);
|
||||
if (!error) error = posix_spawn_file_actions_adddup2(&actions, out[1], STDOUT_FILENO);
|
||||
if (!error) error = posix_spawn_file_actions_addopen(&actions, STDERR_FILENO, "/dev/null", O_WRONLY, 0);
|
||||
for (int fd : std::array<int, 4>{in[0], in[1], out[0], out[1]})
|
||||
if (!error && fd > STDERR_FILENO) error = posix_spawn_file_actions_addclose(&actions, fd);
|
||||
pid_t pid = -1;
|
||||
if (!error) error = posix_spawnp(&pid, python.c_str(), &actions, nullptr,
|
||||
const_cast<char* const*>(args), envp.data());
|
||||
posix_spawn_file_actions_destroy(&actions);
|
||||
if (error) {
|
||||
close_fd(in[0]); close_fd(in[1]); close_fd(out[0]); close_fd(out[1]);
|
||||
throw std::runtime_error("cannot launch configured Python worker");
|
||||
}
|
||||
close_fd(in[0]); close_fd(out[1]);
|
||||
state_->pid = pid; state_->to_child = in[1]; state_->from_child = out[0];
|
||||
int flags = ::fcntl(state_->from_child, F_GETFL);
|
||||
if (flags < 0 || ::fcntl(state_->from_child, F_SETFL, flags | O_NONBLOCK))
|
||||
throw std::runtime_error("cannot set nonblocking worker pipe");
|
||||
state_->deadline = Clock::now() + state_->warmup_timeout;
|
||||
} catch (...) { stop(); throw; }
|
||||
}
|
||||
|
||||
bool Worker::ready() const { return state_->pid > 0 && state_->loaded; }
|
||||
|
||||
void Worker::submit(uint64_t id, const std::vector<float>& pcm) {
|
||||
auto& s = *state_;
|
||||
if (!ready() || s.pending) throw std::logic_error("worker not ready or request already pending");
|
||||
if (pcm.size() < 3200 || pcm.size() > 320000) throw std::invalid_argument("clip must be 0.2..20 seconds at 16 kHz");
|
||||
for (float value : pcm) if (!std::isfinite(value)) throw std::invalid_argument("nonfinite PCM sample");
|
||||
const std::string path = s.dir + "/clip.raw";
|
||||
int fd = ::open(path.c_str(), O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0600);
|
||||
if (fd < 0) throw std::runtime_error("cannot create exclusive private clip");
|
||||
try {
|
||||
// IEEE-754 binary32 encoded explicitly little-endian, independent of host byte order.
|
||||
std::array<unsigned char, 4096> bytes{};
|
||||
size_t index = 0;
|
||||
for (float value : pcm) {
|
||||
uint32_t bits; static_assert(sizeof bits == sizeof value);
|
||||
std::memcpy(&bits, &value, sizeof bits);
|
||||
put32(bytes.data() + index, bits); index += 4;
|
||||
if (index == bytes.size()) { write_all(fd, bytes.data(), index); index = 0; }
|
||||
}
|
||||
if (index) write_all(fd, bytes.data(), index);
|
||||
if (::close(fd)) { fd = -1; throw std::runtime_error("clip close failed"); }
|
||||
fd = -1;
|
||||
unsigned char request[13]{};
|
||||
put32(request, 9); request[4] = 'T'; put64(request + 5, id);
|
||||
// Pipe is empty (single outstanding request); 13 bytes <= PIPE_BUF.
|
||||
write_all(s.to_child, request, sizeof request);
|
||||
s.pending = id;
|
||||
s.deadline = Clock::now() + s.request_timeout;
|
||||
} catch (...) {
|
||||
if (fd >= 0) ::close(fd);
|
||||
::unlink(path.c_str());
|
||||
stop(); // Any partial pipe write means protocol synchronization is unknown.
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<WorkerReply> Worker::poll() {
|
||||
auto& s = *state_;
|
||||
if (s.pid <= 0) return std::nullopt;
|
||||
try {
|
||||
if ((!s.loaded || s.pending) && Clock::now() > s.deadline)
|
||||
throw std::runtime_error(s.loaded ? "worker transcription timed out" : "worker warmup timed out");
|
||||
unsigned char block[4096];
|
||||
bool eof = false;
|
||||
for (;;) {
|
||||
ssize_t n = ::read(s.from_child, block, sizeof block);
|
||||
if (n > 0) {
|
||||
s.input.insert(s.input.end(), block, block + n);
|
||||
if (s.input.size() > max_frame + 4) throw std::runtime_error("worker frame exceeded limit");
|
||||
continue;
|
||||
}
|
||||
if (n == 0) { eof = true; break; }
|
||||
if (errno == EINTR) continue;
|
||||
if (errno != EAGAIN && errno != EWOULDBLOCK) throw std::runtime_error("worker pipe read failed");
|
||||
break;
|
||||
}
|
||||
if (s.input.size() >= 4) {
|
||||
auto size = get32(s.input.data());
|
||||
if (size > max_frame || size < 1) throw std::runtime_error("invalid worker frame length");
|
||||
if (s.input.size() >= 4 + size) {
|
||||
if (s.input.size() != 4 + size) throw std::runtime_error("unexpected extra worker frame");
|
||||
char type = static_cast<char>(s.input[4]);
|
||||
if (type == 'Y' && size == 1 && !s.loaded && !s.pending) {
|
||||
if (eof) throw std::runtime_error("worker exited after warmup");
|
||||
s.loaded = true; s.input.clear(); return std::nullopt;
|
||||
}
|
||||
if (type == 'F' && !s.loaded) {
|
||||
if (size == 2 && s.input[5] == 'M')
|
||||
throw std::runtime_error("worker missing local model weights or private clip directory");
|
||||
throw std::runtime_error("worker failed to load local CPU model/runtime");
|
||||
}
|
||||
if ((type != 'R' && type != 'E') || size < 9 || !s.loaded || !s.pending ||
|
||||
get64(s.input.data() + 5) != *s.pending || size - 9 > max_text)
|
||||
throw std::runtime_error("invalid or stale worker reply");
|
||||
WorkerReply reply{*s.pending, {}, {}};
|
||||
std::string text(reinterpret_cast<const char*>(s.input.data() + 13), size - 9);
|
||||
if (!valid_utf8(text)) throw std::runtime_error("invalid worker UTF-8 reply");
|
||||
if (type == 'R') reply.text = std::move(text);
|
||||
else reply.error = std::move(text);
|
||||
s.pending.reset(); s.input.clear();
|
||||
::unlink((s.dir + "/clip.raw").c_str());
|
||||
return reply;
|
||||
}
|
||||
}
|
||||
if (eof) throw std::runtime_error("worker exited or closed its pipe");
|
||||
int status{};
|
||||
if (::waitpid(s.pid, &status, WNOHANG) == s.pid) {
|
||||
s.pid = -1;
|
||||
throw std::runtime_error("worker exited unexpectedly");
|
||||
}
|
||||
return std::nullopt;
|
||||
} catch (...) { stop(); throw; }
|
||||
}
|
||||
} // namespace frameyap
|
||||
@@ -0,0 +1,37 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace frameyap {
|
||||
|
||||
struct WorkerReply {
|
||||
uint64_t id;
|
||||
std::string text;
|
||||
std::string error;
|
||||
};
|
||||
|
||||
class Worker {
|
||||
public:
|
||||
explicit Worker(std::chrono::milliseconds warmup_timeout = std::chrono::seconds(120),
|
||||
std::chrono::milliseconds request_timeout = std::chrono::seconds(60));
|
||||
~Worker();
|
||||
Worker(const Worker&) = delete;
|
||||
Worker& operator=(const Worker&) = delete;
|
||||
void start(const std::string& python, const std::string& script,
|
||||
const std::string& model, int threads = 2);
|
||||
bool ready() const;
|
||||
void submit(uint64_t id, const std::vector<float>& pcm);
|
||||
std::optional<WorkerReply> poll();
|
||||
void stop();
|
||||
|
||||
private:
|
||||
struct State;
|
||||
std::unique_ptr<State> state_;
|
||||
};
|
||||
|
||||
} // namespace frameyap
|
||||
@@ -0,0 +1,31 @@
|
||||
#include "core.hpp"
|
||||
#include <cstdlib>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
using namespace frameyap;
|
||||
#define CHECK(x) do { if (!(x)) { std::cerr << "line " << __LINE__ << ": " #x "\n"; std::exit(1); } } while (0)
|
||||
void rejects(std::string text) { try { literal_text(text); } catch (const std::runtime_error&) { return; } CHECK(false); }
|
||||
int main() {
|
||||
CHECK(literal_text("Hello 世界 😀") == "Hello 世界 😀");
|
||||
CHECK(literal_text("one\r\ntwo\tthree\xe2\x80\xa8") == "one two three ");
|
||||
CHECK(literal_text("submit; $(echo x)") == "submit; $(echo x)");
|
||||
CHECK(literal_text(" \t\n").empty());
|
||||
CHECK(literal_text(std::string(4096, 'a')).size() == 4096);
|
||||
rejects(std::string(4097, 'a')); rejects(std::string("a\0b", 3));
|
||||
rejects("\x1b[0m"); rejects("\xc0\xaf"); rejects("\xed\xa0\x80");
|
||||
rejects("\xf4\x90\x80\x80"); rejects("\xe2\x82"); rejects("\xc2\x85");
|
||||
Session s;
|
||||
CHECK(!s.record()); s.ready(); CHECK(s.record());
|
||||
CHECK(!s.finish(3199)); CHECK(s.state() == State::Ready);
|
||||
CHECK(s.record()); auto cancelled = s.id(); CHECK(s.finish(3200));
|
||||
CHECK(!s.record()); s.cancel(); CHECK(!s.reply(cancelled, "stale"));
|
||||
CHECK(s.record()); auto current = s.id(); CHECK(s.finish(320000));
|
||||
CHECK(!s.reply(current - 1, "stale")); CHECK(s.reply(current, "text\n"));
|
||||
CHECK(!s.reply(current, "duplicate")); CHECK(!s.record());
|
||||
CHECK(s.take_insert() == "text "); CHECK(!s.take_insert());
|
||||
CHECK(!s.reply(current, "again")); CHECK(s.record()); CHECK(!s.finish(320001));
|
||||
CHECK(s.record()); CHECK(s.finish(16000)); CHECK(s.reply(s.id(), ""));
|
||||
CHECK(s.state() == State::Ready); CHECK(!s.take_insert());
|
||||
CHECK(s.record()); CHECK(s.finish(16000)); s.fail(); CHECK(!s.reply(s.id(), "bad"));
|
||||
std::cout << "core checks passed\n";
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Hardware-free tests, and a deliberately fake child for native IPC tests.
|
||||
|
||||
Run: python3 -m unittest discover -s tests -p test_worker.py
|
||||
Native fixture: python3 tests/test_worker.py --model ok --threads 2 --clip-dir DIR
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "python"))
|
||||
from frameyap import worker
|
||||
|
||||
|
||||
class FakeModel:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def transcribe(self, audio, sample_rate):
|
||||
self.calls.append((audio, sample_rate))
|
||||
return {"text": "héllo 世界"}
|
||||
|
||||
|
||||
def fake_child():
|
||||
# This branch intentionally never imports numpy, torch, moondream or a mic.
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--model", required=True)
|
||||
parser.add_argument("--threads", required=True)
|
||||
parser.add_argument("--clip-dir", required=True)
|
||||
args = parser.parse_args()
|
||||
if args.model == "fail":
|
||||
worker.send_frame(1, b"F")
|
||||
return
|
||||
if args.model == "crash":
|
||||
return
|
||||
if args.model == "hang-warm":
|
||||
import time
|
||||
time.sleep(130)
|
||||
return
|
||||
worker.send_frame(1, b"Y")
|
||||
while True:
|
||||
msg = worker.read_frame(0)
|
||||
if msg is None:
|
||||
return
|
||||
if args.model == "hang":
|
||||
import time
|
||||
time.sleep(70)
|
||||
continue
|
||||
assert len(msg) == 9 and msg[:1] == b"T"
|
||||
clip = Path(args.clip_dir) / "clip.raw"
|
||||
assert clip.stat().st_size == 3200 * 4
|
||||
ident = msg[1:9]
|
||||
if args.model == "stale":
|
||||
ident = struct.pack("<Q", struct.unpack("<Q", ident)[0] + 1)
|
||||
if args.model == "oversized-frame":
|
||||
os.write(1, struct.pack("<I", 65537))
|
||||
continue
|
||||
text = b"a" * 4097 if args.model == "long" else "héllo 世界".encode()
|
||||
worker.send_frame(1, b"R", ident + text)
|
||||
if args.model == "duplicate":
|
||||
worker.send_frame(1, b"R", ident + text)
|
||||
|
||||
|
||||
class WorkerTests(unittest.TestCase):
|
||||
def test_framing_and_bounds(self):
|
||||
r, w = os.pipe()
|
||||
try:
|
||||
worker.send_frame(w, b"T", struct.pack("<Q", 23))
|
||||
self.assertEqual(worker.read_frame(r), b"T" + struct.pack("<Q", 23))
|
||||
os.write(w, struct.pack("<I", 65537))
|
||||
with self.assertRaises(ValueError):
|
||||
worker.read_frame(r)
|
||||
finally:
|
||||
os.close(r)
|
||||
os.close(w)
|
||||
|
||||
def test_fake_transcription_correlated_and_private(self):
|
||||
with tempfile.TemporaryDirectory() as path:
|
||||
os.chmod(path, 0o700)
|
||||
clip = Path(path) / "clip.raw"
|
||||
clip.write_bytes(struct.pack("<3200f", *([0.0] * 3200)))
|
||||
os.chmod(clip, 0o600)
|
||||
model = FakeModel()
|
||||
with patch.object(worker, "read_clip", return_value=[0.0] * 3200):
|
||||
in_r, in_w = os.pipe()
|
||||
out_r, out_w = os.pipe()
|
||||
try:
|
||||
worker.send_frame(in_w, b"T", struct.pack("<Q", 42))
|
||||
os.close(in_w)
|
||||
in_w = -1
|
||||
worker.run(model, path, in_r, out_w)
|
||||
self.assertEqual(worker.read_frame(out_r), b"Y")
|
||||
reply = worker.read_frame(out_r)
|
||||
self.assertEqual(reply, b"R" + struct.pack("<Q", 42) + "héllo 世界".encode())
|
||||
self.assertEqual(model.calls[0][1], 16000)
|
||||
finally:
|
||||
os.close(in_r)
|
||||
if in_w >= 0:
|
||||
os.close(in_w)
|
||||
os.close(out_r)
|
||||
os.close(out_w)
|
||||
|
||||
def test_missing_weights_no_dependency_import(self):
|
||||
with tempfile.TemporaryDirectory() as path:
|
||||
with self.assertRaisesRegex(ValueError, "weights missing"):
|
||||
worker.local_model(path)
|
||||
with patch.dict(sys.modules, {"moondream": None}):
|
||||
with self.assertRaises(ValueError):
|
||||
worker.load_model(path, 2)
|
||||
self.assertEqual(os.environ["HF_HUB_OFFLINE"], "1")
|
||||
|
||||
def test_pinned_model_hashes_and_symlinks(self):
|
||||
import hashlib
|
||||
with tempfile.TemporaryDirectory() as path:
|
||||
root = Path(path)
|
||||
(root / "model.safetensors").write_bytes(b"fixture")
|
||||
digest = hashlib.sha256(b"fixture").hexdigest()
|
||||
with patch.object(worker, "FILES", {"model.safetensors": (7, digest)}):
|
||||
self.assertEqual(worker.local_model(path), path)
|
||||
(root / "model.safetensors").write_bytes(b"changed")
|
||||
with self.assertRaisesRegex(ValueError, "pinned revision"):
|
||||
worker.local_model(path)
|
||||
(root / "model.safetensors").unlink()
|
||||
(root / "other").write_bytes(b"fixture")
|
||||
(root / "model.safetensors").symlink_to(root / "other")
|
||||
with self.assertRaises(ValueError):
|
||||
worker.local_model(path)
|
||||
|
||||
def test_unsafe_clip_and_oversize_text(self):
|
||||
with tempfile.TemporaryDirectory() as path:
|
||||
os.chmod(path, 0o700)
|
||||
outside = Path(path) / "outside"
|
||||
outside.write_bytes(b"0" * 12800)
|
||||
os.symlink(outside, Path(path) / "clip.raw")
|
||||
with self.assertRaises(OSError):
|
||||
worker.read_clip(path)
|
||||
(Path(path) / "clip.raw").unlink()
|
||||
class Large:
|
||||
def transcribe(self, **kwargs):
|
||||
return {"text": "a" * 4097}
|
||||
with patch.object(worker, "read_clip", return_value=[]):
|
||||
in_r, in_w = os.pipe()
|
||||
out_r, out_w = os.pipe()
|
||||
try:
|
||||
worker.send_frame(in_w, b"T", struct.pack("<Q", 7))
|
||||
os.close(in_w)
|
||||
in_w = -1
|
||||
worker.run(Large(), path, in_r, out_w)
|
||||
self.assertEqual(worker.read_frame(out_r), b"Y")
|
||||
self.assertEqual(worker.read_frame(out_r), b"E" + struct.pack("<Q", 7) + b"transcription failed")
|
||||
finally:
|
||||
os.close(in_r)
|
||||
if in_w >= 0:
|
||||
os.close(in_w)
|
||||
os.close(out_r)
|
||||
os.close(out_w)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if "--clip-dir" in sys.argv:
|
||||
fake_child()
|
||||
else:
|
||||
unittest.main()
|
||||
@@ -0,0 +1,131 @@
|
||||
#include "worker.hpp"
|
||||
|
||||
#include <cassert>
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <functional>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
#include <unistd.h>
|
||||
|
||||
using namespace frameyap;
|
||||
using namespace std::chrono_literals;
|
||||
|
||||
static void until(const std::function<bool()>& fn) {
|
||||
auto deadline = std::chrono::steady_clock::now() + 4s;
|
||||
while (!fn()) {
|
||||
if (std::chrono::steady_clock::now() >= deadline) throw std::runtime_error("fixture timed out");
|
||||
std::this_thread::sleep_for(5ms);
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
// CMake should pass its Python interpreter and the absolute test_worker.py path.
|
||||
if (argc != 3) return 2;
|
||||
char runtime[] = "/tmp/frameyap-test-XXXXXX";
|
||||
if (!::mkdtemp(runtime)) return 3;
|
||||
::setenv("XDG_RUNTIME_DIR", runtime, 1);
|
||||
try {
|
||||
std::vector<float> clip(3200, 0.25f);
|
||||
Worker worker;
|
||||
worker.start(argv[1], argv[2], "ok", 2);
|
||||
assert(!worker.ready());
|
||||
until([&] { worker.poll(); return worker.ready(); });
|
||||
bool rejected = false;
|
||||
try { worker.submit(1, std::vector<float>(3199)); }
|
||||
catch (const std::invalid_argument&) { rejected = true; }
|
||||
assert(rejected);
|
||||
worker.submit(100, clip);
|
||||
rejected = false;
|
||||
try { worker.submit(101, clip); }
|
||||
catch (const std::logic_error&) { rejected = true; }
|
||||
assert(rejected);
|
||||
std::optional<WorkerReply> reply;
|
||||
until([&] { reply = worker.poll(); return reply.has_value(); });
|
||||
assert(reply->id == 100 && reply->text == "héllo 世界" && reply->error.empty());
|
||||
worker.submit(101, clip);
|
||||
until([&] { reply = worker.poll(); return reply.has_value(); });
|
||||
assert(reply->id == 101);
|
||||
worker.stop();
|
||||
assert(!worker.ready());
|
||||
for (const auto& entry : std::filesystem::directory_iterator(runtime)) {
|
||||
(void)entry;
|
||||
throw std::runtime_error("private directory leaked");
|
||||
}
|
||||
worker.start(argv[1], argv[2], "fail", 2);
|
||||
bool failed = false;
|
||||
until([&] {
|
||||
try { worker.poll(); }
|
||||
catch (const std::runtime_error&) { failed = true; }
|
||||
return failed;
|
||||
});
|
||||
assert(!worker.ready());
|
||||
worker.start(argv[1], argv[2], "stale", 2);
|
||||
until([&] { worker.poll(); return worker.ready(); });
|
||||
worker.submit(4, clip);
|
||||
failed = false;
|
||||
until([&] {
|
||||
try { worker.poll(); }
|
||||
catch (const std::runtime_error&) { failed = true; }
|
||||
return failed;
|
||||
});
|
||||
for (const char* mode : {"long", "oversized-frame", "duplicate"}) {
|
||||
worker.start(argv[1], argv[2], mode, 2);
|
||||
until([&] { worker.poll(); return worker.ready(); });
|
||||
worker.submit(5, clip);
|
||||
failed = false;
|
||||
until([&] {
|
||||
try { worker.poll(); }
|
||||
catch (const std::runtime_error&) { failed = true; }
|
||||
return failed;
|
||||
});
|
||||
assert(!worker.ready());
|
||||
}
|
||||
// Cancellation must not wait for inference or leak the private clip.
|
||||
worker.start(argv[1], argv[2], "hang", 2);
|
||||
until([&] { worker.poll(); return worker.ready(); });
|
||||
worker.submit(6, clip);
|
||||
auto cancel_start = std::chrono::steady_clock::now();
|
||||
worker.stop();
|
||||
assert(std::chrono::steady_clock::now() - cancel_start < 2s);
|
||||
assert(std::filesystem::is_empty(runtime));
|
||||
worker.start(argv[1], argv[2], "hang-warm", 2);
|
||||
worker.stop();
|
||||
assert(std::filesystem::is_empty(runtime));
|
||||
worker.start(argv[1], argv[2], "crash", 2);
|
||||
failed = false;
|
||||
until([&] {
|
||||
try { worker.poll(); }
|
||||
catch (const std::runtime_error&) { failed = true; }
|
||||
return failed;
|
||||
});
|
||||
worker.stop();
|
||||
Worker deadlines(2s, 50ms);
|
||||
deadlines.start(argv[1], argv[2], "hang", 2);
|
||||
until([&] { deadlines.poll(); return deadlines.ready(); });
|
||||
deadlines.submit(9, clip);
|
||||
failed = false;
|
||||
until([&] {
|
||||
try { deadlines.poll(); }
|
||||
catch (const std::runtime_error& e) { failed = std::string(e.what()).find("timed out") != std::string::npos; }
|
||||
return failed;
|
||||
});
|
||||
assert(std::filesystem::is_empty(runtime));
|
||||
Worker warm_deadline(50ms, 2s);
|
||||
warm_deadline.start(argv[1], argv[2], "hang-warm", 2);
|
||||
failed = false;
|
||||
until([&] {
|
||||
try { warm_deadline.poll(); }
|
||||
catch (const std::runtime_error& e) { failed = std::string(e.what()).find("timed out") != std::string::npos; }
|
||||
return failed;
|
||||
});
|
||||
assert(std::filesystem::is_empty(runtime));
|
||||
std::filesystem::remove(runtime);
|
||||
} catch (...) {
|
||||
std::filesystem::remove(runtime);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user