diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 225421c..98984cc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,7 +22,7 @@ jobs: run: | pacman -Syu --noconfirm --needed base-devel meson ninja gcc pkgconf \ wayland wayland-protocols libglvnd libepoxy mpv ffmpeg \ - systemd-libs socat python git + systemd-libs socat python git cmake qt6-declarative - uses: actions/checkout@v5 - name: Trust the checkout run: git config --global --add safe.directory "$GITHUB_WORKSPACE" @@ -46,6 +46,11 @@ jobs: ninja -C build - name: Test run: meson test -C build --print-errorlogs + - name: Build and test the lock feed plugin + run: | + cmake -S qml-plugin -B build-qml -DBUILD_TESTING=ON + cmake --build build-qml + ctest --test-dir build-qml --output-on-failure - name: Create the source archive run: meson dist -C build --formats xztar - name: Collect artifacts diff --git a/README.md b/README.md index 6a1c6c7..d0693c1 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,9 @@ Commands: - `{"cmd":"stop"}` — unload all media. - `{"cmd":"status"}` reports the path, kind, pause state, outputs, `time_pos`, `hwdec`, and playback `error`. - `{"cmd":"fade","ms":250}` — set the still fade length. +- `{"cmd":"feed"}` — start muted video output to the lock feed. +- `{"cmd":"feed-stop"}` — stop the lock feed and release its buffers. +- `{"cmd":"skip","outputs":["DP-1"]}` — stop desktop swaps on the named outputs. ## Examples @@ -133,7 +136,7 @@ and sleep. Reasons in priority order: 2. `sleep` pauses playback before suspend. 3. `always-animate` bypasses automatic pause rules. 4. `idle` pause from the `owe-idle` helper. -5. `locked` follows logind session state. +5. `locked` follows logind or Hyprland session state. 6. `dpms-off` applies when every connected monitor reports a known off state. Missing DRM data falls back to Hyprland state. 7. `battery` holds or replaces playback while on battery. Off by default. @@ -149,7 +152,16 @@ Idle pauses are optional. Wire the `owe-idle` helper into `hypridle` and it sets the idle pause. `owe resume` clears only the manual pause, so an idle resume never releases a manual pause. -To force motion regardless of policy, run `owe always-animate on`. +To bypass automatic pause rules, run `owe always-animate on`. +Manual pause and sleep still take precedence. + +A locked session can keep video moving through the muted lock feed instead +of the desktop layer. The feed honors manual pause, sleep, DPMS, idle, +blocklist and battery pause/poster settings. The animation override bypasses +the automatic rules, but the feed still stops for manual pause, sleep and DPMS. +Fullscreen and occupied desktop windows do not stop the lock feed. +A lock screen can import `Owe.LockFeed` and display a `LockFeed` item; +it should provide its own still fallback when the feed is inactive. ## GIF handling @@ -160,7 +172,8 @@ same cache. The GIF and poster cache keeps the newest 512 MiB by default. Set `cache_max_mb` in `[transcode]` to change the budget. `0` disables -eviction. +eviction. The current conversion and its cached source are retained even when +they exceed the budget; older unused entries are evicted first. ## Audio @@ -232,6 +245,11 @@ cmake --build build-qml ctest --test-dir build-qml --output-on-failure ``` +The AUR package installs the plugin to `/usr/lib/qt6/qml/Owe/LockFeed`. +The local installer uses `~/.local/lib/qt6/qml/Owe/LockFeed`; add +`~/.local/lib/qt6/qml` to the consuming application's `QML_IMPORT_PATH`. +Installing the module does not change the lock screen's QML layout. + The plugin tests use an offscreen Qt platform and local sockets. They cover reconnects, fragmented messages, and descriptor ownership. To run sanitizer checks, use a separate build directory: @@ -246,7 +264,7 @@ To check live playback and IPC, run `build/test/owe-live-test`. Build and install deps: `meson`, `ninja`, `gcc`, `pkgconf`, `wayland`, `wayland-protocols`, `libglvnd`, `libepoxy`, `mpv`, `ffmpeg`, `systemd-libs`, -`socat`, `python`. -Runtime deps: `mpv`, `ffmpeg`, `socat`. +`socat`, `python`, `cmake`, `qt6-declarative`. +Runtime deps: `mpv`, `ffmpeg`, `socat`, `qt6-declarative`. Docs: `docs/architecture.md`, `docs/theme-contract.md`, `docs/benchmarks.md`. diff --git a/bench/bench.py b/bench/bench.py new file mode 100644 index 0000000..b3cab40 --- /dev/null +++ b/bench/bench.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +"""Measure verified wallpaper states and restore the original selection and flags.""" +import json +import math +import os +from pathlib import Path +import signal +import socket +import statistics +import struct +import sys +import time + + +class Benchmark: + def __init__(self, output, seconds, runs): + self.output = Path(output) + self.output.mkdir(parents=True, exist_ok=True) + self.seconds = seconds + self.runs = runs + runtime = Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")) + self.runtime = runtime / "owe" + self.initial = None + self.changed_background = False + + def request(self, command, renderer=False, **fields): + path = self.runtime / ("render.sock" if renderer else "owed.sock") + with socket.socket(socket.AF_UNIX) as peer: + peer.settimeout(10) + peer.connect(str(path)) + pid, _, _ = struct.unpack("3i", peer.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12)) + peer.sendall(json.dumps(dict(cmd=command, **fields)).encode() + b"\n") + data = bytearray() + while b"\n" not in data: + chunk = peer.recv(65536) + if not chunk or len(data) + len(chunk) > 65536: + raise RuntimeError(f"{path}: incomplete or oversized reply") + data.extend(chunk) + reply = json.loads(data.split(b"\n", 1)[0]) + if reply.get("status") != "ok": + raise RuntimeError(f"{command}: {reply}") + return pid, reply + + def command(self, command, **fields): + return self.request(command, **fields)[1] + + @staticmethod + def process(pid): + fields = Path(f"/proc/{pid}/stat").read_text().rsplit(")", 1)[1].split() + return dict(pid=pid, parent=int(fields[1]), started=int(fields[19]), + ticks=int(fields[11]) + int(fields[12]), + rss=int(fields[21]) * os.sysconf("SC_PAGE_SIZE") / 1048576) + + def snapshot(self, state): + daemon_pid, daemon = self.request("status") + if daemon.get("failed_path") or daemon.get("job_running"): + raise RuntimeError("media failed or is still converting") + processes = {"daemon": self.process(daemon_pid)} + renderer = None + if state == "still": + if daemon.get("engine") != "shell" or daemon.get("render_alive"): + raise RuntimeError("still handoff has not stopped the renderer") + else: + renderer_pid, renderer = self.request("status", renderer=True) + processes["renderer"] = self.process(renderer_pid) + if processes["renderer"]["parent"] != daemon_pid: + raise RuntimeError("renderer does not belong to the tested daemon") + if (daemon.get("engine") != "renderer" or not daemon.get("media_ready") or + not renderer.get("ready") or not renderer.get("has_video") or renderer.get("error")): + raise RuntimeError("video is not ready") + want_pause = state != "playing" + if daemon.get("paused") != want_pause or renderer.get("paused") != want_pause: + raise RuntimeError(f"{state}: playback state does not match") + if daemon.get("locked"): + raise RuntimeError("lock-feed playback needs a separate benchmark") + expected = {"playing": "always-animate", "paused": "manual", "policy": "idle"}[state] + if daemon.get("reason") != expected: + raise RuntimeError(f"{state}: unexpected policy reason {daemon.get('reason')}") + return dict(daemon=daemon, renderer=renderer, processes=processes) + + def wait_ready(self, state): + deadline = time.monotonic() + 15 + while True: + try: + return self.snapshot(state) + except (RuntimeError, OSError) as error: + if time.monotonic() >= deadline: + raise RuntimeError(f"{state}: did not become ready: {error}") from error + time.sleep(0.1) + + @staticmethod + def verify_same(before, after): + if before["processes"].keys() != after["processes"].keys(): + raise RuntimeError("process set changed during the sample") + for name, process in before["processes"].items(): + current = after["processes"][name] + if (process["pid"], process["started"]) != (current["pid"], current["started"]): + raise RuntimeError(f"{name} restarted during the sample") + for key in ("source_path", "loaded_path", "loaded_kind"): + if before["daemon"].get(key) != after["daemon"].get(key): + raise RuntimeError("media changed during the sample") + + def sample(self, state): + self.wait_ready(state) + results = [] + for _ in range(self.runs): + before = self.snapshot(state) + start = time.monotonic() + while True: + remaining = self.seconds - (time.monotonic() - start) + if remaining <= 0: + break + time.sleep(min(1, remaining)) + after = self.snapshot(state) + self.verify_same(before, after) + if state in ("paused", "policy") and abs( + after["renderer"]["time_pos"] - before["renderer"]["time_pos"]) > 0.05: + raise RuntimeError("playback advanced while paused") + elapsed = time.monotonic() - start + row = dict(elapsed=elapsed, before=before, after=after, resources={}) + for name, process in before["processes"].items(): + current = after["processes"][name] + cpu = (current["ticks"] - process["ticks"]) / os.sysconf("SC_CLK_TCK") / elapsed * 100 + row["resources"][name] = dict(cpu=cpu, rss_mib=current["rss"]) + results.append(row) + (self.output / f"{state}.json").write_text(json.dumps(results, indent=2) + "\n") + summary = [] + for name in results[0]["resources"]: + values = [row["resources"][name] for row in results] + summary.append(f"{name} median CPU={statistics.median(v['cpu'] for v in values):.3f}% " + f"RSS={max(v['rss_mib'] for v in values):.1f}MiB") + line = f"{state}: " + "; ".join(summary) + print(line, flush=True) + with (self.output / "summary.txt").open("a") as file: + file.write(line + "\n") + + def restore(self): + if not self.initial: + return + commands = [] + if self.changed_background: + commands.append(("set", dict(path=self.initial["source_path"]))) + commands.extend([ + ("pause" if self.initial["manual_pause"] else "resume", {}), + ("idle-pause" if self.initial["idle_pause"] else "idle-resume", {}), + ("always-animate", dict(value=self.initial["always_animate"])), + ]) + errors = [] + for command, fields in commands: + try: + self.command(command, **fields) + except (OSError, RuntimeError) as error: + errors.append(str(error)) + if errors: + raise RuntimeError("could not restore benchmark state: " + "; ".join(errors)) + + def run(self, still=None): + self.initial = self.command("status") + (self.output / "config.json").write_text(json.dumps(self.command("config"), indent=2) + "\n") + try: + self.command("resume") + self.command("always-animate", value=True) + self.sample("playing") + self.command("always-animate", value=False) + self.command("pause") + self.sample("paused") + self.command("idle-pause") + self.command("resume") + self.sample("policy") + if still: + self.changed_background = True + self.command("set", path=str(Path(still).resolve(strict=True))) + self.sample("still") + finally: + self.restore() + + +def main(): + def stop(signum, _frame): + # Let the finally block restore state once, even after repeated signals. + signal.signal(signal.SIGINT, signal.SIG_IGN) + signal.signal(signal.SIGTERM, signal.SIG_IGN) + raise SystemExit(128 + signum) + + signal.signal(signal.SIGINT, stop) + signal.signal(signal.SIGTERM, stop) + seconds = float(os.environ.get("OWE_BENCH_SECONDS", "5")) + runs = int(os.environ.get("OWE_BENCH_RUNS", "3")) + if not math.isfinite(seconds) or seconds <= 0 or runs <= 0: + raise ValueError("benchmark duration and run count must be positive") + output = sys.argv[1] if len(sys.argv) > 1 else f"bench/results-{int(time.time())}" + Benchmark(output, seconds, runs).run(os.environ.get("OWE_BENCH_STILL")) + + +if __name__ == "__main__": + try: + main() + except (OSError, RuntimeError, ValueError) as error: + print(f"benchmark failed: {error}", file=sys.stderr) + sys.exit(1) diff --git a/bench/bench.sh b/bench/bench.sh index 64f0892..6620a2c 100755 --- a/bench/bench.sh +++ b/bench/bench.sh @@ -1,149 +1,3 @@ #!/bin/bash -# owe benchmark: sample renderer and daemon resources per verified state. # Usage: bench/bench.sh [outdir] -# Optional: OWE_BENCH_STILL=/path/to/still.jpg adds a still sample and -# restores the previous background afterwards. -set -u - -OUT="${1:-bench/results-$(date +%s)}" -RUNS="${OWE_BENCH_RUNS:-3}" -SECONDS_PER_RUN="${OWE_BENCH_SECONDS:-5}" -mkdir -p "$OUT" - -need() { command -v "$1" >/dev/null 2>&1 || { echo "missing: $1" >&2; exit 1; }; } -need owe -need python3 - -reason() { - owe status 2>/dev/null | python3 -c 'import json,sys; print(json.load(sys.stdin)["reason"])' 2>/dev/null || echo "unknown" -} - -MANUAL_PAUSED=$(owe status 2>/dev/null | python3 -c 'import json,sys; print(json.load(sys.stdin)["manual_pause"])' 2>/dev/null || echo "false") -ANIMATE=$(owe status 2>/dev/null | python3 -c 'import json,sys; print(json.load(sys.stdin)["always_animate"])' 2>/dev/null || echo "false") -ORIGINAL_BACKGROUND=$(readlink -f "$HOME/.local/state/omarchy/current/background" 2>/dev/null || true) -STILL_CHANGED=0 - -restore() { - if [[ $STILL_CHANGED == 1 && -n $ORIGINAL_BACKGROUND && -f $ORIGINAL_BACKGROUND ]]; then - owe set "$ORIGINAL_BACKGROUND" >/dev/null 2>&1 || true - fi - if [[ $MANUAL_PAUSED == "true" ]]; then owe pause >/dev/null 2>&1 || true; else owe resume >/dev/null 2>&1 || true; fi - if [[ $ANIMATE == "true" ]]; then owe always-animate on >/dev/null 2>&1 || true; else owe always-animate off >/dev/null 2>&1 || true; fi -} -trap restore EXIT INT TERM - -sample_state() { # name seconds runs - local name="$1" secs="$2" runs="$3" state - state=$(reason) - owe status >"$OUT/status-$name.json" 2>/dev/null || true - owe render-status >"$OUT/render-$name.json" 2>/dev/null || true - printf '%s: reason=%s\n' "$name" "$state" | tee -a "$OUT/summary.txt" - python3 - "$name" "$secs" "$runs" "$OUT" <<'PY' -import json -import os -import statistics -import subprocess -import sys -import time -from pathlib import Path - -name = sys.argv[1] -seconds = float(sys.argv[2]) -runs = int(sys.argv[3]) -out = Path(sys.argv[4]) - - -def proc_stat(pid): - fields = Path(f"/proc/{pid}/stat").read_text().rsplit(")", 1)[1].split() - rss = 0 - for line in Path(f"/proc/{pid}/status").read_text().splitlines(): - if line.startswith("VmRSS:"): - rss = int(line.split()[1]) - return int(fields[11]) + int(fields[12]), rss - - -def find_pids(): - owed_list = subprocess.check_output(["pgrep", "-x", "owed"], text=True).split() - if not owed_list: - return None, None - owed = int(owed_list[0]) - for entry in subprocess.check_output(["pgrep", "-x", "owe-render"], text=True).split(): - pid = int(entry) - try: - ppid = int(Path(f"/proc/{pid}/stat").read_text().rsplit(")", 1)[1].split()[1]) - except OSError: - continue - if ppid == owed: - return owed, pid - return owed, None - - -owed, renderer = find_pids() -if renderer is None: - print(f"{name}: no owe renderer; the shell draws this state") - sys.exit(0) -results = [] -for _ in range(runs): - base = {pid: proc_stat(pid) for pid in (owed, renderer)} - start = time.monotonic() - time.sleep(seconds) - elapsed = time.monotonic() - start - now = {pid: proc_stat(pid) for pid in (owed, renderer)} - tick = os.sysconf("SC_CLK_TCK") - results.append(( - (now[renderer][0] - base[renderer][0]) / tick / elapsed * 100, - (now[owed][0] - base[owed][0]) / tick / elapsed * 100, - now[renderer][1] / 1024, - now[owed][1] / 1024, - )) - - -def spread(values, unit): - return f"min={min(values):.3f}{unit} median={statistics.median(values):.3f}{unit} max={max(values):.3f}{unit}" - - -line = (f"{name}: renderer cpu {spread([r[0] for r in results], '%')}" - f" rss {min(r[2] for r in results):.1f}-{max(r[2] for r in results):.1f}MiB;" - f" daemon cpu {spread([r[1] for r in results], '%')}" - f" rss {min(r[3] for r in results):.1f}-{max(r[3] for r in results):.1f}MiB") -print(line) -with (out / "summary.txt").open("a") as handle: - handle.write(line + "\n") -PY - if command -v gputop >/dev/null 2>&1; then - timeout $((secs + 4)) gputop -d 2 -n 2 2>/dev/null | grep -E "PID|owe-render|owed" >"$OUT/gpu-$name.txt" || true - fi -} - -echo "owe benchmark $(date)" | tee "$OUT/summary.txt" -echo "states: playing, paused, policy when active, still when OWE_BENCH_STILL is set" | tee -a "$OUT/summary.txt" -echo "runs per state: $RUNS, seconds per run: $SECONDS_PER_RUN" | tee -a "$OUT/summary.txt" - -# Playing. always-animate forces motion regardless of windows or battery. -owe always-animate on >/dev/null 2>&1 -sample_state "playing" "$SECONDS_PER_RUN" "$RUNS" - -# Manual pause. The renderer must stop while the last frame stays presented. -owe always-animate off >/dev/null 2>&1 -owe pause >/dev/null 2>&1 -sample_state "paused" "$SECONDS_PER_RUN" "$RUNS" -owe resume >/dev/null 2>&1 - -# Policy pause. Only sample when a real policy reason is active. -if [[ $(reason) != "visible" && $(reason) != "always-animate" ]]; then - sample_state "policy" "$SECONDS_PER_RUN" "$RUNS" -else - echo "policy: skipped, no policy condition is active" | tee -a "$OUT/summary.txt" -fi - -# Still. Only when a still file is supplied, then restore the background. -if [[ -n ${OWE_BENCH_STILL:-} && -f ${OWE_BENCH_STILL:-} ]]; then - owe set "$OWE_BENCH_STILL" >/dev/null 2>&1 - STILL_CHANGED=1 - sleep 2 - sample_state "still" "$SECONDS_PER_RUN" "$RUNS" -else - echo "still: skipped, set OWE_BENCH_STILL to a still image to sample it" | tee -a "$OUT/summary.txt" -fi - -echo "results in $OUT" +exec python3 "$(dirname "${BASH_SOURCE[0]}")/bench.py" "$@" diff --git a/docs/architecture.md b/docs/architecture.md index a3dc12a..8325c57 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -48,6 +48,10 @@ The decoder rejects images above 64 million pixels. A still decodes on a worker thread and uploads its texture on the render thread, so a large image does not stall the control loop. When an output grows, the current still decodes again at the larger size. +Replacement requests cancel the worker's I/O and keep only the newest queued +request. The event loop reaps the old decoder when it completes; it never +joins an unfinished decoder during media changes. Shutdown joins the worker +before freeing its state. The renderer uploads one texture, presents the fade, and stops frame requests after the fully opaque final frame. Pause sets `pause` to `yes`. Decode stops, update callbacks stop, @@ -57,7 +61,7 @@ while paused, so the main loop sleeps in `poll`. ## Lock feed A locked session keeps its video. The daemon sends `feed` to the renderer -instead of `pause` when logind reports the session locked and the loaded +instead of `pause` when logind or Hyprland reports the session locked and the loaded media is a video. The renderer resumes playback muted and writes frames into a three slot shared memory ring, one memfd per slot. `lock-feed.sock` sits next to `render.sock` and carries a fixed size binary protocol. A @@ -72,11 +76,21 @@ It pauses decode when no clients remain and resumes decode when a client connect Each buffer tracks the clients that must acknowledge its frame. Duplicate acknowledgements and client disconnects cannot release another client's buffer. Frame sequence numbers remain unique across buffer size changes. +Clients that hold a frame for a second without acknowledging it are disconnected, +so one stalled output cannot exhaust the ring for every other output. +Readback resolution follows the outputs' physical cover requirements, capped +at the source resolution and the protocol's 3840×2160 limit. QML items in the +same process share one CPU copy of a frame. Geometry-only updates reuse the +existing texture. The feed still uses GPU readback and texture uploads; +these changes reduce their cost without claiming zero-copy GPU transport. The QML client reads one protocol message at a time, so descriptors stay with their `HELLO` message. It retries the connection after a disconnect while `active` remains true. The daemon ends the feed before sleep, when all monitors turn off, or after a still replaces the video. The renderer also ends the feed after a successful still decode and requests a desktop redraw. +Stopping the feed releases its shared-memory and GPU buffers. Manual, idle, +blocklist and battery pause rules also apply to a locked session; the animation +override bypasses the automatic rules, but never manual pause, sleep or DPMS. ## Daemon @@ -87,8 +101,9 @@ because `ln -nsf` swaps the entry atomically. It owns the engine choice. While a video or GIF plays it starts `owe-render` and disables the shell background plugin. A still always belongs to the shell: it enables the plugin, stops the renderer, and -keeps only the daemon. The plugin is disabled only after the new media -presents its first frame, so the handoff never shows a black frame. +keeps only the daemon. The plugin is disabled after the renderer starts, before waiting for its first +frame: an occluded background surface may never receive a frame callback. +Readiness and fallback are tracked separately from releasing the shell layer. It connects to the Hyprland event socket for `fullscreen`, `openwindow`, `closewindow`, `movewindow`, `workspace`, and monitor events. It queries @@ -104,6 +119,8 @@ Cancellation terminates FFmpeg and joins the worker before it frees job memory. Only the worker reaps its FFmpeg child. Conversions use temporary files and publish complete results with an atomic rename. Cache keys include the source path, size, nanosecond timestamps, conversion version, and encoding settings. +Startup removes abandoned conversion temporary files older than a day, leaving +recent files alone so an orphaned worker can finish safely. The daemon tracks what the renderer currently shows. A load is sent only when the target path or kind changes. Pause and resume are sent only on @@ -112,7 +129,8 @@ request, so the daemon polls the renderer status until the first frame presents. A video that fails to decode, or that never presents a frame within five seconds, falls back to the last media that proved it plays. The renderer child is reaped on `SIGCHLD` and restarted with the current -media when it dies. +media when it dies. Only the daemon restart path spawns a replacement; +individual IPC sends fail if the child has exited, preserving state recovery. Retry delays prevent a renderer failure from causing a rapid restart loop. ## IPC @@ -120,7 +138,10 @@ Retry delays prevent a renderer failure from causing a rapid restart loop. Both sockets use JSON lines. Each command gets one reply line. `owed.sock` serves CLI and hook clients. `render.sock` serves the daemon and direct debug clients. No broadcast exists. Each reply -goes to its own requester. +goes to its own requester, including asynchronous replies after a client slot +is reused. Both servers use the same bounded transport: idle connections and +incomplete requests expire after two minutes, and partial writes are queued +with a four-message-size limit. Slow readers cannot grow the queue indefinitely. ## Shutdown diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 19c81a4..0b7d176 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -4,21 +4,32 @@ For missing checks and follow-up work, see [Performance checks and follow-up wor ## Method -Run `bench/bench.sh [outdir]`. It samples renderer CPU ticks from -`/proc//stat` per state and captures `gputop` rows per state. -It stores `owe status` JSON per state. No absolute numbers ship here. -Compare each run against the local baseline. +Run `bench/bench.sh [outdir]` with a working video wallpaper. It measures +renderer and daemon CPU ticks and resident memory using `/proc`. The script +identifies the daemon through its socket credentials and verifies the renderer +belongs to that daemon. It records effective configuration and each sample's +before/after status and resource measurements as JSON. States sampled: -- `playing` with `always-animate on` and a verified `always-animate` or `visible` reason. -- `paused` after `owe pause`, with a verified `manual` reason. -- `policy` only when a policy reason other than `visible`, `always-animate`, `manual`, or `resume` is active. -- `still` when `OWE_BENCH_STILL` names a still image. The previous background is restored. - -Each state is sampled three times by default. The summary reports the -minimum, median, and maximum for renderer and daemon CPU and the resident -memory range. The initial pause and animation state is restored on exit. +- `playing` clears manual pause and enables `always-animate`. +- `paused` uses manual pause. +- `policy` sets the idle pause explicitly. +- `still` runs when `OWE_BENCH_STILL` names an image, and measures the daemon + after the renderer has stopped. + +Each state is sampled three times for five seconds by default. Set +`OWE_BENCH_RUNS` and `OWE_BENCH_SECONDS` to change these durations. The summary +reports median CPU and maximum sampled RSS. Playback state is verified before, +during and after each sample; a process restart, media change, failed load or +advancing paused video aborts the run. Locked sessions require a separate feed +benchmark. The initial manual pause, idle pause, animation override and any +changed background are restored on normal exit, failure, SIGINT or SIGTERM. + +The periodic verification itself adds a small amount of control traffic. These +measurements do not establish GPU utilization, frame pacing or battery power; +capture those separately under a controlled display configuration. No new +hardware performance claims are implied by the regression tests. ## Budget diff --git a/docs/performance-follow-up.md b/docs/performance-follow-up.md index c078d60..627c589 100644 --- a/docs/performance-follow-up.md +++ b/docs/performance-follow-up.md @@ -11,6 +11,23 @@ Related documents: - [Renderer and daemon architecture](architecture.md) - [Omarchy integration contracts](theme-contract.md) +## Subsequent correctness and performance fixes + +The later project review adds coverage for oversized conversion results, +partial IPC requests, slow readers, asynchronous reply ownership, still decode +replacement and cancellation, stalled feed clients, and benchmark restoration. +Both JSON servers now share bounded connection handling. QML deletes its old +scene-graph node when a feed clears and shares copied frames between outputs. +Lock frames are read back at the required cover resolution, without upscaling. + +`bench/bench.sh` now verifies actual playback throughout each sample, rejects +process restarts and media changes, explicitly creates an idle policy pause, +and measures the daemon when a still stops the renderer. It restores manual +pause, idle pause, animation override and any changed selection on exit. +Historical descriptions below refer to the review state when written; use +[the current benchmark method](benchmarks.md) for new measurements. These +regression fixes do not complete the outstanding live GPU and power matrix. + ## Measured baseline The live sample uses a 3840×2160 H.264 video at 24 FPS with one 2880×1800 output. @@ -157,7 +174,7 @@ Measured on eDP-1 with a still background: An always-on renderer for stills measured about 121 MiB RSS and is no longer supported. -The handoff disables the shell plugin before the renderer starts. An +The handoff disables the shell plugin after the renderer starts. An occluded renderer receives no frame callbacks, so waiting for readiness first would deadlock. @@ -165,7 +182,7 @@ The renderer stops its swap loop while the compositor presents nothing, which handles a blanked screen. The daemon pauses playback when Hyprland reports the monitors off. -### Completed checks +### Historical checks at the initial checkpoint - All six registered test suites pass. - All six suites pass under AddressSanitizer and UndefinedBehaviorSanitizer. @@ -227,7 +244,7 @@ References: `src/render/render_ipc.c:115–124`, `src/render/mpv.c:241–246`, ` ## Priority 2: Repair the benchmark method -The current script labels states without verifying them. +The original script labeled states without verifying them. With the default configuration, visible ordinary windows do not cause the `policy-paused` state. An existing manual pause can also invalidate the `video-playing` state. @@ -236,7 +253,7 @@ An existing manual pause can also invalidate the `video-playing` state. - [x] Verify daemon policy and renderer state before each sample. - [x] Create an explicit policy condition for the policy-pause sample. - [x] Add an actual still-image sample. -- [ ] Reject samples when media fails, the renderer exits, or the renderer PID changes. +- [x] Reject samples when media fails, the renderer exits, or the renderer PID changes. - [x] Select the renderer from the tested daemon's child process. - [x] Read `SC_CLK_TCK` instead of assuming 100 ticks per second. - [x] Record elapsed monotonic time instead of assuming the requested sleep duration. @@ -247,10 +264,10 @@ An existing manual pause can also invalidate the `video-playing` state. - [x] Repeat each settled-state sample to expose run-to-run variation. - [ ] Summarize latency distributions and memory peaks, not only averages. -Until the script restores playback state, run it only in a dedicated test session. -Treat existing benchmark state labels as unverified unless the recorded status confirms them. +The current script restores playback state on normal exit and interruption. +Treat historical benchmark state labels as unverified unless their recorded status confirms them. -Reference: `bench/bench.sh:12–57`. +Reference: `bench/bench.py`. ## Priority 3: Complete the missing performance matrix diff --git a/docs/theme-contract.md b/docs/theme-contract.md index c0dae78..341c619 100644 --- a/docs/theme-contract.md +++ b/docs/theme-contract.md @@ -14,16 +14,17 @@ It adds no Omarchy-side hooks and edits no packaged file. - Shell config: `owed` controls `omarchy.background` in `shell.json` at runtime through `omarchy-shell shell setPluginEnabled`. It disables the plugin while a video or GIF plays, and enables it while a still shows. - OWE never draws stills. -- Lock screen: `LockView` renders its own `BackgroundMedia` from the - symlink. It needs no background plugin. + The renderer handles battery posters and still fallback if the shell is unavailable. +- Lock screen: a consumer can import `Owe.LockFeed` to show the daemon's + shared video feed. `BackgroundMedia` remains the fallback for stills or + when the feed is inactive. It needs no background plugin. - Bar sampler, `bg-switcher`, and `bg-cache`: they read files and the symlink. They keep their behavior. ## Shell handoff -Only one background layer owner wins. The installer disables the shell -renderer so `owe-render` owns the background layer alone. +The daemon manages ownership at runtime: it enables the shell background +plugin for stills and releases that layer after the video renderer starts. Uninstall re-enables the background plugin in the current `shell.json`. It preserves shell settings added after installation. diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD index a9292e0..0e4ed23 100644 --- a/packaging/PKGBUILD +++ b/packaging/PKGBUILD @@ -6,8 +6,8 @@ pkgdesc="High-performance wallpaper engine for Omarchy (mp4, gif, stills)" arch=('x86_64' 'aarch64') url="https://github.com/omacom/owe" license=('MIT') -depends=('mpv' 'ffmpeg' 'wayland' 'libglvnd' 'libepoxy' 'systemd-libs' 'socat') -makedepends=('meson' 'ninja' 'gcc' 'pkgconf' 'wayland-protocols') +depends=('mpv' 'ffmpeg' 'wayland' 'libglvnd' 'libepoxy' 'systemd-libs' 'socat' 'qt6-declarative') +makedepends=('meson' 'ninja' 'gcc' 'pkgconf' 'wayland-protocols' 'cmake') checkdepends=('python') optdepends=('intel-media-driver: VAAPI hardware decode on Intel GPUs' 'libva-mesa-driver: VAAPI hardware decode on AMD GPUs') @@ -17,14 +17,19 @@ sha256sums=('af7440d50c3a4b1ebb7fb4968abb508d100ac1111580966a8da2abd8d024877b') build() { meson setup build "$srcdir/owe-$pkgver" -Dbuildtype=release -Dprefix=/usr ninja -C build + cmake -S "$srcdir/owe-$pkgver/qml-plugin" -B build-qml \ + -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_INSTALL_LIBDIR=lib + cmake --build build-qml } check() { meson test -C build + ctest --test-dir build-qml --output-on-failure } package() { DESTDIR="$pkgdir" ninja -C build install + DESTDIR="$pkgdir" cmake --install build-qml install -d "$pkgdir/usr/lib/systemd/user" sed 's|%h/.local/bin/owed|/usr/bin/owed|' "$srcdir/owe-$pkgver/systemd/owed.service" \ >"$pkgdir/usr/lib/systemd/user/owed.service" diff --git a/packaging/install.sh b/packaging/install.sh index 7f88175..961ffd1 100755 --- a/packaging/install.sh +++ b/packaging/install.sh @@ -4,17 +4,16 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" PREFIX="${HOME}/.local" -SHELL_JSON="${HOME}/.config/omarchy/shell.json" missing=() -for command in meson ninja gcc pkg-config ffmpeg socat python3; do +for command in meson ninja gcc pkg-config ffmpeg socat python3 cmake; do command -v "$command" >/dev/null 2>&1 || missing+=("$command") done if ((${#missing[@]})); then printf 'missing: %s\n' "${missing[@]}" >&2 printf '%s\n' \ 'On Omarchy, install the prerequisites with:' \ - ' omarchy pkg add meson ninja gcc pkgconf wayland wayland-protocols libglvnd libepoxy mpv ffmpeg systemd-libs socat python' >&2 + ' omarchy pkg add meson ninja gcc pkgconf wayland wayland-protocols libglvnd libepoxy mpv ffmpeg systemd-libs socat python cmake qt6-declarative' >&2 exit 1 fi @@ -23,6 +22,11 @@ meson setup --reconfigure -Dbuildtype=release "${ROOT}/build" "${ROOT}" >/dev/nu meson setup -Dbuildtype=release "${ROOT}/build" "${ROOT}" ninja -C "${ROOT}/build" meson test -C "${ROOT}/build" +cmake -S "${ROOT}/qml-plugin" -B "${ROOT}/build-qml" \ + -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="${PREFIX}" -DCMAKE_INSTALL_LIBDIR=lib +cmake --build "${ROOT}/build-qml" +ctest --test-dir "${ROOT}/build-qml" --output-on-failure +cmake --install "${ROOT}/build-qml" echo "==> install binaries to ${PREFIX}/bin" install_binary() { @@ -59,4 +63,5 @@ echo "==> enable and (re)start owed.service" systemctl --user enable owed.service systemctl --user restart owed.service +echo "QML consumers need ${PREFIX}/lib/qt6/qml in their QML_IMPORT_PATH." echo "done. run: owe status" diff --git a/packaging/uninstall.sh b/packaging/uninstall.sh index 49b0d52..78b2340 100755 --- a/packaging/uninstall.sh +++ b/packaging/uninstall.sh @@ -38,4 +38,9 @@ rm -f "${HOME}/.config/omarchy/hooks/theme-set.d/10-owe-sync" echo "==> remove binaries" rm -f "${PREFIX}/bin/owe" "${PREFIX}/bin/owed" "${PREFIX}/bin/owe-render" "${PREFIX}/bin/owe-idle" +echo "==> remove the lock feed plugin" +rm -f "${PREFIX}/lib/qt6/qml/Owe/LockFeed/libowe_lockfeed.so" \ + "${PREFIX}/lib/qt6/qml/Owe/LockFeed/qmldir" +rmdir "${PREFIX}/lib/qt6/qml/Owe/LockFeed" 2>/dev/null || true + echo "done. shell background renderer is active again." diff --git a/qml-plugin/CMakeLists.txt b/qml-plugin/CMakeLists.txt index b8f66e1..fac98dd 100644 --- a/qml-plugin/CMakeLists.txt +++ b/qml-plugin/CMakeLists.txt @@ -1,9 +1,11 @@ cmake_minimum_required(VERSION 3.16) project(owe-lockfeed VERSION 0.2.1 LANGUAGES CXX) +include(GNUInstallDirs) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) +set(CMAKE_POSITION_INDEPENDENT_CODE ON) find_package(Qt6 REQUIRED COMPONENTS Core Gui Qml Quick) diff --git a/qml-plugin/src/lockfeed.cpp b/qml-plugin/src/lockfeed.cpp index d885be4..6ce6c17 100644 --- a/qml-plugin/src/lockfeed.cpp +++ b/qml-plugin/src/lockfeed.cpp @@ -11,9 +11,37 @@ #include #include #include +#include + +/* Items on multiple outputs share one CPU copy of each decoded frame. The + * cache lives only as long as its clients and is keyed by the ring's memfd, + * so a renderer restart cannot reuse an old frame sequence. GUI thread only. */ +struct LockFeedFrameCache { + quint64 seq = 0; + QImage image; +}; namespace { +QSharedPointer frameCache(const QString &key) { + static QHash> caches; + for (auto it = caches.begin(); it != caches.end();) { + if (it.value().isNull()) it = caches.erase(it); + else ++it; + } + auto cache = caches.value(key).toStrongRef(); + if (!cache) { + cache = QSharedPointer::create(); + caches.insert(key, cache.toWeakRef()); + } + return cache; +} + +class FrameNode : public QSGSimpleTextureNode { +public: + qint64 frameKey = 0; +}; + constexpr quint32 kMagic = 0x4645574Fu; constexpr quint32 kVersion = 1; @@ -57,7 +85,7 @@ QByteArray writeMessage(quint32 type, quint32 slot, quint64 seq) { QString defaultSocketPath() { const QByteArray runtime = qgetenv("XDG_RUNTIME_DIR"); if (runtime.isEmpty()) { - return QStringLiteral("/run/user/owe/lock-feed.sock"); + return QStringLiteral("/run/user/%1/owe/lock-feed.sock").arg(getuid()); } return QString::fromLocal8Bit(runtime) + QStringLiteral("/owe/lock-feed.sock"); } @@ -196,6 +224,7 @@ void LockFeed::retrySocket() { } void LockFeed::resetMaps() { + m_frameCache.clear(); for (const FrameSlot &slot : m_slots) { if (slot.map && slot.size) { munmap(slot.map, slot.size); @@ -282,6 +311,7 @@ bool LockFeed::handleMessage(const QByteArray &message, const QVector &fds) m_width = (int)msg.width; m_height = (int)msg.height; m_stride = (int)msg.stride; + QString cacheKey; for (int fd : fds) { FrameSlot slot; struct stat st; @@ -289,12 +319,17 @@ bool LockFeed::handleMessage(const QByteArray &message, const QVector &fds) if (fstat(fd, &st) != 0 || st.st_size < (off_t)slot.size) { return false; } + if (cacheKey.isEmpty()) { + cacheKey = QStringLiteral("%1:%2:%3:%4") + .arg(qulonglong(st.st_dev)).arg(qulonglong(st.st_ino)).arg(m_width).arg(m_height); + } slot.map = mmap(nullptr, slot.size, PROT_READ, MAP_SHARED, fd, 0); if (slot.map == MAP_FAILED) { return false; } m_slots.append(slot); } + m_frameCache = frameCache(cacheKey); m_frame = QImage(); update(); return true; @@ -304,16 +339,16 @@ bool LockFeed::handleMessage(const QByteArray &message, const QVector &fds) if (slot < 0 || slot >= m_slots.size() || !m_slots[slot].map || m_width <= 0 || m_height <= 0) { return false; } - QImage image(m_width, m_height, QImage::Format_RGBA8888); - if (image.isNull()) { - return false; + if (!m_frameCache || msg.seq == 0) return false; + if (m_frameCache->seq != msg.seq || m_frameCache->image.isNull()) { + QImage image(static_cast(m_slots[slot].map), m_width, m_height, + m_stride, QImage::Format_RGBA8888); + QImage copy = image.copy(); + if (copy.isNull()) return false; + m_frameCache->image = copy; + m_frameCache->seq = msg.seq; } - for (int row = 0; row < m_height; row++) { - memcpy(image.scanLine(row), - static_cast(m_slots[slot].map) + (size_t)row * (size_t)m_stride, - (size_t)m_width * 4); - } - m_frame = image; + m_frame = m_frameCache->image; update(); const QByteArray ack = writeMessage(Ack, (quint32)slot, msg.seq); return send(m_fd, ack.constData(), (size_t)ack.size(), MSG_NOSIGNAL) == ack.size(); @@ -322,26 +357,32 @@ bool LockFeed::handleMessage(const QByteArray &message, const QVector &fds) } QSGNode *LockFeed::updatePaintNode(QSGNode *node, UpdatePaintNodeData *) { - QSGSimpleTextureNode *textureNode = static_cast(node); + if (m_frame.isNull() || !window()) { + delete node; + return nullptr; + } + FrameNode *textureNode = static_cast(node); if (!textureNode) { - textureNode = new QSGSimpleTextureNode(); + textureNode = new FrameNode(); textureNode->setOwnsTexture(true); textureNode->setFiltering(QSGTexture::Linear); } - if (m_frame.isNull() || !window()) { - return nullptr; - } - QSGTexture *texture = window()->createTextureFromImage(m_frame); - if (!texture) { - return nullptr; + if (textureNode->frameKey != m_frame.cacheKey()) { + QSGTexture *texture = window()->createTextureFromImage(m_frame); + if (!texture) { + delete textureNode; + return nullptr; + } + texture->setFiltering(QSGTexture::Linear); + textureNode->setTexture(texture); + textureNode->frameKey = m_frame.cacheKey(); } - texture->setFiltering(QSGTexture::Linear); - textureNode->setTexture(texture); const QRectF bounds = boundingRect(); const qreal imageWidth = m_frame.width(); const qreal imageHeight = m_frame.height(); if (imageWidth <= 0 || imageHeight <= 0) { + delete textureNode; return nullptr; } if (m_fillMode == Stretch) { diff --git a/qml-plugin/src/lockfeed.h b/qml-plugin/src/lockfeed.h index 34582e1..fcfa462 100644 --- a/qml-plugin/src/lockfeed.h +++ b/qml-plugin/src/lockfeed.h @@ -6,9 +6,11 @@ #include #include #include +#include class QSocketNotifier; class QSGTexture; +struct LockFeedFrameCache; /* Displays the frame stream that the OWE renderer publishes on its lock feed * socket. The renderer owns the buffers and sends them as memfds, so this item @@ -46,6 +48,7 @@ class LockFeed : public QQuickItem { void fillModeChanged(); private: + friend class LockFeedTest; void connectSocket(); void disconnectSocket(); void retrySocket(); @@ -73,4 +76,5 @@ class LockFeed : public QQuickItem { int m_stride = 0; QImage m_frame; + QSharedPointer m_frameCache; }; diff --git a/qml-plugin/src/qmldir b/qml-plugin/src/qmldir index df55012..fed54b2 100644 --- a/qml-plugin/src/qmldir +++ b/qml-plugin/src/qmldir @@ -1,5 +1,4 @@ module Owe.LockFeed plugin owe_lockfeed classname OweLockFeedPlugin -typeinfo owe_lockfeed.qmltypes depends QtQuick diff --git a/qml-plugin/test/lockfeed.cpp b/qml-plugin/test/lockfeed.cpp index b78f78f..342297b 100644 --- a/qml-plugin/test/lockfeed.cpp +++ b/qml-plugin/test/lockfeed.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -120,6 +121,30 @@ class LockFeedTest : public QObject { } private slots: + void defaultRuntimePath() { + const bool hadRuntime = qEnvironmentVariableIsSet("XDG_RUNTIME_DIR"); + const QByteArray runtime = qgetenv("XDG_RUNTIME_DIR"); + qunsetenv("XDG_RUNTIME_DIR"); + LockFeed item; + const QString expected = QStringLiteral("/run/user/%1/owe/lock-feed.sock").arg(getuid()); + const QString actual = item.socketPath(); + if (hadRuntime) qputenv("XDG_RUNTIME_DIR", runtime); + QCOMPARE(actual, expected); + } + + void emptyFrameDeletesNode() { + bool destroyed = false; + struct TrackedNode : QSGSimpleTextureNode { + bool &destroyed; + explicit TrackedNode(bool &flag) : destroyed(flag) {} + ~TrackedNode() override { destroyed = true; } + }; + LockFeed item; + item.setActive(false); + QCOMPARE(item.updatePaintNode(new TrackedNode(destroyed), nullptr), nullptr); + QVERIFY(destroyed); + QCOMPARE(item.updatePaintNode(nullptr, nullptr), nullptr); + } void init() { QVERIFY(m_directory.isValid()); startServer(); @@ -170,6 +195,29 @@ private slots: verifyAck(1); } + void outputsShareFrameCopy() { + LockFeed first; + first.setSocketPath(socketPath()); + acceptClient(); + int firstPeer = m_peer; + m_peer = -1; + LockFeed second; + second.setSocketPath(socketPath()); + acceptClient(); + QTemporaryFile backing; + QVERIFY(backing.open() && backing.resize(4)); + QVERIFY(sendHello(firstPeer, backing.handle(), Message{})); + QVERIFY(sendHello(m_peer, backing.handle(), Message{})); + QVERIFY(sendFrame(firstPeer, 1)); + QTRY_VERIFY(!first.m_frame.isNull()); + QVERIFY(sendFrame(m_peer, 1)); + verifyAck(1); + QCOMPARE(first.m_frame.cacheKey(), second.m_frame.cacheKey()); + first.setActive(false); + QVERIFY(!second.m_frame.isNull()); + close(firstPeer); + } + void reconnectAfterServerRestart() { LockFeed item; item.setSocketPath(socketPath()); diff --git a/src/common/common_ipc.c b/src/common/common_ipc.c index ca2678c..3dbf279 100644 --- a/src/common/common_ipc.c +++ b/src/common/common_ipc.c @@ -11,6 +11,107 @@ #include #include #include +#include + +int64_t owe_ipc_now_ms(void) { + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + return (int64_t)now.tv_sec * 1000 + now.tv_nsec / 1000000; +} + +void owe_ipc_client_close(struct owe_ipc_client *c) { + if (c->fd >= 0) close(c->fd); + c->fd = -1; + c->len = 0; + c->request_ms = 0; + free(c->output); + c->output = NULL; + c->output_len = c->output_sent = 0; +} + +void owe_ipc_client_open(struct owe_ipc_client *c, int fd) { + owe_ipc_client_close(c); + c->fd = fd; + c->generation++; + c->active_ms = owe_ipc_now_ms(); +} + +short owe_ipc_client_events(const struct owe_ipc_client *c) { + return POLLIN | (c->output_len > c->output_sent ? POLLOUT : 0); +} + +static void client_flush(struct owe_ipc_client *c) { + while (c->fd >= 0 && c->output_sent < c->output_len) { + ssize_t n = send(c->fd, c->output + c->output_sent, + c->output_len - c->output_sent, MSG_NOSIGNAL | MSG_DONTWAIT); + if (n < 0 && errno == EINTR) continue; + if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) return; + if (n <= 0) { + owe_ipc_client_close(c); + return; + } + c->output_sent += (size_t)n; + } + free(c->output); + c->output = NULL; + c->output_len = c->output_sent = 0; +} + +void owe_ipc_client_send(struct owe_ipc_client *c, const char *line) { + if (c->fd < 0 || !line) return; + size_t len = strlen(line); + size_t pending = c->output_len - c->output_sent; + if (len >= OWE_IPC_MAX_LINE || pending + len + 1 > 4 * OWE_IPC_MAX_LINE) { + owe_ipc_client_close(c); + return; + } + if (pending) memmove(c->output, c->output + c->output_sent, pending); + char *output = realloc(c->output, pending + len + 1); + if (!output) { + owe_ipc_client_close(c); + return; + } + c->output = output; + memcpy(output + pending, line, len); + output[pending + len] = '\n'; + c->output_len = pending + len + 1; + c->output_sent = 0; + client_flush(c); +} + +void owe_ipc_client_poll(struct owe_ipc_client *c, int64_t now, owe_ipc_handler handler, + void *context) { + if (c->fd < 0) return; + if (now - c->active_ms >= OWE_IPC_CLIENT_IDLE_MS || + (c->len && now - c->request_ms >= OWE_IPC_CLIENT_IDLE_MS)) { + owe_ipc_client_close(c); + return; + } + client_flush(c); + if (c->fd < 0) return; + ssize_t n = recv(c->fd, c->buf + c->len, sizeof(c->buf) - c->len - 1, MSG_DONTWAIT); + if (n < 0 && (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK)) return; + if (n <= 0 || memchr(c->buf + c->len, '\0', (size_t)n)) { + owe_ipc_client_close(c); + return; + } + if (!c->len) c->request_ms = now; + c->active_ms = now; + c->len += (size_t)n; + c->buf[c->len] = '\0'; + char *nl; + while ((nl = strchr(c->buf, '\n'))) { + *nl = '\0'; + if (nl > c->buf && nl[-1] == '\r') nl[-1] = '\0'; + if (*c->buf) handler(context, c, c->buf); + if (c->fd < 0) return; + size_t used = (size_t)(nl - c->buf) + 1; + memmove(c->buf, c->buf + used, c->len - used + 1); + c->len -= used; + c->request_ms = now; + } + if (c->len == sizeof(c->buf) - 1) owe_ipc_client_close(c); +} struct owe_ipc_server { int fd; diff --git a/src/common/common_ipc.h b/src/common/common_ipc.h index 6db6c02..245aca8 100644 --- a/src/common/common_ipc.h +++ b/src/common/common_ipc.h @@ -3,10 +3,34 @@ #include #include #include +#include #define OWE_IPC_MAX_LINE 65536 #define OWE_IPC_VERSION 1 #define OWE_IPC_MAX_CLIENTS 16 +#define OWE_IPC_CLIENT_IDLE_MS 120000 + +/* Shared, bounded JSON-line transport for the daemon and renderer. */ +struct owe_ipc_client { + int fd; + uint64_t generation; + char buf[OWE_IPC_MAX_LINE]; + size_t len; + int64_t active_ms; + int64_t request_ms; + char *output; + size_t output_len; + size_t output_sent; +}; + +typedef void (*owe_ipc_handler)(void *context, struct owe_ipc_client *client, const char *line); +int64_t owe_ipc_now_ms(void); +void owe_ipc_client_open(struct owe_ipc_client *c, int fd); +void owe_ipc_client_close(struct owe_ipc_client *c); +short owe_ipc_client_events(const struct owe_ipc_client *c); +void owe_ipc_client_send(struct owe_ipc_client *c, const char *line); +void owe_ipc_client_poll(struct owe_ipc_client *c, int64_t now, owe_ipc_handler handler, + void *context); int owe_ipc_listen(const char *path); int owe_ipc_connect(const char *path); diff --git a/src/common/log.c b/src/common/log.c index cb69801..9ed6c50 100644 --- a/src/common/log.c +++ b/src/common/log.c @@ -19,6 +19,7 @@ void owe_log_set_level(owe_log_level_t level) { } void owe_log(owe_log_level_t level, const char *file, int line, const char *fmt, ...) { + if (!fmt) return; static const char *names[] = { "DEBUG", "INFO", "WARN", "ERROR" }; FILE *out; struct timespec ts; diff --git a/src/daemon/daemon.h b/src/daemon/daemon.h index 42be083..fe7a820 100644 --- a/src/daemon/daemon.h +++ b/src/daemon/daemon.h @@ -33,6 +33,7 @@ typedef struct owed_app { int engine; int shell_enabled; /* -1 unknown, 0 disabled, 1 enabled */ int64_t shell_stop_at_ms; + int64_t shell_retry_at_ms; int64_t renderer_retry_at_ms; char source_path[4096]; @@ -51,7 +52,9 @@ typedef struct owed_app { int64_t media_deadline_ms; owed_async_job_t *job; + bool job_is_poster; char fail_path[4096]; + char poster_fail_path[4096]; int render_paused; /* -1 unknown, 0 playing, 1 paused */ int render_feeding; /* 1 while the lock feed owns the renderer */ unsigned long source_generation; diff --git a/src/daemon/daemon_ipc.c b/src/daemon/daemon_ipc.c index f052cc3..dad3eab 100644 --- a/src/daemon/daemon_ipc.c +++ b/src/daemon/daemon_ipc.c @@ -15,7 +15,6 @@ #include "common_ipc.h" #include "daemon.h" #include "hypr.h" -#include "common_ipc.h" #include "log.h" #include "policy.h" #include "power.h" @@ -27,58 +26,38 @@ #include "xdg.h" #define MAX_CLIENTS OWE_IPC_MAX_CLIENTS -#define CLIENT_IDLE_MS 120000 - -struct owed_client { - int fd; - char buf[OWE_IPC_MAX_LINE]; - size_t len; - int64_t active_ms; -}; - struct owed_ipc { owe_ipc_server_t *srv; char path[PATH_MAX]; - struct owed_client clients[MAX_CLIENTS]; + struct owe_ipc_client clients[MAX_CLIENTS]; }; -static int64_t now_ms(void) { - struct timespec now; - clock_gettime(CLOCK_MONOTONIC, &now); - return (int64_t)now.tv_sec * 1000 + now.tv_nsec / 1000000; -} - static void client_remove(struct owed_ipc *ipc, int idx) { - if (ipc->clients[idx].fd >= 0) { - close(ipc->clients[idx].fd); - } - ipc->clients[idx].fd = -1; - ipc->clients[idx].len = 0; - ipc->clients[idx].active_ms = 0; + owe_ipc_client_close(&ipc->clients[idx]); } -static void send_ok(struct owed_client *c, const char *extra) { +static void send_ok(struct owe_ipc_client *c, const char *extra) { char line[OWE_IPC_MAX_LINE]; if (extra && *extra) { snprintf(line, sizeof(line), "{\"status\":\"ok\",%s}", extra); } else { snprintf(line, sizeof(line), "{\"status\":\"ok\"}"); } - owe_ipc_send_line(c->fd, line); + owe_ipc_client_send(c, line); } -static void send_err(struct owed_client *c, const char *msg) { +static void send_err(struct owe_ipc_client *c, const char *msg) { char line[1024]; snprintf(line, sizeof(line), "{\"status\":\"error\",\"message\":\"%s\"}", msg ? msg : "failed"); - owe_ipc_send_line(c->fd, line); + owe_ipc_client_send(c, line); } -static void handle_status(struct owed_client *c) { +static void handle_status(struct owe_ipc_client *c) { owed_app_t *app = owed_app_get(); char *line = NULL; char *source = owe_json_quote(app->source_path); char *loaded = owe_json_quote(app->loaded_path); - char *failed = owe_json_quote(app->fail_path); + char *failed = owe_json_quote(*app->fail_path ? app->fail_path : app->poster_fail_path); if (!source || !loaded || !failed) goto done; if (asprintf(&line, "{\"status\":\"ok\",\"source_path\":%s,\"source_kind\":\"%s\"," @@ -105,7 +84,7 @@ static void handle_status(struct owed_client *c) { ? "true" : "false", app->supervisor && owed_render_is_alive(app->supervisor) ? "true" : "false") >= 0) - owe_ipc_send_line(c->fd, line); + owe_ipc_client_send(c, line); done: free(line); free(source); @@ -113,7 +92,7 @@ static void handle_status(struct owed_client *c) { free(failed); } -static void handle_config(struct owed_client *c) { +static void handle_config(struct owe_ipc_client *c) { owed_app_t *app = owed_app_get(); char *blocklist = NULL; char *line = NULL; @@ -154,13 +133,13 @@ static void handle_config(struct owed_client *c) { app->config.gif_crf, app->config.transcode_max_width, app->config.transcode_max_height, app->config.cache_max_mb, app->config.fade_ms, app->config.blocklist_count, blocklist) >= 0) { - owe_ipc_send_line(c->fd, line); + owe_ipc_client_send(c, line); } free(line); free(blocklist); } -static void handle_set(struct owed_client *c, yyjson_val *root) { +static void handle_set(struct owe_ipc_client *c, yyjson_val *root) { yyjson_val *v = yyjson_obj_get(root, "path"); const char *path = v && yyjson_is_str(v) ? yyjson_get_str(v) : ""; if (!owe_json_path(v)) { @@ -174,7 +153,8 @@ static void handle_set(struct owed_client *c, yyjson_val *root) { send_ok(c, NULL); } -static void handle_command(struct owed_ipc *ipc, struct owed_client *c, const char *line) { +static void handle_command(void *context, struct owe_ipc_client *c, const char *line) { + struct owed_ipc *ipc = context; owed_app_t *app = owed_app_get(); yyjson_doc *doc; yyjson_val *root; @@ -260,7 +240,7 @@ static void handle_command(struct owed_ipc *ipc, struct owed_client *c, const ch "\"has_video\":%s,\"has_still\":%s,\"time_pos\":-1.000," "\"hwdec\":\"no\",\"error\":\"\",\"skipped\":\"\"}", path, kind, video ? "true" : "false", video ? "false" : "true") >= 0) { - owe_ipc_send_line(c->fd, line); + owe_ipc_client_send(c, line); } else { send_err(c, "render unavailable"); } @@ -270,7 +250,7 @@ static void handle_command(struct owed_ipc *ipc, struct owed_client *c, const ch } else { char reply[8192]; if (owed_supervisor_send(app->supervisor, "{\"cmd\":\"status\"}", reply, sizeof(reply)) == 0) { - owe_ipc_send_line(c->fd, reply); + owe_ipc_client_send(c, reply); } else { send_err(c, "render unreachable"); } @@ -284,15 +264,7 @@ static void handle_command(struct owed_ipc *ipc, struct owed_client *c, const ch } owed_supervisor_stop(app->supervisor); if (owed_supervisor_ensure_running(app->supervisor) == 0) { - char resolved[4096]; - app->loaded_path[0] = '\0'; - app->loaded_kind[0] = '\0'; - app->render_paused = -1; - owed_supervisor_fade(app->supervisor, app->config.fade_ms); - if (owed_watch_resolve_current(resolved, sizeof(resolved)) == 0) { - app->source_path[0] = '\0'; - owed_app_on_background_changed(resolved); - } + owed_app_on_renderer_restarted(); send_ok(c, NULL); } else { send_err(c, "render restart failed"); @@ -357,7 +329,7 @@ int owed_ipc_pollfds(struct owed_ipc *ipc, struct pollfd *fds) { int n = 0; for (int i = 0; ipc && i < MAX_CLIENTS; i++) { if (ipc->clients[i].fd >= 0) - fds[n++] = (struct pollfd){.fd = ipc->clients[i].fd, .events = POLLIN}; + fds[n++] = (struct pollfd){.fd = ipc->clients[i].fd, .events = owe_ipc_client_events(&ipc->clients[i])}; } return n; } @@ -371,9 +343,7 @@ void owed_ipc_accept(struct owed_ipc *ipc) { while ((fd = owe_ipc_server_accept(ipc->srv)) >= 0) { for (i = 0; i < MAX_CLIENTS; i++) { if (ipc->clients[i].fd < 0) { - ipc->clients[i].fd = fd; - ipc->clients[i].len = 0; - ipc->clients[i].active_ms = now_ms(); + owe_ipc_client_open(&ipc->clients[i], fd); break; } } @@ -384,57 +354,10 @@ void owed_ipc_accept(struct owed_ipc *ipc) { } void owed_ipc_poll_clients(struct owed_ipc *ipc) { - int i; - if (!ipc) { - return; - } - for (i = 0; i < MAX_CLIENTS; i++) { - struct owed_client *c = &ipc->clients[i]; - ssize_t n; - char *nl; - if (c->fd < 0) { - continue; - } - if (c->len == 0 && now_ms() - c->active_ms > CLIENT_IDLE_MS) { - client_remove(ipc, i); - continue; - } - n = recv(c->fd, c->buf + c->len, sizeof(c->buf) - c->len - 1, 0); - if (n == 0) { - client_remove(ipc, i); - continue; - } - if (n < 0) { - if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) { - continue; - } - client_remove(ipc, i); - continue; - } - if (memchr(c->buf + c->len, '\0', (size_t)n)) { - client_remove(ipc, i); - continue; - } - c->len += (size_t)n; - c->buf[c->len] = '\0'; - c->active_ms = now_ms(); - while ((nl = strchr(c->buf, '\n')) != NULL) { - *nl = '\0'; - if (nl > c->buf && nl[-1] == '\r') { - nl[-1] = '\0'; - } - if (*c->buf) { - handle_command(ipc, c, c->buf); - } - { - size_t used = (size_t)(nl - c->buf) + 1; - memmove(c->buf, c->buf + used, c->len - used + 1); - c->len -= used; - } - } - if (c->len >= sizeof(c->buf) - 1) { - client_remove(ipc, i); - } + if (!ipc) return; + int64_t now = owe_ipc_now_ms(); + for (int i = 0; i < MAX_CLIENTS; i++) { + owe_ipc_client_poll(&ipc->clients[i], now, handle_command, ipc); } } diff --git a/src/daemon/main.c b/src/daemon/main.c index ea2883b..3ef173f 100644 --- a/src/daemon/main.c +++ b/src/daemon/main.c @@ -96,7 +96,10 @@ static int load_if_needed(const char *path, const char *kind) { /* The renderer keeps its previous media after a rejected load, so * loaded_path stays as the last successful media to keep policy in * control of what is playing. */ - copy_path(app->fail_path, sizeof(app->fail_path), app->source_path); + char *failed = strcmp(kind, "still") == 0 && battery_poster_active() && + strcmp(path, app->source_path) != 0 + ? app->poster_fail_path : app->fail_path; + copy_path(failed, sizeof(app->fail_path), app->source_path); return -1; } /* Snapshot the media that already proved it plays. A video whose decode @@ -127,9 +130,7 @@ static int apply_feed_state(void) { } locked = (app->power && owed_power_locked(app->power)) || (app->hypr && owed_hypr_locked(app->hypr)); - want_feed = locked && !owed_policy_manual_pause(app->policy) && - !(app->power && owed_power_sleeping(app->power)) && - !(app->hypr && owed_hypr_all_monitors_off(app->hypr)) && + want_feed = locked && owed_policy_allows_feed(app->policy) && strcmp(app->loaded_kind, "video") == 0 && owed_render_is_alive(app->supervisor); if (want_feed) { @@ -193,9 +194,14 @@ bool owed_app_renderer_expected(void) { * costs only the daemon. */ static int switch_to_shell(void) { owed_app_t *app = &g_app; + int64_t now = monotonic_ms(); + if (now < app->shell_retry_at_ms) return -1; if (app->shell_enabled != 1 && owed_shell_plugin_set(true) != 0) { + OWE_WARN("shell handoff failed, keeping the renderer for the still"); + app->shell_retry_at_ms = now + 30000; return -1; } + app->shell_retry_at_ms = 0; app->shell_enabled = 1; app->engine = OWE_ENGINE_SHELL; app->media_pending = false; @@ -208,8 +214,7 @@ static int switch_to_shell(void) { return 0; } -/* Start the renderer. The shell plugin is disabled once the new media is - * ready, so the handoff never shows a black frame. */ +/* Start the renderer and release the shell layer so it can receive frames. */ static int switch_to_renderer(void) { owed_app_t *app = &g_app; int64_t now = monotonic_ms(); @@ -217,6 +222,7 @@ static int switch_to_renderer(void) { return -1; } if (owed_supervisor_ensure_running(app->supervisor) != 0) { + OWE_ERROR("renderer start failed"); app->renderer_retry_at_ms = now + 30000; return -1; } @@ -346,6 +352,11 @@ static void sync_output_skips(void) { name[len] = '\0'; quoted = owe_json_quote(name); if (quoted) { + size_t needed = strlen(quoted) + (used > 1 ? 1 : 0); + if (needed > sizeof(array) - used - 2) { + free(quoted); + return; + } used += (size_t)snprintf(array + used, sizeof(array) - used, "%s%s", used > 1 ? "," : "", quoted); free(quoted); @@ -376,6 +387,7 @@ void owed_app_apply_policy(void) { if (!*app->source_path) { return; } + if (!battery_poster_active()) app->poster_fail_path[0] = '\0'; /* OWE owns video and GIF backgrounds only. A still background belongs to * Omarchy's shell, so the renderer never starts for one. */ shell_engine = strcmp(app->source_kind, "still") == 0; @@ -385,15 +397,12 @@ void owed_app_apply_policy(void) { OWE_INFO("engine: shell"); return; } - OWE_WARN("shell handoff failed, keeping the renderer for the still"); } else { return; } } if (app->engine != OWE_ENGINE_RENDERER) { if (switch_to_renderer() != 0) { - OWE_ERROR("renderer start failed"); - copy_path(app->fail_path, sizeof(app->fail_path), app->source_path); return; } OWE_INFO("engine: renderer"); @@ -416,11 +425,13 @@ void owed_app_apply_policy(void) { app->config.transcode_max_width, app->config.transcode_max_height, cache, sizeof(cache)) != 0) { OWE_ERROR("cannot resolve gif cache path: %s", app->source_path); + copy_path(app->fail_path, sizeof(app->fail_path), app->source_path); return; } if (owed_transcode_file_ready(cache)) { snprintf(video, sizeof(video), "%s", cache); } else { + app->job_is_poster = false; app->job = owed_async_gif(app->source_path, app->config.gif_fps, app->config.gif_crf, app->config.transcode_max_width, app->config.transcode_max_height); @@ -440,22 +451,26 @@ void owed_app_apply_policy(void) { } if (battery_poster_active()) { + if (strcmp(app->poster_fail_path, app->source_path) == 0) { + apply_playback_state(); + return; + } if (owed_transcode_poster_path(video, cache, sizeof(cache)) != 0) { OWE_ERROR("cannot resolve poster path: %s", app->source_path); + copy_path(app->poster_fail_path, sizeof(app->poster_fail_path), app->source_path); return; } if (owed_transcode_file_ready(cache)) { - app->fail_path[0] = '\0'; + app->poster_fail_path[0] = '\0'; finish_media(cache, "still"); return; } - if (strcmp(app->fail_path, app->source_path) == 0) { - return; - } if (strcmp(app->loaded_kind, "video") == 0) apply_playback_state(); + app->job_is_poster = true; app->job = owed_async_poster(video); if (!app->job) { OWE_ERROR("cannot start poster job: %s", app->source_path); + copy_path(app->poster_fail_path, sizeof(app->poster_fail_path), app->source_path); } else { OWE_INFO("poster extraction started for %s", app->source_path); } @@ -474,13 +489,14 @@ void owed_app_on_job_done(void) { if (!app->job) { return; } + char *failed = app->job_is_poster ? app->poster_fail_path : app->fail_path; snprintf(input, sizeof(input), "%s", owed_async_job_input(app->job)); if (owed_async_job_finish(app->job, &result) != 0) { OWE_WARN("job for %s produced no result", input); - copy_path(app->fail_path, sizeof(app->fail_path), app->source_path); + copy_path(failed, sizeof(app->fail_path), app->source_path); } else if (!result.ok) { OWE_ERROR("job failed for %s", input); - copy_path(app->fail_path, sizeof(app->fail_path), app->source_path); + copy_path(failed, sizeof(app->fail_path), app->source_path); } else { OWE_INFO("job done for %s", input); } @@ -495,6 +511,7 @@ void owed_app_on_renderer_restarted(void) { g_app.render_paused = -1; g_app.render_feeding = 0; g_app.fail_path[0] = '\0'; + g_app.poster_fail_path[0] = '\0'; g_app.media_pending = false; g_last_skip[0] = '\0'; owed_supervisor_fade(g_app.supervisor, g_app.config.fade_ms); @@ -525,6 +542,7 @@ void owed_app_on_background_changed(const char *resolved_path) { snprintf(app->source_path, sizeof(app->source_path), "%s", resolved_path); snprintf(app->source_kind, sizeof(app->source_kind), "%s", owe_kind_to_string(kind)); app->fail_path[0] = '\0'; + app->poster_fail_path[0] = '\0'; owed_policy_recompute(app->policy); owed_app_apply_policy(); owed_app_emit_event("background", resolved_path); @@ -693,6 +711,7 @@ int main(int argc, char **argv) { } } owed_transcode_set_cache_limit(g_app.config.cache_max_mb); + owed_transcode_cleanup_cache(); g_app.supervisor = owed_supervisor_new(); g_app.policy = owed_policy_new(); @@ -796,8 +815,6 @@ int main(int argc, char **argv) { owed_watch_poll(g_app.watch); } else if (pfds[i].fd == owed_hypr_event_fd(g_app.hypr)) { owed_hypr_poll(g_app.hypr); - } else if (pfds[i].fd == owed_power_fd_system(g_app.power)) { - owed_power_poll(g_app.power); } else if (pfds[i].fd == owed_ipc_fd(g_app.ipc)) { owed_ipc_accept(g_app.ipc); } else if (have_job && job_generation == g_app.source_generation && @@ -808,6 +825,7 @@ int main(int argc, char **argv) { if (!g_app.running) break; owed_ipc_poll_clients(g_app.ipc); if (!g_app.running) break; + owed_power_poll(g_app.power); check_media_ready(); sync_output_skips(); process_shell_handoff(); diff --git a/src/daemon/policy.c b/src/daemon/policy.c index 3dce881..290b45b 100644 --- a/src/daemon/policy.c +++ b/src/daemon/policy.c @@ -72,9 +72,9 @@ void owed_policy_recompute(struct owed_policy *p) { goto done; } if (app->power && owed_power_on_battery(app->power)) { - bool poster = app->config.battery_poster || - strcmp(app->config.battery_mode, "poster") == 0; - if (poster) { + bool want_poster = app->config.battery_poster || + strcmp(app->config.battery_mode, "poster") == 0; + if (want_poster) { pause = true; poster = true; reason = "battery"; @@ -120,6 +120,20 @@ bool owed_policy_should_poster(struct owed_policy *p) { return p && p->poster; } +/* A lock feed bypasses desktop visibility, but preserves explicit pauses and + * power-saving rules even when "locked" is the highest-priority reason. */ +bool owed_policy_allows_feed(struct owed_policy *p) { + owed_app_t *app = owed_app_get(); + if (!p || !app || p->manual_pause || owed_power_sleeping(app->power) || + (app->hypr && owed_hypr_all_monitors_off(app->hypr))) return false; + if (app->always_animate) return true; + bool battery_pause = owed_power_on_battery(app->power) && + (app->config.battery_poster || + strcmp(app->config.battery_mode, "poster") == 0 || + strcmp(app->config.battery_mode, "pause") == 0); + return !p->idle_pause && !p->blocklisted && !battery_pause; +} + const char *owed_policy_reason(struct owed_policy *p) { return p ? p->reason : ""; } diff --git a/src/daemon/policy.h b/src/daemon/policy.h index ad7e6f3..e1657fe 100644 --- a/src/daemon/policy.h +++ b/src/daemon/policy.h @@ -10,6 +10,7 @@ void owed_policy_free(struct owed_policy *p); void owed_policy_recompute(struct owed_policy *p); bool owed_policy_should_pause(struct owed_policy *p); bool owed_policy_should_poster(struct owed_policy *p); +bool owed_policy_allows_feed(struct owed_policy *p); const char *owed_policy_reason(struct owed_policy *p); void owed_policy_set_blocklisted(struct owed_policy *p, bool blocked); diff --git a/src/daemon/power.c b/src/daemon/power.c index 959c1be..8078119 100644 --- a/src/daemon/power.c +++ b/src/daemon/power.c @@ -1,4 +1,6 @@ #include "power.h" +#include +#include #include #include #include @@ -9,6 +11,7 @@ struct owed_power { sd_bus *system; + int64_t retry_at_ms; bool on_battery; bool locked; bool sleeping; @@ -61,15 +64,23 @@ static int sleep_signal(sd_bus_message *m, void *userdata, sd_bus_error *error) return 0; } -struct owed_power *owed_power_new(void) { - struct owed_power *p = calloc(1, sizeof(*p)); - if (!p) return NULL; - if (sd_bus_open_system(&p->system) < 0) return p; +static int64_t power_now_ms(void) { + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + return (int64_t)now.tv_sec * 1000 + now.tv_nsec / 1000000; +} + +static void connect_system(struct owed_power *p) { + p->retry_at_ms = power_now_ms() + 30000; + if (sd_bus_open_system(&p->system) < 0) return; sd_bus_set_method_call_timeout(p->system, 500000); int value = 0; - sd_bus_get_property_trivial(p->system, "org.freedesktop.UPower", "/org/freedesktop/UPower", - "org.freedesktop.UPower", "OnBattery", NULL, 'b', &value); - p->on_battery = value; + if (sd_bus_get_property_trivial(p->system, "org.freedesktop.UPower", "/org/freedesktop/UPower", + "org.freedesktop.UPower", "OnBattery", NULL, 'b', &value) >= 0) + p->on_battery = value; + if (sd_bus_get_property_trivial(p->system, "org.freedesktop.login1", "/org/freedesktop/login1", + "org.freedesktop.login1.Manager", "PreparingForSleep", NULL, 'b', &value) >= 0) + p->sleeping = value; sd_bus_match_signal(p->system, NULL, "org.freedesktop.UPower", "/org/freedesktop/UPower", "org.freedesktop.DBus.Properties", "PropertiesChanged", properties, p); sd_bus_match_signal(p->system, NULL, "org.freedesktop.login1", "/org/freedesktop/login1", @@ -85,9 +96,9 @@ struct owed_power *owed_power_new(void) { const char *path; if (sd_bus_message_read(reply, "o", &path) >= 0) { value = 0; - sd_bus_get_property_trivial(p->system, "org.freedesktop.login1", path, - "org.freedesktop.login1.Session", "LockedHint", NULL, 'b', &value); - p->locked = value; + if (sd_bus_get_property_trivial(p->system, "org.freedesktop.login1", path, + "org.freedesktop.login1.Session", "LockedHint", NULL, 'b', &value) >= 0) + p->locked = value; sd_bus_match_signal(p->system, NULL, "org.freedesktop.login1", path, "org.freedesktop.login1.Session", "Lock", lock_signal, p); sd_bus_match_signal(p->system, NULL, "org.freedesktop.login1", path, @@ -98,6 +109,11 @@ struct owed_power *owed_power_new(void) { } sd_bus_message_unref(reply); free(sid); +} + +struct owed_power *owed_power_new(void) { + struct owed_power *p = calloc(1, sizeof(*p)); + if (p) connect_system(p); return p; } @@ -109,7 +125,21 @@ void owed_power_free(struct owed_power *p) { int owed_power_fd_system(struct owed_power *p) { return p && p->system ? sd_bus_get_fd(p->system) : -1; } int owed_power_poll(struct owed_power *p) { - if (p && p->system) while (sd_bus_process(p->system, NULL) > 0) {} + if (!p) return 0; + if (!p->system && power_now_ms() >= p->retry_at_ms) { + connect_system(p); + if (p->system) owed_app_on_policy_changed(); + } + if (p->system) { + int rc; + while ((rc = sd_bus_process(p->system, NULL)) > 0) {} + if (rc < 0) { + OWE_WARN("system bus disconnected; retrying in 30 seconds"); + p->system = sd_bus_close_unref(p->system); + p->retry_at_ms = power_now_ms() + 30000; + return -1; + } + } return 0; } bool owed_power_on_battery(struct owed_power *p) { return p && p->on_battery; } diff --git a/src/daemon/supervisor.c b/src/daemon/supervisor.c index 8e6eca5..e572fab 100644 --- a/src/daemon/supervisor.c +++ b/src/daemon/supervisor.c @@ -174,7 +174,9 @@ int owed_supervisor_send(struct owed_supervisor *s, const char *line, char *repl if (!s || !line) { return -1; } - if (owed_supervisor_ensure_running(s) != 0) { + /* Only the daemon's restart path may spawn: it also resets loaded-media + * and playback state. A send must never silently create an empty renderer. */ + if (!owed_render_is_alive(s)) { return -1; } fd = owe_ipc_connect(s->socket_path); diff --git a/src/daemon/transcode.c b/src/daemon/transcode.c index a502e64..86bdccc 100644 --- a/src/daemon/transcode.c +++ b/src/daemon/transcode.c @@ -47,10 +47,10 @@ bool owed_transcode_file_ready(const char *path) { return path && stat(path, &st) == 0 && S_ISREG(st.st_mode) && st.st_size > 0; } -static int g_cache_max_mb = 512; +static atomic_int g_cache_max_mb = 512; void owed_transcode_set_cache_limit(int max_mb) { - g_cache_max_mb = max_mb; + atomic_store(&g_cache_max_mb, max_mb); } struct cache_entry { @@ -69,7 +69,7 @@ static int cache_entry_cmp(const void *a, const void *b) { /* Remove the oldest cache files until the budget is met. A limit of 0 or * less disables eviction. */ -void owed_transcode_prune_cache(void) { +static void prune_cache(const char *keep, const char *input) { char dir[PATH_MAX]; DIR *handle; struct dirent *entry; @@ -78,7 +78,8 @@ void owed_transcode_prune_cache(void) { size_t capacity = 0; long long total = 0; long long budget; - if (g_cache_max_mb <= 0) { + int max_mb = atomic_load(&g_cache_max_mb); + if (max_mb <= 0) { return; } if (owe_transcode_cache_dir(dir, sizeof(dir)) != 0) { @@ -94,6 +95,7 @@ void owed_transcode_prune_cache(void) { if (entry->d_name[0] == '.') { continue; } + if (strstr(entry->d_name, ".part-")) continue; if (snprintf(path, sizeof(path), "%s/%s", dir, entry->d_name) >= (int)sizeof(path)) { continue; } @@ -116,11 +118,15 @@ void owed_transcode_prune_cache(void) { count++; } closedir(handle); - budget = (long long)g_cache_max_mb * 1024 * 1024; + budget = (long long)max_mb * 1024 * 1024; if (total > budget && count > 0) { size_t i; qsort(files, count, sizeof(*files), cache_entry_cmp); for (i = 0; i < count && total > budget; i++) { + /* The current result and its source must survive until the daemon + * loads them. A single oversized selection may exceed the budget. */ + if ((keep && strcmp(files[i].path, keep) == 0) || + (input && strcmp(files[i].path, input) == 0)) continue; if (unlink(files[i].path) == 0) { total -= files[i].size; OWE_INFO("cache pruned %s", files[i].path); @@ -130,6 +136,30 @@ void owed_transcode_prune_cache(void) { free(files); } +void owed_transcode_prune_cache(void) { + prune_cache(NULL, NULL); +} + +/* Conversion jobs time out after five minutes. A day's grace also avoids + * interfering with an FFmpeg child left running by a just-killed daemon. */ +void owed_transcode_cleanup_cache(void) { + char path[PATH_MAX]; + if (owe_transcode_cache_dir(path, sizeof(path)) != 0) return; + DIR *dir = opendir(path); + if (!dir) return; + time_t now = time(NULL); + struct dirent *entry; + while ((entry = readdir(dir))) { + struct stat st; + if (!strstr(entry->d_name, ".part-") || + fstatat(dirfd(dir), entry->d_name, &st, AT_SYMLINK_NOFOLLOW) != 0 || + !S_ISREG(st.st_mode) || now < st.st_mtime || now - st.st_mtime < 86400) continue; + if (unlinkat(dirfd(dir), entry->d_name, 0) == 0) + OWE_INFO("removed abandoned conversion %s", entry->d_name); + } + closedir(dir); +} + static int gif_cache_path(const char *gif_path, int fps, int crf, int max_w, int max_h, char *out, unsigned long out_len) { char dir[PATH_MAX]; @@ -256,7 +286,7 @@ static int run_gif(const owed_async_job_t *job, char *out, unsigned long out_len } snprintf(out, out_len, "%s", cached); OWE_INFO("gif cached %s", cached); - owed_transcode_prune_cache(); + prune_cache(cached, job->input); return 0; } @@ -302,7 +332,7 @@ static int run_poster(const owed_async_job_t *job, char *out, unsigned long out_ return -1; } snprintf(out, out_len, "%s", poster); - owed_transcode_prune_cache(); + prune_cache(poster, job->input); return 0; } diff --git a/src/daemon/transcode.h b/src/daemon/transcode.h index 1b3a739..375a310 100644 --- a/src/daemon/transcode.h +++ b/src/daemon/transcode.h @@ -20,6 +20,7 @@ bool owed_transcode_file_ready(const char *path); void owed_transcode_set_cache_limit(int max_mb); void owed_transcode_prune_cache(void); +void owed_transcode_cleanup_cache(void); owed_async_job_t *owed_async_gif(const char *gif_path, int fps, int crf, int max_w, int max_h); owed_async_job_t *owed_async_poster(const char *video_path); diff --git a/src/render/egl.c b/src/render/egl.c index ef6ab2e..935d5d9 100644 --- a/src/render/egl.c +++ b/src/render/egl.c @@ -220,6 +220,7 @@ int owe_egl_make_current(struct owe_egl *egl) { if (!egl) { return -1; } + if (eglGetCurrentContext() == egl->context) return 0; if (eglMakeCurrent(egl->display, EGL_NO_SURFACE, EGL_NO_SURFACE, egl->context)) { egl->current = EGL_NO_SURFACE; return 0; @@ -257,7 +258,12 @@ void owe_egl_destroy_output(struct owe_egl *egl, struct owe_output *out) { return; } if (out->egl_surface) { - owe_egl_make_current(egl); + if (eglGetCurrentSurface(EGL_DRAW) == (EGLSurface)out->egl_surface) { + if (!eglMakeCurrent(egl->display, EGL_NO_SURFACE, EGL_NO_SURFACE, egl->context)) { + OWE_ERROR("cannot release output surface: 0x%x", eglGetError()); + } + egl->current = EGL_NO_SURFACE; + } eglDestroySurface(egl->display, (EGLSurface)out->egl_surface); out->egl_surface = NULL; } diff --git a/src/render/feed.c b/src/render/feed.c index 65f938a..9a81633 100644 --- a/src/render/feed.c +++ b/src/render/feed.c @@ -25,6 +25,7 @@ #define FEED_MAX_WIDTH 3840 #define FEED_MAX_HEIGHT 2160 #define FEED_FORMAT_RGBA8888 0 +#define FEED_ACK_TIMEOUT_MS 1000 enum { FEED_MSG_HELLO = 1, @@ -55,6 +56,7 @@ struct feed_slot { int stride; uint64_t seq; uint32_t pending; /* One bit per client that must acknowledge this frame. */ + int64_t published_ms; }; struct feed_client { @@ -71,6 +73,8 @@ struct owe_feed { struct feed_slot slots[OWE_FEED_SLOTS]; int width; int height; + int target_width; + int target_height; uint64_t seq; bool active; }; @@ -360,7 +364,7 @@ static void handle_ack(struct owe_feed *f, int client, uint32_t slot, uint64_t s static void poll_client(struct owe_feed *f, int idx) { struct feed_client *c = &f->clients[idx]; - for (;;) { + for (int messages = 0; messages < 64; messages++) { ssize_t n = recv(c->fd, (char *)&c->msg + c->len, sizeof(c->msg) - c->len, 0); if (n == 0) { client_remove(f, idx); @@ -395,6 +399,18 @@ void owe_feed_poll_clients(struct owe_feed *f, struct owe_mpv *m) { poll_client(f, i); } } + int64_t now = owe_ipc_now_ms(); + for (i = 0; i < OWE_FEED_SLOTS; i++) { + struct feed_slot *slot = &f->slots[i]; + if (slot->pending && now - slot->published_ms >= FEED_ACK_TIMEOUT_MS) { + for (int client = 0; client < OWE_FEED_MAX_CLIENTS; client++) { + if (slot->pending & (UINT32_C(1) << client)) { + OWE_WARN("feed client %d missed its acknowledgement deadline", client); + client_remove(f, client); + } + } + } + } if (f->active && m) { /* A new client needs decode to start before the first buffers exist. */ owe_mpv_set_paused(m, client_count(f) == 0); @@ -426,6 +442,9 @@ void owe_feed_stop(struct owe_feed *f) { for (i = 0; i < OWE_FEED_MAX_CLIENTS; i++) { client_remove(f, i); } + if (f->egl) owe_egl_make_current(f->egl); + for (i = 0; i < OWE_FEED_SLOTS; i++) slot_reset(&f->slots[i]); + f->width = f->height = 0; OWE_INFO("feed stopped"); } @@ -433,6 +452,32 @@ bool owe_feed_running(struct owe_feed *f) { return f && f->active; } +void owe_feed_set_target_size(struct owe_feed *f, int width, int height) { + if (!f) return; + f->target_width = width; + f->target_height = height; +} + +static void frame_size(struct owe_feed *f, int *w, int *h) { + double scale = 1.0; + if (f->target_width > 0 && f->target_height > 0) { + double sx = (double)f->target_width / *w; + double sy = (double)f->target_height / *h; + double cover = sx > sy ? sx : sy; + if (cover < scale) scale = cover; + } + double sx = (double)FEED_MAX_WIDTH / *w; + double sy = (double)FEED_MAX_HEIGHT / *h; + if (sx < scale) scale = sx; + if (sy < scale) scale = sy; + if (scale < 1.0) { + *w = (int)(*w * scale); + *h = (int)(*h * scale); + if (*w < 1) *w = 1; + if (*h < 1) *h = 1; + } +} + int owe_feed_publish(struct owe_feed *f, struct owe_mpv *m) { struct feed_slot *s; struct feed_msg frame; @@ -446,18 +491,10 @@ int owe_feed_publish(struct owe_feed *f, struct owe_mpv *m) { if (!owe_mpv_has_video(m) || owe_mpv_video_size(m, &w, &h) != 0) { return -1; } - if (w > FEED_MAX_WIDTH || h > FEED_MAX_HEIGHT) { - double scale = (double)FEED_MAX_WIDTH / (double)w; - double hscale = (double)FEED_MAX_HEIGHT / (double)h; - if (hscale < scale) { - scale = hscale; - } - w = (int)((double)w * scale) & ~1; - h = (int)((double)h * scale) & ~1; - } if (w <= 0 || h <= 0) { return -1; } + frame_size(f, &w, &h); owe_egl_make_current(f->egl); if (ensure_slots(f, w, h) != 0) { return -1; @@ -483,6 +520,7 @@ int owe_feed_publish(struct owe_feed *f, struct owe_mpv *m) { glReadPixels(0, 0, s->w, s->h, GL_RGBA, GL_UNSIGNED_BYTE, s->map); glBindFramebuffer(GL_FRAMEBUFFER, 0); s->seq = ++f->seq; + s->published_ms = owe_ipc_now_ms(); s->pending = 0; memset(&frame, 0, sizeof(frame)); frame.magic = FEED_MAGIC; diff --git a/src/render/feed.h b/src/render/feed.h index 15e449f..598f7e9 100644 --- a/src/render/feed.h +++ b/src/render/feed.h @@ -25,6 +25,7 @@ void owe_feed_poll_clients(struct owe_feed *f, struct owe_mpv *m); void owe_feed_start(struct owe_feed *f); void owe_feed_stop(struct owe_feed *f); bool owe_feed_running(struct owe_feed *f); +void owe_feed_set_target_size(struct owe_feed *f, int width, int height); /* Publishes one decoded frame to every connected client. Returns 0 when a * frame was rendered and sent. */ diff --git a/src/render/main.c b/src/render/main.c index 0363b22..c342d70 100644 --- a/src/render/main.c +++ b/src/render/main.c @@ -186,12 +186,14 @@ int main(int argc, char **argv) { owe_wayland_render_pending(g_app.wl); } owe_wayland_outputs_max_size(g_app.wl, &max_w, &max_h); + owe_feed_set_target_size(g_app.feed, max_w, max_h); if (max_w != g_last_max_w || max_h != g_last_max_h) { - g_last_max_w = max_w; - g_last_max_h = max_h; /* A still texture is cut for the output that decoded it. Give a * larger output the extra detail. */ - owe_render_ipc_reload_still(g_app.ipc); + if (owe_render_ipc_reload_still(g_app.ipc)) { + g_last_max_w = max_w; + g_last_max_h = max_h; + } } int wlfd = owe_wayland_fd(g_app.wl); int ipcfd = owe_render_ipc_fd(g_app.ipc); diff --git a/src/render/render_ipc.c b/src/render/render_ipc.c index 48e3e34..a9a9611 100644 --- a/src/render/render_ipc.c +++ b/src/render/render_ipc.c @@ -26,45 +26,26 @@ #include "xdg.h" #define MAX_CLIENTS OWE_IPC_MAX_CLIENTS -#define CLIENT_IDLE_MS 120000 - -struct owe_client { - int fd; - char buf[OWE_IPC_MAX_LINE]; - size_t len; - int64_t active_ms; -}; - struct owe_render_ipc { owe_ipc_server_t *srv; char path[PATH_MAX]; - struct owe_client clients[MAX_CLIENTS]; + struct owe_ipc_client clients[MAX_CLIENTS]; int pending_client; + uint64_t pending_generation; char pending_path[4096]; }; -static int64_t now_ms(void) { - struct timespec now; - clock_gettime(CLOCK_MONOTONIC, &now); - return (int64_t)now.tv_sec * 1000 + now.tv_nsec / 1000000; -} - static void client_remove(struct owe_render_ipc *ipc, int idx) { - if (ipc->clients[idx].fd >= 0) { - close(ipc->clients[idx].fd); - } - ipc->clients[idx].fd = -1; - ipc->clients[idx].len = 0; - ipc->clients[idx].active_ms = 0; + owe_ipc_client_close(&ipc->clients[idx]); } -static void client_send(struct owe_client *c, const char *line) { +static void client_send(struct owe_ipc_client *c, const char *line) { if (c->fd >= 0) { - owe_ipc_send_line(c->fd, line); + owe_ipc_client_send(c, line); } } -static void send_ok(struct owe_client *c, const char *extra) { +static void send_ok(struct owe_ipc_client *c, const char *extra) { char line[OWE_IPC_MAX_LINE]; if (extra && *extra) { snprintf(line, sizeof(line), "{\"status\":\"ok\",%s}", extra); @@ -74,13 +55,13 @@ static void send_ok(struct owe_client *c, const char *extra) { client_send(c, line); } -static void send_err(struct owe_client *c, const char *msg) { +static void send_err(struct owe_ipc_client *c, const char *msg) { char line[1024]; snprintf(line, sizeof(line), "{\"status\":\"error\",\"message\":\"%s\"}", msg ? msg : "failed"); client_send(c, line); } -static void handle_status(struct owe_client *c) { +static void handle_status(struct owe_ipc_client *c) { owe_app_t *app = owe_app_get(); char *line = NULL; char *path = owe_json_quote(app ? app->current_path : ""); @@ -122,7 +103,8 @@ static void pending_reply(struct owe_render_ipc *ipc, int ok, const char *messag if (ipc->pending_client < 0) { return; } - if (ipc->clients[ipc->pending_client].fd >= 0) { + if (ipc->clients[ipc->pending_client].fd >= 0 && + ipc->clients[ipc->pending_client].generation == ipc->pending_generation) { if (ok) { send_ok(&ipc->clients[ipc->pending_client], NULL); } else { @@ -142,7 +124,7 @@ static void stop_feed(owe_app_t *app) { } } -static void handle_load(struct owe_render_ipc *ipc, struct owe_client *c, yyjson_val *root) { +static void handle_load(struct owe_render_ipc *ipc, struct owe_ipc_client *c, yyjson_val *root) { owe_app_t *app = owe_app_get(); yyjson_val *vpath; yyjson_val *vkind; @@ -184,14 +166,17 @@ static void handle_load(struct owe_render_ipc *ipc, struct owe_client *c, yyjson send_err(c, "still load failed"); return; } + pending_reply(ipc, 0, "load superseded"); ipc->pending_client = (int)(c - ipc->clients); + ipc->pending_generation = c->generation; snprintf(ipc->pending_path, sizeof(ipc->pending_path), "%s", path); return; } send_err(c, "unknown kind"); } -static void handle_command(struct owe_render_ipc *ipc, struct owe_client *c, const char *line) { +static void handle_command(void *context, struct owe_ipc_client *c, const char *line) { + struct owe_render_ipc *ipc = context; owe_app_t *app = owe_app_get(); yyjson_doc *doc; yyjson_val *root; @@ -340,7 +325,7 @@ int owe_render_ipc_pollfds(struct owe_render_ipc *ipc, struct pollfd *fds) { int n = 0; for (int i = 0; ipc && i < MAX_CLIENTS; i++) { if (ipc->clients[i].fd >= 0) - fds[n++] = (struct pollfd){.fd = ipc->clients[i].fd, .events = POLLIN}; + fds[n++] = (struct pollfd){.fd = ipc->clients[i].fd, .events = owe_ipc_client_events(&ipc->clients[i])}; } return n; } @@ -354,9 +339,7 @@ void owe_render_ipc_accept(struct owe_render_ipc *ipc) { while ((fd = owe_ipc_server_accept(ipc->srv)) >= 0) { for (i = 0; i < MAX_CLIENTS; i++) { if (ipc->clients[i].fd < 0) { - ipc->clients[i].fd = fd; - ipc->clients[i].len = 0; - ipc->clients[i].active_ms = now_ms(); + owe_ipc_client_open(&ipc->clients[i], fd); break; } } @@ -367,57 +350,10 @@ void owe_render_ipc_accept(struct owe_render_ipc *ipc) { } void owe_render_ipc_poll_clients(struct owe_render_ipc *ipc) { - int i; - if (!ipc) { - return; - } - for (i = 0; i < MAX_CLIENTS; i++) { - struct owe_client *c = &ipc->clients[i]; - ssize_t n; - char *nl; - if (c->fd < 0) { - continue; - } - if (c->len == 0 && now_ms() - c->active_ms > CLIENT_IDLE_MS) { - client_remove(ipc, i); - continue; - } - n = recv(c->fd, c->buf + c->len, sizeof(c->buf) - c->len - 1, 0); - if (n == 0) { - client_remove(ipc, i); - continue; - } - if (n < 0) { - if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) { - continue; - } - client_remove(ipc, i); - continue; - } - if (memchr(c->buf + c->len, '\0', (size_t)n)) { - client_remove(ipc, i); - continue; - } - c->len += (size_t)n; - c->buf[c->len] = '\0'; - c->active_ms = now_ms(); - while ((nl = strchr(c->buf, '\n')) != NULL) { - *nl = '\0'; - if (nl > c->buf && nl[-1] == '\r') { - nl[-1] = '\0'; - } - if (*c->buf) { - handle_command(ipc, c, c->buf); - } - { - size_t used = (size_t)(nl - c->buf) + 1; - memmove(c->buf, c->buf + used, c->len - used + 1); - c->len -= used; - } - } - if (c->len >= sizeof(c->buf) - 1) { - client_remove(ipc, i); - } + if (!ipc) return; + int64_t now = owe_ipc_now_ms(); + for (int i = 0; i < MAX_CLIENTS; i++) { + owe_ipc_client_poll(&ipc->clients[i], now, handle_command, ipc); } } @@ -449,26 +385,26 @@ void owe_render_ipc_poll_still(struct owe_render_ipc *ipc) { /* Re-decode the current still when the outputs grew. The texture was cut for * the previous output size, and a larger output needs the extra detail. */ -void owe_render_ipc_reload_still(struct owe_render_ipc *ipc) { +bool owe_render_ipc_reload_still(struct owe_render_ipc *ipc) { owe_app_t *app = owe_app_get(); int max_w = 0; int max_h = 0; int old_w = 0; int old_h = 0; if (!ipc || !app || !app->still) { - return; - } - if (!owe_still_has_image(app->still) || owe_still_busy(app->still)) { - return; + return true; } + if (owe_still_busy(app->still)) return false; + if (!owe_still_has_image(app->still)) return true; owe_wayland_outputs_max_size(app->wl, &max_w, &max_h); owe_still_decoded_max(app->still, &old_w, &old_h); if (max_w <= 0 || max_h <= 0 || (max_w <= old_w && max_h <= old_h)) { - return; + return true; } if (owe_still_start(app->still, owe_still_path(app->still), max_w, max_h) != 0) { - return; + return false; } ipc->pending_client = -1; snprintf(ipc->pending_path, sizeof(ipc->pending_path), "%s", owe_still_path(app->still)); + return true; } diff --git a/src/render/render_ipc.h b/src/render/render_ipc.h index e8d1cd2..86d82c2 100644 --- a/src/render/render_ipc.h +++ b/src/render/render_ipc.h @@ -1,5 +1,6 @@ #pragma once #include +#include struct owe_render_ipc; @@ -10,4 +11,5 @@ void owe_render_ipc_accept(struct owe_render_ipc *ipc); void owe_render_ipc_poll_clients(struct owe_render_ipc *ipc); int owe_render_ipc_pollfds(struct owe_render_ipc *ipc, struct pollfd *fds); void owe_render_ipc_poll_still(struct owe_render_ipc *ipc); -void owe_render_ipc_reload_still(struct owe_render_ipc *ipc); +/* False when an in-flight decode or resource failure requires a later retry. */ +bool owe_render_ipc_reload_still(struct owe_render_ipc *ipc); diff --git a/src/render/still.c b/src/render/still.c index a6b754b..c3dcde0 100644 --- a/src/render/still.c +++ b/src/render/still.c @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -30,6 +31,7 @@ struct still_job { int h; int rc; int ready; + atomic_bool cancelled; }; struct owe_still { @@ -44,6 +46,7 @@ struct owe_still { int fade_ms; struct timespec loaded_at; struct still_job *job; + struct still_job *queued; }; static long elapsed_ms(const struct timespec *a, const struct timespec *b) { @@ -61,16 +64,24 @@ struct owe_still *owe_still_new(struct owe_wayland *wl, struct owe_egl *egl) { return s; } +static void still_job_join(struct owe_still *s); + void owe_still_free(struct owe_still *s) { if (!s) { return; } owe_still_unload(s); + still_job_join(s); free(s); } +static int decode_cancelled(void *opaque) { + const atomic_bool *cancelled = opaque; + return cancelled && atomic_load(cancelled); +} + static int decode_first_frame(const char *path, int max_w, int max_h, uint8_t **rgba_out, - int *w_out, int *h_out) { + int *w_out, int *h_out, atomic_bool *cancelled) { AVFormatContext *fmt = NULL; AVCodecContext *dec = NULL; const AVCodec *codec = NULL; @@ -81,6 +92,9 @@ static int decode_first_frame(const char *path, int max_w, int max_h, uint8_t ** int video_stream = -1; int rc = -1; unsigned int i; + fmt = avformat_alloc_context(); + if (!fmt) return -1; + fmt->interrupt_callback = (AVIOInterruptCB){decode_cancelled, cancelled}; if (avformat_open_input(&fmt, path, NULL, NULL) < 0) { OWE_ERROR("avformat_open_input failed: %s", path); return -1; @@ -124,6 +138,7 @@ static int decode_first_frame(const char *path, int max_w, int max_h, uint8_t ** goto done; } for (;;) { + if (decode_cancelled(cancelled)) goto done; int read_rc = av_read_frame(fmt, pkt); int send_rc; if (read_rc < 0) { @@ -152,7 +167,7 @@ static int decode_first_frame(const char *path, int max_w, int max_h, uint8_t ** avcodec_receive_frame(dec, frame); } } - if (frame->width <= 0 || frame->height <= 0) { + if (decode_cancelled(cancelled) || frame->width <= 0 || frame->height <= 0) { goto done; } { @@ -190,6 +205,7 @@ static int decode_first_frame(const char *path, int max_w, int max_h, uint8_t ** if (av_frame_get_buffer(rgb, 0) < 0) { goto done; } + if (decode_cancelled(cancelled)) goto done; sws_scale(sws, (const uint8_t *const *)frame->data, frame->linesize, 0, frame->height, rgb->data, rgb->linesize); { @@ -234,49 +250,40 @@ static int decode_first_frame(const char *path, int max_w, int max_h, uint8_t ** static void *still_job_main(void *arg) { struct still_job *job = arg; - job->rc = decode_first_frame(job->path, job->max_w, job->max_h, &job->rgba, &job->w, &job->h); + job->rc = decode_first_frame(job->path, job->max_w, job->max_h, &job->rgba, &job->w, &job->h, &job->cancelled); { char c = 'x'; - ssize_t n = write(job->done_fd[1], &c, 1); - (void)n; + while (write(job->done_fd[1], &c, 1) < 0 && errno == EINTR) {} } return NULL; } +static void job_free(struct still_job *job) { + if (!job) return; + close(job->done_fd[0]); + close(job->done_fd[1]); + free(job->rgba); + free(job); +} + static void still_job_release(struct owe_still *s) { - if (!s->job) { - return; - } - close(s->job->done_fd[0]); - close(s->job->done_fd[1]); - free(s->job->rgba); - free(s->job); + job_free(s->job); s->job = NULL; } +/* Only shutdown waits for a decoder. Replacements queue the newest request, + * cancel the old one and reap it from its completion pipe on the event loop. */ static void still_job_join(struct owe_still *s) { - if (!s->job) { - return; - } - pthread_join(s->job->thread, NULL); + if (!s->job) return; + if (!s->job->ready) pthread_join(s->job->thread, NULL); still_job_release(s); } int owe_still_start(struct owe_still *s, const char *path, int max_w, int max_h) { - struct still_job *job; - if (!s || !path || !*path) { - return -1; - } - if (s->job) { - /* A decode cannot be interrupted, so wait for the finished frame - * before replacing the request. The event loop only blocks for the - * remainder of that one decode. */ - still_job_join(s); - } - job = calloc(1, sizeof(*job)); - if (!job) { - return -1; - } + if (!s || !path || !*path) return -1; + struct still_job *job = calloc(1, sizeof(*job)); + if (!job) return -1; + atomic_init(&job->cancelled, false); if (pipe2(job->done_fd, O_CLOEXEC | O_NONBLOCK) != 0) { free(job); return -1; @@ -284,13 +291,18 @@ int owe_still_start(struct owe_still *s, const char *path, int max_w, int max_h) snprintf(job->path, sizeof(job->path), "%s", path); job->max_w = max_w; job->max_h = max_h; - if (pthread_create(&job->thread, NULL, still_job_main, job) != 0) { - close(job->done_fd[0]); - close(job->done_fd[1]); - free(job); - return -1; + if (s->job && s->job->ready) still_job_release(s); + if (s->job) { + atomic_store(&s->job->cancelled, true); + job_free(s->queued); + s->queued = job; + } else { + if (pthread_create(&job->thread, NULL, still_job_main, job) != 0) { + job_free(job); + return -1; + } + s->job = job; } - s->job = job; return 0; } @@ -316,8 +328,12 @@ int owe_still_decoded_max(struct owe_still *s, int *w, int *h) { } void owe_still_cancel(struct owe_still *s) { - if (s) { - still_job_join(s); + if (!s) return; + job_free(s->queued); + s->queued = NULL; + if (s->job) { + atomic_store(&s->job->cancelled, true); + if (s->job->ready) still_job_release(s); } } @@ -341,6 +357,17 @@ int owe_still_poll(struct owe_still *s) { return -1; } pthread_join(job->thread, NULL); + job->ready = 1; + if (atomic_load(&job->cancelled)) { + still_job_release(s); + s->job = s->queued; + s->queued = NULL; + if (s->job && pthread_create(&s->job->thread, NULL, still_job_main, s->job) != 0) { + still_job_release(s); + return -1; + } + return 0; + } if (job->rc != 0 || !job->rgba) { OWE_ERROR("still decode failed: %s", job->path); still_job_release(s); diff --git a/src/render/wayland.c b/src/render/wayland.c index f3ff22f..cd2e361 100644 --- a/src/render/wayland.c +++ b/src/render/wayland.c @@ -203,6 +203,7 @@ static owe_output_t *output_new(struct owe_wayland *wl, struct wl_output *wl_out owe_output_t *out = calloc(1, sizeof(*out)); struct wl_region *region; if (!out) { + wl_output_destroy(wl_output); return NULL; } out->wl_output = wl_output; @@ -216,10 +217,7 @@ static owe_output_t *output_new(struct owe_wayland *wl, struct wl_output *wl_out wl_output_add_listener(wl_output, &output_listener, out); out->surface = wl_compositor_create_surface(wl->compositor); - if (!out->surface) { - free(out); - return NULL; - } + if (!out->surface) goto fail; wl_surface_set_buffer_scale(out->surface, out->scale > 0 ? out->scale : 1); if (wl->viewporter) { out->viewport = wp_viewporter_get_viewport(wl->viewporter, out->surface); @@ -234,11 +232,7 @@ static owe_output_t *output_new(struct owe_wayland *wl, struct wl_output *wl_out out->layer = zwlr_layer_shell_v1_get_layer_surface(wl->layer_shell, out->surface, wl_output, ZWLR_LAYER_SHELL_V1_LAYER_BACKGROUND, "owe-background"); - if (!out->layer) { - wl_surface_destroy(out->surface); - free(out); - return NULL; - } + if (!out->layer) goto fail; zwlr_layer_surface_v1_add_listener(out->layer, &layer_listener, out); zwlr_layer_surface_v1_set_size(out->layer, 0, 0); zwlr_layer_surface_v1_set_anchor(out->layer, ZWLR_LAYER_SURFACE_V1_ANCHOR_TOP | @@ -255,27 +249,19 @@ static owe_output_t *output_new(struct owe_wayland *wl, struct wl_output *wl_out wl_surface_commit(out->surface); out->egl_window = wl_egl_window_create(out->surface, 64, 64); - if (!out->egl_window) { - zwlr_layer_surface_v1_destroy(out->layer); - wl_surface_destroy(out->surface); - free(out); - return NULL; - } + if (!out->egl_window) goto fail; if (app && app->egl) { out->egl_surface = owe_egl_create_window_surface(app->egl, out->egl_window); - if (!out->egl_surface) { - wl_egl_window_destroy(out->egl_window); - zwlr_layer_surface_v1_destroy(out->layer); - wl_surface_destroy(out->surface); - free(out); - return NULL; - } + if (!out->egl_surface) goto fail; } out->next = wl->outputs; wl->outputs = out; wl->output_count++; return out; +fail: + output_free(wl, out); + return NULL; } static void output_free(struct owe_wayland *wl, owe_output_t *out) { @@ -306,10 +292,10 @@ static void output_free(struct owe_wayland *wl, owe_output_t *out) { for (link = &wl->outputs; *link; link = &(*link)->next) { if (*link == out) { *link = out->next; + wl->output_count--; break; } } - wl->output_count--; free(out); } @@ -329,9 +315,7 @@ static void registry_global(void *data, struct wl_registry *registry, uint32_t n uint32_t v = version >= 4 ? 4 : version; wo = wl_registry_bind(registry, name, &wl_output_interface, v); if (wo && wl->compositor && wl->layer_shell) { - if (!output_new(wl, wo, name)) { - wl_output_destroy(wo); - } + output_new(wl, wo, name); } else if (wo) { wl_output_destroy(wo); } diff --git a/test/benchmark.py b/test/benchmark.py new file mode 100644 index 0000000..f20c7bd --- /dev/null +++ b/test/benchmark.py @@ -0,0 +1,122 @@ +import copy +import importlib.util +from pathlib import Path +import tempfile +import unittest + +spec = importlib.util.spec_from_file_location("benchmark", Path(__file__).resolve().parents[1] / "bench/bench.py") +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + + +class FakeBenchmark(module.Benchmark): + def __init__(self, output): + super().__init__(output, 0.001, 1) + self.state = dict(source_path="/original.mp4", loaded_path="/original.mp4", loaded_kind="video", + manual_pause=True, idle_pause=False, always_animate=False, render_alive=True, + engine="renderer", media_ready=True, locked=False) + self.samples = [] + self.fail_at = None + + def request(self, command, renderer=False, **fields): + if renderer: + return 2, dict(status="ok", ready=True, has_video=True, paused=self.paused(), time_pos=0) + if command in ("pause", "resume"): + self.state["manual_pause"] = command == "pause" + elif command in ("idle-pause", "idle-resume"): + self.state["idle_pause"] = command == "idle-pause" + elif command == "always-animate": + self.state["always_animate"] = fields["value"] + elif command == "set": + self.state.update(source_path=fields["path"], engine="shell", render_alive=False) + return 1, dict(self.state, status="ok", paused=self.paused(), reason=self.reason()) + + def reason(self): + if self.state["manual_pause"]: + return "manual" + if self.state["always_animate"]: + return "always-animate" + return "idle" if self.state["idle_pause"] else "visible" + + def paused(self): + return self.reason() in ("manual", "idle") + + @staticmethod + def process(pid): + return dict(pid=pid, started=1, parent=1, ticks=0, rss=1) + + def sample(self, state): + self.samples.append(state) + if state == self.fail_at: + raise RuntimeError("injected failure") + super().sample(state) + + +class BenchmarkTest(unittest.TestCase): + def test_samples_and_restore_initial_pause(self): + with tempfile.TemporaryDirectory() as directory: + bench = FakeBenchmark(directory) + bench.run() + self.assertEqual(bench.samples, ["playing", "paused", "policy"]) + self.assertTrue(bench.state["manual_pause"]) + self.assertFalse(bench.state["always_animate"]) + self.assertFalse(bench.state["idle_pause"]) + + def test_restore_after_failure(self): + with tempfile.TemporaryDirectory() as directory: + bench = FakeBenchmark(directory) + bench.state["idle_pause"] = True + bench.fail_at = "policy" + with self.assertRaises(RuntimeError): + bench.run() + self.assertTrue(bench.state["manual_pause"]) + self.assertTrue(bench.state["idle_pause"]) + self.assertFalse(bench.state["always_animate"]) + + def test_restore_after_interruption(self): + with tempfile.TemporaryDirectory() as directory: + bench = FakeBenchmark(directory) + def interrupted(_state): + raise SystemExit(143) + bench.sample = interrupted + with self.assertRaises(SystemExit): + bench.run() + self.assertTrue(bench.state["manual_pause"]) + self.assertFalse(bench.state["always_animate"]) + self.assertFalse(bench.state["idle_pause"]) + + def test_still_measures_daemon_without_renderer(self): + with tempfile.TemporaryDirectory() as directory: + bench = FakeBenchmark(directory) + bench.state.update(engine="shell", render_alive=False) + bench.sample("still") + self.assertTrue((Path(directory) / "still.json").exists()) + bench.state["render_alive"] = True + with self.assertRaises(RuntimeError): + bench.snapshot("still") + + def test_reject_paused_playing_sample(self): + with tempfile.TemporaryDirectory() as directory: + bench = FakeBenchmark(directory) + bench.command("always-animate", value=True) + with self.assertRaises(RuntimeError): + bench.snapshot("playing") + + def test_reject_restart_or_media_change(self): + with tempfile.TemporaryDirectory() as directory: + bench = FakeBenchmark(directory) + bench.command("resume") + bench.command("always-animate", value=True) + before = bench.snapshot("playing") + after = copy.deepcopy(before) + after["processes"]["renderer"]["started"] += 1 + with self.assertRaises(RuntimeError): + bench.verify_same(before, after) + after = copy.deepcopy(before) + after["daemon"]["loaded_path"] = "/replacement.mp4" + with self.assertRaises(RuntimeError): + bench.verify_same(before, after) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/decode.c b/test/decode.c index ef4f0dd..f8ded49 100644 --- a/test/decode.c +++ b/test/decode.c @@ -2,6 +2,7 @@ #include #include #include +#include #include "owe_spawn.h" @@ -11,6 +12,61 @@ static char root[512]; +/* Async lifecycle checks stop before GPU upload. */ +void owe_app_request_render(void) {} +owe_output_t *owe_wayland_outputs(struct owe_wayland *wl) { (void)wl; return NULL; } +int owe_egl_prepare_output(struct owe_egl *e, owe_output_t *o) { (void)e; (void)o; return 0; } +int owe_egl_make_current(struct owe_egl *e) { (void)e; return 0; } +unsigned int owe_egl_tex_from_rgba(struct owe_egl *e, const uint8_t *p, int w, int h) { + (void)e; (void)p; (void)w; (void)h; return 1; +} +void owe_egl_tex_free(struct owe_egl *e, unsigned int tex) { (void)e; (void)tex; } + +static pthread_mutex_t gate_mutex = PTHREAD_MUTEX_INITIALIZER; +static pthread_cond_t gate_cond = PTHREAD_COND_INITIALIZER; +static bool gate_open; + +static void *held_decode(void *opaque) { + struct still_job *job = opaque; + pthread_mutex_lock(&gate_mutex); + while (!gate_open) pthread_cond_wait(&gate_cond, &gate_mutex); + pthread_mutex_unlock(&gate_mutex); + char done = 'x'; + CHECK(write(job->done_fd[1], &done, 1) == 1); + return NULL; +} + +static void test_async_replace(const char *path) { + struct owe_still still = {0}; + struct still_job *held = calloc(1, sizeof(*held)); + CHECK(held); + atomic_init(&held->cancelled, false); + CHECK(pipe2(held->done_fd, O_CLOEXEC | O_NONBLOCK) == 0); + CHECK(pthread_create(&held->thread, NULL, held_decode, held) == 0); + still.job = held; + /* Joining the worker here deadlocks: the gate opens only after replacing + * and cancelling, proving these operations never wait for a decoder. */ + CHECK(owe_still_start(&still, "/obsolete.png", 10, 10) == 0); + owe_still_cancel(&still); + CHECK(!still.queued && atomic_load(&held->cancelled)); + CHECK(owe_still_start(&still, "/also-obsolete.png", 20, 20) == 0); + CHECK(owe_still_start(&still, path, 64, 48) == 0); + pthread_mutex_lock(&gate_mutex); + gate_open = true; + pthread_cond_signal(&gate_cond); + pthread_mutex_unlock(&gate_mutex); + struct pollfd fd = {.fd = owe_still_fd(&still), .events = POLLIN}; + CHECK(poll(&fd, 1, 5000) == 1); + CHECK(owe_still_poll(&still) == 0); + CHECK(still.job && !still.queued && strcmp(still.job->path, path) == 0); + fd.fd = owe_still_fd(&still); + CHECK(poll(&fd, 1, 5000) == 1); + CHECK(owe_still_poll(&still) == 0); /* Keep result until an output exists. */ + CHECK(still.job && still.job->ready && still.job->rgba); + owe_still_cancel(&still); /* A joined result must not be joined twice. */ + CHECK(!owe_still_busy(&still)); +} + static int run(char *const argv[]) { char log[8192]; return owe_spawn_capture(argv[0], argv, log, sizeof(log), 20000); @@ -35,7 +91,7 @@ static void check_decode(const char *name, int *w, int *h) { uint8_t *rgba = NULL; int rc; path_for(path, sizeof(path), name); - rc = decode_first_frame(path, 0, 0, &rgba, w, h); + rc = decode_first_frame(path, 0, 0, &rgba, w, h, NULL); CHECK(rc == 0); CHECK(rgba != NULL); CHECK(*w > 0 && *h > 0); @@ -52,6 +108,11 @@ int main(void) { CHECK(owe_spawn_capture("ffmpeg", version, log, sizeof(log), 5000) == 0); CHECK(generate("still.png", "64x48") == 0); + { + char path[1024]; + path_for(path, sizeof(path), "still.png"); + test_async_replace(path); + } CHECK(generate("still.jpg", "64x48") == 0); CHECK(generate("large.jpg", "4000x3000") == 0); CHECK(generate("portrait.jpg", "1200x2400") == 0); @@ -83,12 +144,12 @@ int main(void) { uint8_t *rgba = NULL; int w = 0, h = 0; path_for(path, sizeof(path), "large.jpg"); - CHECK(decode_first_frame(path, 1000, 1000, &rgba, &w, &h) == 0); + CHECK(decode_first_frame(path, 1000, 1000, &rgba, &w, &h, NULL) == 0); CHECK(w == 1333 && h == 1000); printf("cover decode at %dx%d\n", w, h); free(rgba); path_for(path, sizeof(path), "portrait.jpg"); - CHECK(decode_first_frame(path, 1920, 1080, &rgba, &w, &h) == 0); + CHECK(decode_first_frame(path, 1920, 1080, &rgba, &w, &h, NULL) == 0); CHECK(w == 1200 && h == 2400); free(rgba); } diff --git a/test/egl_context.c b/test/egl_context.c new file mode 100644 index 0000000..4587246 --- /dev/null +++ b/test/egl_context.c @@ -0,0 +1,35 @@ +#include "../src/render/egl.c" +#include + +#define CHECK(x) do { if (!(x)) { fprintf(stderr, "%s:%d: %s\n", __FILE__, __LINE__, #x); exit(1); } } while (0) + +int main(void) { + EGLDisplay display = eglGetPlatformDisplay(EGL_PLATFORM_SURFACELESS_MESA, EGL_DEFAULT_DISPLAY, NULL); + if (display == EGL_NO_DISPLAY || !eglInitialize(display, NULL, NULL)) return 77; + CHECK(eglBindAPI(EGL_OPENGL_API)); + EGLint attrs[] = {EGL_SURFACE_TYPE, EGL_PBUFFER_BIT, EGL_RENDERABLE_TYPE, EGL_OPENGL_BIT, EGL_NONE}; + EGLConfig config; + EGLint count; + CHECK(eglChooseConfig(display, attrs, &config, 1, &count) && count == 1); + EGLContext context = eglCreateContext(display, config, EGL_NO_CONTEXT, NULL); + CHECK(context != EGL_NO_CONTEXT); + EGLint size[] = {EGL_WIDTH, 2, EGL_HEIGHT, 2, EGL_NONE}; + EGLSurface surface = eglCreatePbufferSurface(display, config, size); + CHECK(surface != EGL_NO_SURFACE); + CHECK(eglMakeCurrent(display, surface, surface, context)); + struct owe_egl egl = {.display = display, .context = context, .current = surface}; + CHECK(owe_egl_make_current(&egl) == 0); + CHECK(eglGetCurrentSurface(EGL_DRAW) == surface && egl.current == surface); + owe_output_t output = {.egl_surface = surface}; + owe_egl_destroy_output(&egl, &output); + CHECK(!output.egl_surface && egl.current == EGL_NO_SURFACE); + CHECK(eglGetCurrentSurface(EGL_DRAW) == EGL_NO_SURFACE); + CHECK(eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT)); + CHECK(owe_egl_make_current(&egl) == 0); + CHECK(eglGetCurrentContext() == context); + CHECK(eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT)); + CHECK(eglDestroyContext(display, context)); + CHECK(eglTerminate(display)); + eglReleaseThread(); + puts("EGL context reuse and output release passed"); +} diff --git a/test/fake_ffmpeg.c b/test/fake_ffmpeg.c index 13d2db7..ca40511 100644 --- a/test/fake_ffmpeg.c +++ b/test/fake_ffmpeg.c @@ -8,6 +8,11 @@ int main(int argc, char **argv) { const char *mode = getenv("OWE_TEST_FFMPEG_MODE"); FILE *out = fopen(argv[argc - 1], "w"); if (!out) return 2; + if (mode && strcmp(mode, "large") == 0) { + int rc = ftruncate(fileno(out), 2 * 1024 * 1024); + fclose(out); + return rc != 0; + } fputs("partial", out); fflush(out); const char *started = getenv("OWE_TEST_FFMPEG_STARTED"); diff --git a/test/feed.c b/test/feed.c index feae368..a843358 100644 --- a/test/feed.c +++ b/test/feed.c @@ -95,9 +95,9 @@ static void check_no_message(int fd) { static void test_restart(void) { struct owe_feed *f = new_feed(); struct owe_mpv mpv = {0}; - prepare_slots(f); uint64_t previous_seq = 0; for (int i = 0; i < 2; i++) { + prepare_slots(f); int fd = connect_client(f); check_no_message(fd); owe_feed_start(f); @@ -107,6 +107,10 @@ static void test_restart(void) { CHECK(frame.seq > previous_seq); previous_seq = frame.seq; owe_feed_stop(f); + CHECK(f->width == 0 && f->height == 0); + for (int slot = 0; slot < OWE_FEED_SLOTS; slot++) { + CHECK(f->slots[slot].fd == -1 && f->slots[slot].map == NULL); + } char byte; CHECK(recv(fd, &byte, 1, 0) == 0); close(fd); @@ -213,6 +217,33 @@ static void test_send_failure(void) { owe_feed_free(f); } +static void test_stalled_client(void) { + struct owe_feed *f = new_feed(); + struct owe_mpv mpv = {0}; + prepare_slots(f); + owe_feed_start(f); + int stalled = connect_client(f), healthy = connect_client(f); + receive(stalled, FEED_MSG_HELLO); + receive(healthy, FEED_MSG_HELLO); + for (int i = 0; i < OWE_FEED_SLOTS; i++) { + CHECK(owe_feed_publish(f, &mpv) == 0); + receive(stalled, FEED_MSG_FRAME); + ack(healthy, receive(healthy, FEED_MSG_FRAME)); + owe_feed_poll_clients(f, &mpv); + } + CHECK(owe_feed_publish(f, &mpv) == -1); + f->slots[0].published_ms -= FEED_ACK_TIMEOUT_MS; + owe_feed_poll_clients(f, &mpv); + char byte; + CHECK(recv(stalled, &byte, 1, 0) == 0); + CHECK(!mpv.paused); + CHECK(owe_feed_publish(f, &mpv) == 0); + receive(healthy, FEED_MSG_FRAME); + close(stalled); + close(healthy); + owe_feed_free(f); +} + int main(void) { char root[] = "/tmp/owe-feed-XXXXXX"; CHECK(mkdtemp(root)); @@ -220,10 +251,21 @@ int main(void) { epoxy_glBindFramebuffer = bind_framebuffer; epoxy_glPixelStorei = pixel_store; epoxy_glReadPixels = read_pixels; + struct owe_feed dimensions = {.target_width = 1920, .target_height = 1080}; + int w = 3840, h = 2160; + frame_size(&dimensions, &w, &h); + CHECK(w == 1920 && h == 1080); + w = 1200; h = 2400; + frame_size(&dimensions, &w, &h); + CHECK(w == 1080 && h == 2160); /* Protocol cap, with aspect preserved. */ + w = 640; h = 360; + frame_size(&dimensions, &w, &h); + CHECK(w == 640 && h == 360); /* Never upscale readback. */ test_restart(); test_client_pause(); test_frame_ownership(); test_send_failure(); + test_stalled_client(); char lock[128]; snprintf(lock, sizeof(lock), "%s.lock", socket_path); unlink(lock); diff --git a/test/ipc_clients.c b/test/ipc_clients.c new file mode 100644 index 0000000..095a939 --- /dev/null +++ b/test/ipc_clients.c @@ -0,0 +1,70 @@ +#include "common_ipc.h" +#include +#include +#include +#include +#include +#include + +#define CHECK(x) do { if (!(x)) { fprintf(stderr, "%s:%d: %s\n", __FILE__, __LINE__, #x); exit(1); } } while (0) + +static void echo(void *context, struct owe_ipc_client *c, const char *line) { + int *commands = context; + (*commands)++; + owe_ipc_client_send(c, line); +} + +int main(void) { + struct owe_ipc_client c = {.fd = -1}; + int pair[2], commands = 0; + CHECK(socketpair(AF_UNIX, SOCK_STREAM | SOCK_NONBLOCK, 0, pair) == 0); + owe_ipc_client_open(&c, pair[0]); + int64_t start = c.active_ms; + CHECK(send(pair[1], "x", 1, 0) == 1); + owe_ipc_client_poll(&c, start, echo, &commands); + CHECK(c.len == 1 && commands == 0); + /* Dribbling bytes extends activity, but not an incomplete request's deadline. */ + CHECK(send(pair[1], "y", 1, 0) == 1); + owe_ipc_client_poll(&c, start + OWE_IPC_CLIENT_IDLE_MS - 1, echo, &commands); + CHECK(c.fd >= 0); + owe_ipc_client_poll(&c, start + OWE_IPC_CLIENT_IDLE_MS, echo, &commands); + CHECK(c.fd < 0); + close(pair[1]); + + CHECK(socketpair(AF_UNIX, SOCK_STREAM | SOCK_NONBLOCK, 0, pair) == 0); + owe_ipc_client_open(&c, pair[0]); + CHECK(send(pair[1], "first\r\nsecond\n", 14, 0) == 14); + owe_ipc_client_poll(&c, c.active_ms, echo, &commands); + CHECK(commands == 2); + char small[64] = {0}; + CHECK(recv(pair[1], small, sizeof(small), 0) == 13); + CHECK(strcmp(small, "first\nsecond\n") == 0); + + /* Force EAGAIN and partial writes, then drain and verify the complete reply. */ + int size = 1024; + CHECK(setsockopt(c.fd, SOL_SOCKET, SO_SNDBUF, &size, sizeof(size)) == 0); + char *line = malloc(OWE_IPC_MAX_LINE - 1); + CHECK(line); + memset(line, 'a', OWE_IPC_MAX_LINE - 2); + line[OWE_IPC_MAX_LINE - 2] = '\0'; + owe_ipc_client_send(&c, line); + CHECK(c.fd >= 0 && (owe_ipc_client_events(&c) & POLLOUT)); + size_t received = 0; + for (int attempts = 0; attempts < 1000 && received < OWE_IPC_MAX_LINE - 1; attempts++) { + char buf[8192]; + ssize_t n = recv(pair[1], buf, sizeof(buf), 0); + if (n > 0) { + for (ssize_t i = 0; i < n; i++, received++) + CHECK(buf[i] == (received == OWE_IPC_MAX_LINE - 2 ? '\n' : 'a')); + } else CHECK(errno == EAGAIN); + owe_ipc_client_poll(&c, owe_ipc_now_ms(), echo, &commands); + } + CHECK(received == OWE_IPC_MAX_LINE - 1); + CHECK(!(owe_ipc_client_events(&c) & POLLOUT)); + /* A reader that never drains cannot grow our response queue indefinitely. */ + for (int i = 0; i < 10 && c.fd >= 0; i++) owe_ipc_client_send(&c, line); + CHECK(c.fd < 0 && c.output == NULL); + close(pair[1]); + free(line); + puts("IPC deadlines, framing and backpressure passed"); +} diff --git a/test/lock_policy.c b/test/lock_policy.c index 13aee8b..77d1a7a 100644 --- a/test/lock_policy.c +++ b/test/lock_policy.c @@ -6,7 +6,19 @@ struct owed_power { bool locked; bool sleeping; bool battery; }; struct owed_hypr { bool locked; bool off; }; -struct owed_supervisor { int starts; int stops; bool feeding; bool paused; }; +struct owed_supervisor { int starts; int stops; bool feeding; bool paused; bool fail_start; bool fail_load; int loads; }; +static char covered_names[1024]; +static int skip_sends; +const char *owed_hypr_covered_names(struct owed_hypr *h) { (void)h; return covered_names; } +int owed_supervisor_send(struct owed_supervisor *s, const char *line, char *reply, + unsigned long reply_len) { + (void)s; (void)reply; (void)reply_len; + yyjson_doc *doc = yyjson_read(line, strlen(line), 0); + CHECK(doc); + yyjson_doc_free(doc); + skip_sends++; + return 0; +} bool owed_power_locked(struct owed_power *p) { return p && p->locked; } bool owed_power_sleeping(struct owed_power *p) { return p && p->sleeping; } @@ -31,19 +43,20 @@ int owed_supervisor_feed_stop(struct owed_supervisor *s) { int owed_supervisor_pause(struct owed_supervisor *s) { s->paused = true; return 0; } int owed_supervisor_resume(struct owed_supervisor *s) { s->paused = false; return 0; } int owed_supervisor_load(struct owed_supervisor *s, const char *path, const char *kind) { - (void)s; (void)path; (void)kind; - return 0; + s->loads++; (void)path; (void)kind; + return s->fail_load ? -1 : 0; } -int owed_supervisor_ensure_running(struct owed_supervisor *s) { s->starts++; return 0; } +int owed_supervisor_ensure_running(struct owed_supervisor *s) { s->starts++; return s->fail_start ? -1 : 0; } int owed_supervisor_fade(struct owed_supervisor *s, int ms) { (void)s; (void)ms; return 0; } static int shell_plugin_calls; static bool shell_plugin_enabled; +static bool shell_plugin_fail; int owed_shell_plugin_set(bool enabled) { shell_plugin_calls++; shell_plugin_enabled = enabled; - return 0; + return shell_plugin_fail ? -1 : 0; } int owed_transcode_gif_path(const char *gif_path, int fps, int crf, int max_w, int max_h, @@ -51,9 +64,11 @@ int owed_transcode_gif_path(const char *gif_path, int fps, int crf, int max_w, i (void)gif_path; (void)fps; (void)crf; (void)max_w; (void)max_h; (void)out_mp4; (void)out_len; return -1; } +static bool poster_path_ok; int owed_transcode_poster_path(const char *video_path, char *out_png, unsigned long out_len) { - (void)video_path; (void)out_png; (void)out_len; - return -1; + (void)video_path; + snprintf(out_png, out_len, "/poster.png"); + return poster_path_ok ? 0 : -1; } bool owed_transcode_file_ready(const char *path) { (void)path; return false; } owed_async_job_t *owed_async_gif(const char *gif_path, int fps, int crf, int max_w, int max_h) { @@ -61,6 +76,11 @@ owed_async_job_t *owed_async_gif(const char *gif_path, int fps, int crf, int max return NULL; } owed_async_job_t *owed_async_poster(const char *video_path) { (void)video_path; return NULL; } +const char *owed_async_job_input(owed_async_job_t *job) { (void)job; return g_app.source_path; } +int owed_async_job_finish(owed_async_job_t *job, struct owed_async_result *result) { + (void)job; result->ok = 0; return 0; +} +void owed_async_job_free(owed_async_job_t *job) { (void)job; } static void apply(void) { owed_policy_recompute(g_app.policy); @@ -120,10 +140,36 @@ int main(void) { finish_media("/video.mp4", "video"); CHECK(renderer.feeding); power.battery = true; + strcpy(g_app.config.battery_mode, "pause"); + apply(); + CHECK(!renderer.feeding && renderer.paused); + g_app.always_animate = true; + apply(); + CHECK(renderer.feeding && !renderer.paused); + g_app.always_animate = false; + power.battery = false; + owed_policy_set_idle_pause(g_app.policy, true); + apply(); + CHECK(!renderer.feeding && renderer.paused); + owed_policy_set_idle_pause(g_app.policy, false); + owed_policy_set_blocklisted(g_app.policy, true); + apply(); + CHECK(!renderer.feeding && renderer.paused); + owed_policy_set_blocklisted(g_app.policy, false); + apply(); + CHECK(renderer.feeding && !renderer.paused); + power.battery = true; strcpy(g_app.config.battery_mode, "poster"); owed_policy_recompute(g_app.policy); finish_media("/poster.png", "still"); CHECK(!renderer.feeding && !g_app.render_feeding); + power.locked = false; + owed_policy_recompute(g_app.policy); + CHECK(owed_policy_should_pause(g_app.policy)); + CHECK(owed_policy_should_poster(g_app.policy)); + power.battery = false; + owed_policy_recompute(g_app.policy); + CHECK(!owed_policy_should_poster(g_app.policy)); /* A still background belongs to the shell and never starts the renderer. */ renderer.starts = 0; @@ -142,6 +188,89 @@ int main(void) { CHECK(g_app.engine == OWE_ENGINE_SHELL); CHECK(renderer.starts == 0 && !renderer.feeding); CHECK(shell_plugin_calls == 1 && shell_plugin_enabled); + /* Failed startup retries without marking the media itself as failed. */ + strcpy(g_app.source_path, "/retry.mp4"); + strcpy(g_app.source_kind, "video"); + renderer.fail_start = true; + owed_app_apply_policy(); + CHECK(!*g_app.fail_path && renderer.starts == 1); + owed_app_apply_policy(); + CHECK(renderer.starts == 1); + renderer.fail_start = false; + g_app.renderer_retry_at_ms = 0; + owed_app_apply_policy(); + CHECK(renderer.starts == 2 && !strcmp(g_app.loaded_path, "/retry.mp4")); + + /* A failed shell command is also rate limited while the still falls back. */ + strcpy(g_app.source_path, "/still.png"); + strcpy(g_app.source_kind, "still"); + shell_plugin_fail = true; + int calls = shell_plugin_calls; + owed_app_apply_policy(); + CHECK(shell_plugin_calls == calls + 1 && g_app.shell_retry_at_ms > 0); + owed_app_apply_policy(); + CHECK(shell_plugin_calls == calls + 1); + shell_plugin_fail = false; + g_app.shell_retry_at_ms = 0; + owed_app_apply_policy(); + CHECK(g_app.engine == OWE_ENGINE_SHELL); + + /* A new renderer receives feed state and media again. */ + g_app.engine = OWE_ENGINE_RENDERER; + strcpy(g_app.source_path, "/retry.mp4"); + strcpy(g_app.source_kind, "video"); + power.locked = true; + owed_policy_recompute(g_app.policy); + g_app.render_feeding = 1; + strcpy(g_last_skip, "DP-1"); + int starts = renderer.starts, loads = renderer.loads; + owed_app_on_renderer_restarted(); + CHECK(renderer.starts == starts + 1 && renderer.loads == loads + 1); + CHECK(g_app.render_feeding && !*g_last_skip && g_app.media_pending); + /* Poster setup failures must not poison the playable source on AC. */ + power.locked = false; + for (int path_ok = 0; path_ok <= 1; path_ok++) { + poster_path_ok = path_ok; + power.battery = true; + g_app.loaded_path[0] = '\0'; + g_app.loaded_kind[0] = '\0'; + owed_app_on_policy_changed(); + CHECK(!*g_app.fail_path && !strcmp(g_app.poster_fail_path, g_app.source_path)); + power.battery = false; + owed_app_on_policy_changed(); + CHECK(!*g_app.poster_fail_path && !strcmp(g_app.loaded_path, g_app.source_path)); + CHECK(!renderer.paused); + } + power.battery = true; + g_app.job_is_poster = true; + g_app.job = (owed_async_job_t *)(uintptr_t)1; + owed_app_on_job_done(); + CHECK(!g_app.job && !*g_app.fail_path && *g_app.poster_fail_path); + power.battery = false; + owed_app_on_policy_changed(); + CHECK(!*g_app.poster_fail_path && !strcmp(g_app.loaded_path, g_app.source_path)); + + /* Escaping monitor names must not overrun the JSON buffer. */ + memset(covered_names, 1, sizeof(covered_names) - 1); + for (int i = 127; i < 1023; i += 128) covered_names[i] = ','; + sync_output_skips(); + CHECK(skip_sends == 0 && !*g_last_skip); + strcpy(covered_names, "DP-1,HDMI-A-1"); + sync_output_skips(); + CHECK(skip_sends == 1 && !strcmp(g_last_skip, covered_names)); + /* A rejected source still is not mistaken for a failed generated poster. */ + power.battery = true; + renderer.fail_load = true; + shell_plugin_fail = true; + g_app.shell_enabled = 0; + strcpy(g_app.source_path, "/corrupt.png"); + strcpy(g_app.source_kind, "still"); + loads = renderer.loads; + owed_app_on_policy_changed(); + CHECK(!strcmp(g_app.fail_path, g_app.source_path) && !*g_app.poster_fail_path); + CHECK(renderer.loads == loads + 1); + owed_app_apply_policy(); + CHECK(renderer.loads == loads + 1); owed_policy_free(g_app.policy); puts("lock policy and still transition checks passed"); return 0; diff --git a/test/meson.build b/test/meson.build index ed0ae94..ec513e2 100644 --- a/test/meson.build +++ b/test/meson.build @@ -4,6 +4,41 @@ unit_test = executable('owe-unit-test', test('unit', unit_test) +ipc_clients_test = executable('owe-ipc-clients-test', + files('ipc_clients.c', '../src/common/common_ipc.c'), include_directories : common_inc) +test('ipc-clients', ipc_clients_test) +test('benchmark', find_program('python3'), args : files('benchmark.py')) + +supervisor_test = executable('owe-supervisor-test', + files('supervisor.c', '../src/common/common_ipc.c', '../src/common/log.c', + '../src/common/json.c', '../src/common/xdg.c') + yyjson_sources, + include_directories : [common_inc, yyjson_inc, include_directories('../src/daemon')]) +test('supervisor', supervisor_test) + +power_test = executable('owe-power-test', + files('power.c', '../src/common/log.c'), + include_directories : [common_inc, include_directories('../src/daemon')], + dependencies : libsystemd_dep) +test('power', power_test) + +egl_context_test = executable('owe-egl-context-test', + files('egl_context.c', '../src/common/log.c'), + include_directories : [common_inc, include_directories('../src/render')], + dependencies : [egl_dep, epoxy_dep, wayland_egl], + c_args : ['-ffunction-sections', '-fdata-sections'], + link_args : ['-Wl,--gc-sections']) +test('egl-context', egl_context_test, env : ['LIBGL_ALWAYS_SOFTWARE=1']) + +wayland_lifecycle_test = executable('owe-wayland-lifecycle-test', + files('wayland_lifecycle.c', '../src/common/log.c') + + [layer_shell_client_h, layer_shell_c, xdg_shell_c, viewporter_client_h, viewporter_c, + fractional_scale_client_h, fractional_scale_c], + include_directories : [common_inc, yyjson_inc, include_directories('..', '../src/render')], + dependencies : [wayland_client, wayland_egl], + c_args : ['-ffunction-sections', '-fdata-sections'], + link_args : ['-Wl,--gc-sections']) +test('wayland-lifecycle', wayland_lifecycle_test) + fake_ffmpeg = executable('fake-ffmpeg', 'fake_ffmpeg.c') regression = executable('owe-regression-test', files('regression.c', '../src/daemon/transcode.c', '../src/daemon/supervisor.c', '../src/daemon/watch.c') + common_sources + yyjson_sources, @@ -20,7 +55,7 @@ test('media', media_test, timeout : 45) decode_test = executable('owe-decode-test', files('decode.c', '../src/common/log.c', '../src/common/owe_spawn.c'), include_directories : [common_inc, include_directories('../src/render')], - dependencies : [avformat_dep, avcodec_dep, avutil_dep, swscale_dep], + dependencies : [avformat_dep, avcodec_dep, avutil_dep, swscale_dep, threads_dep], c_args : ['-ffunction-sections', '-fdata-sections'], link_args : ['-Wl,--gc-sections']) test('decode', decode_test, timeout : 45) diff --git a/test/power.c b/test/power.c new file mode 100644 index 0000000..da7c647 --- /dev/null +++ b/test/power.c @@ -0,0 +1,24 @@ +#include "../src/daemon/power.c" +#include +#include + +#define CHECK(x) do { if (!(x)) { fprintf(stderr, "%s:%d: %s\n", __FILE__, __LINE__, #x); exit(1); } } while (0) + +static int attempts, closes; +int sd_bus_open_system(sd_bus **bus) { *bus = NULL; attempts++; return -ECONNREFUSED; } +int sd_bus_process(sd_bus *bus, sd_bus_message **message) { + (void)bus; (void)message; return -ECONNRESET; +} +sd_bus *sd_bus_close_unref(sd_bus *bus) { (void)bus; closes++; return NULL; } +void owed_app_on_policy_changed(void) {} + +int main(void) { + struct owed_power p = {.system = (sd_bus *)(uintptr_t)1}; + CHECK(owed_power_poll(&p) == -1); + CHECK(closes == 1 && !p.system && owed_power_fd_system(&p) == -1); + CHECK(owed_power_poll(&p) == 0 && attempts == 0); + p.retry_at_ms = 0; + CHECK(owed_power_poll(&p) == 0 && attempts == 1); + CHECK(owed_power_poll(&p) == 0 && attempts == 1); + puts("disconnected system bus is removed from poll and reconnects with backoff"); +} diff --git a/test/regression.c b/test/regression.c index f9fb3a9..30da2b9 100644 --- a/test/regression.c +++ b/test/regression.c @@ -242,6 +242,15 @@ static void test_jobs(void) { owed_async_job_free(job); CHECK(!owed_transcode_file_ready(target)); no_parts(); + CHECK(setenv("OWE_TEST_FFMPEG_MODE", "large", 1) == 0); + owed_transcode_set_cache_limit(1); + CHECK(owed_transcode_gif(input, 20, 20, 640, 360, target, sizeof(target)) == 0); + CHECK(owed_transcode_file_ready(target)); + char poster[PATH_MAX]; + CHECK(owed_transcode_poster(target, poster, sizeof(poster)) == 0); + CHECK(owed_transcode_file_ready(poster) && owed_transcode_file_ready(target)); + owed_transcode_set_cache_limit(512); + CHECK(unsetenv("OWE_TEST_FFMPEG_MODE") == 0); groups++; } @@ -258,6 +267,23 @@ static void test_spawn(void) { groups++; } +static void test_abandoned_cache_files(void) { + const char *old = "owe/gif/abandoned.part-ABCDEF.mp4"; + const char *fresh = "owe/gif/active.part-ABCDEF.mp4"; + char path[PATH_MAX]; + put(old, "incomplete"); + put(fresh, "active"); + path_for(path, sizeof(path), old); + struct timespec times[2] = {{.tv_sec = time(NULL) - 86401}, {.tv_sec = time(NULL) - 86401}}; + CHECK(utimensat(AT_FDCWD, path, times, 0) == 0); + owed_transcode_cleanup_cache(); + CHECK(access(path, F_OK) != 0); + path_for(path, sizeof(path), fresh); + CHECK(access(path, F_OK) == 0); + CHECK(unlink(path) == 0); + groups++; +} + static void test_config_errors(void) { put("config.toml", "[pause]\nfullscreen=false\n[transcode]\ngif_fps=99999999999999999999999999999\n"); char path[PATH_MAX]; @@ -291,6 +317,7 @@ int main(int argc, char **argv) { test_display_state(); test_symlink(); test_jobs(); + test_abandoned_cache_files(); test_spawn(); test_config_errors(); char *remove[] = {"rm", "-rf", "--", root, NULL}, log[256]; diff --git a/test/render_feed.c b/test/render_feed.c index 9af2deb..15d5529 100644 --- a/test/render_feed.c +++ b/test/render_feed.c @@ -7,14 +7,28 @@ struct owe_still { int result; }; struct owe_feed { bool active; }; static owe_app_t app; static int render_requests; +static bool still_busy = true; +static int reload_width; +static int reload_height; owe_app_t *owe_app_get(void) { return &app; } void owe_app_request_render(void) { render_requests++; } void owe_feed_stop(struct owe_feed *feed) { feed->active = false; } void owe_mpv_set_muted(struct owe_mpv *mpv, bool muted) { mpv->muted = muted; } void owe_mpv_stop(struct owe_mpv *mpv) { mpv->stopped = true; } -bool owe_still_busy(struct owe_still *still) { (void)still; return true; } +bool owe_still_busy(struct owe_still *still) { (void)still; return still_busy; } int owe_still_poll(struct owe_still *still) { return still->result; } +bool owe_still_has_image(struct owe_still *still) { (void)still; return true; } +const char *owe_still_path(struct owe_still *still) { (void)still; return "/still.png"; } +int owe_still_decoded_max(struct owe_still *still, int *w, int *h) { + (void)still; *w = 1280; *h = 720; return 0; +} +void owe_wayland_outputs_max_size(struct owe_wayland *wl, int *w, int *h) { + (void)wl; *w = 3840; *h = 2160; +} +int owe_still_start(struct owe_still *still, const char *path, int w, int h) { + (void)still; (void)path; reload_width = w; reload_height = h; return 0; +} int main(void) { struct owe_mpv mpv = {.muted = true}; @@ -39,5 +53,33 @@ int main(void) { stop_feed(&app); CHECK(render_requests == 1); puts("renderer feed-to-still transition checks passed"); + + /* A late decode reply belongs to the original connection only. */ + int first[2], replacement[2]; + CHECK(socketpair(AF_UNIX, SOCK_STREAM, 0, first) == 0); + ipc.clients[0].fd = -1; + owe_ipc_client_open(&ipc.clients[0], first[0]); + ipc.pending_client = 0; + ipc.pending_generation = ipc.clients[0].generation; + client_remove(&ipc, 0); + close(first[1]); + CHECK(socketpair(AF_UNIX, SOCK_STREAM, 0, replacement) == 0); + owe_ipc_client_open(&ipc.clients[0], replacement[0]); + pending_reply(&ipc, 1, NULL); + char reply[64]; + CHECK(recv(replacement[1], reply, sizeof(reply), MSG_DONTWAIT) < 0 && errno == EAGAIN); + ipc.pending_client = 0; + ipc.pending_generation = ipc.clients[0].generation; + pending_reply(&ipc, 1, NULL); + CHECK(recv(replacement[1], reply, sizeof(reply), 0) > 0); + client_remove(&ipc, 0); + close(replacement[1]); + puts("still replies follow connection generations"); + CHECK(!owe_render_ipc_reload_still(&ipc)); + CHECK(reload_width == 0); + still_busy = false; + CHECK(owe_render_ipc_reload_still(&ipc)); + CHECK(reload_width == 3840 && reload_height == 2160); + puts("output growth retries after the current still decode"); return 0; } diff --git a/test/supervisor.c b/test/supervisor.c new file mode 100644 index 0000000..11c9b81 --- /dev/null +++ b/test/supervisor.c @@ -0,0 +1,25 @@ +#include "../src/daemon/supervisor.c" +#include + +#define CHECK(x) do { if (!(x)) { fprintf(stderr, "%s:%d: %s\n", __FILE__, __LINE__, #x); exit(1); } } while (0) +static int spawns; +int owe_spawn(const char *file, char *const argv[], pid_t *pid) { + (void)file; (void)argv; (void)pid; + spawns++; + return -1; +} + +int main(void) { + struct owed_supervisor supervisor = {0}; + CHECK(owed_supervisor_send(&supervisor, "{}", NULL, 0) < 0); + CHECK(spawns == 0); + pid_t child = fork(); + CHECK(child >= 0); + if (child == 0) _exit(0); + siginfo_t info; + CHECK(waitid(P_PID, (id_t)child, &info, WEXITED | WNOWAIT) == 0); + supervisor.child = child; + CHECK(owed_supervisor_send(&supervisor, "{}", NULL, 0) < 0); + CHECK(supervisor.child == 0 && spawns == 0); + puts("only explicit supervisor restarts spawn renderers"); +} diff --git a/test/wayland_lifecycle.c b/test/wayland_lifecycle.c new file mode 100644 index 0000000..e366860 --- /dev/null +++ b/test/wayland_lifecycle.c @@ -0,0 +1,77 @@ +#include "../src/render/wayland.c" +#include +#include + +#define CHECK(x) do { if (!(x)) { fprintf(stderr, "%s:%d: %s\n", __FILE__, __LINE__, #x); exit(1); } } while (0) + +struct proxy { bool alive; }; +static struct proxy proxies[32]; +static int proxy_count, windows, surfaces, failure; +static owe_app_t app; + +static struct wl_proxy *new_proxy(void) { + CHECK(proxy_count < 32); + proxies[proxy_count].alive = true; + return (struct wl_proxy *)&proxies[proxy_count++]; +} + +void wl_proxy_destroy(struct wl_proxy *proxy) { + struct proxy *p = (struct proxy *)proxy; + CHECK(p->alive); + p->alive = false; +} +uint32_t wl_proxy_get_version(struct wl_proxy *proxy) { (void)proxy; return 4; } +int wl_proxy_add_listener(struct wl_proxy *proxy, void (**listener)(void), void *data) { + (void)proxy; (void)listener; (void)data; return 0; +} +struct wl_proxy *wl_proxy_marshal_flags(struct wl_proxy *proxy, uint32_t opcode, + const struct wl_interface *interface, uint32_t version, uint32_t flags, ...) { + (void)opcode; (void)version; + if (flags & WL_MARSHAL_FLAG_DESTROY) wl_proxy_destroy(proxy); + if (!interface) return NULL; + if (failure == 1 && interface == &zwlr_layer_surface_v1_interface) return NULL; + return new_proxy(); +} +struct wl_egl_window *wl_egl_window_create(struct wl_surface *surface, int width, int height) { + (void)surface; (void)width; (void)height; + if (failure == 2) return NULL; + windows++; + return (struct wl_egl_window *)(uintptr_t)1; +} +void wl_egl_window_destroy(struct wl_egl_window *window) { (void)window; windows--; } +void *owe_egl_create_window_surface(struct owe_egl *egl, void *window) { + (void)egl; (void)window; + if (failure == 3) return NULL; + surfaces++; + return (void *)(uintptr_t)2; +} +void owe_egl_destroy_output(struct owe_egl *egl, struct owe_output *out) { + (void)egl; + if (out->egl_surface) { surfaces--; out->egl_surface = NULL; } +} +owe_app_t *owe_app_get(void) { return &app; } +void owe_app_request_render(void) {} +bool owe_still_has_image(struct owe_still *still) { (void)still; return false; } + +int main(void) { + app.egl = (struct owe_egl *)(uintptr_t)3; + for (failure = 0; failure <= 3; failure++) { + memset(proxies, 0, sizeof(proxies)); + proxy_count = windows = surfaces = 0; + struct owe_wayland wl = {0}; + wl.compositor = (struct wl_compositor *)(uintptr_t)4; + wl.layer_shell = (struct zwlr_layer_shell_v1 *)(uintptr_t)5; + wl.viewporter = (struct wp_viewporter *)(uintptr_t)6; + wl.fractional_scale = (struct wp_fractional_scale_manager_v1 *)(uintptr_t)7; + owe_output_t *out = output_new(&wl, (struct wl_output *)new_proxy(), 1); + if (failure) { + CHECK(out == NULL); + } else { + CHECK(out && wl.output_count == 1); + output_free(&wl, out); + } + CHECK(wl.output_count == 0 && wl.outputs == NULL && windows == 0 && surfaces == 0); + for (int i = 0; i < proxy_count; i++) CHECK(!proxies[i].alive); + } + puts("Wayland creation failures release every owned proxy"); +}