Files
Kiwy-Signage/.github/instructions/kiwy-build-and-development.instructions.md
ske087 d8c6ab0bc5 Fix video hang and silent-video crash; add 24/7 watchdog
Two independent failures were killing long unattended runs.

1. HANG at the end of a video (Windows AppHangB1)

The player froze after ~30-45 minutes of looping, always at a video item. The
playback trace stopped dead right after "video_loaded" with no "video_eos" and
no "advance_after_video_eos", and Windows logged AppHangB1 rather than a crash.

Cause, all inside Kivy and verified against the installed source:

  1. ffpyplayer fires on_eos.
  2. Kivy's Video widget binds its OWN handler first (kivy/uix/video.py
     _do_video_load), and that handler sets state = 'stop' DURING the event
     dispatch.
  3. state = 'stop' -> VideoFFPy.stop() -> unload(), which calls
     self._thread.join() with no timeout (the source even carries the comment
     "TODO: use callback, don't block here").
  4. When that decode thread is slow to exit, the Kivy/SDL main thread never
     returns, so the window stops pumping messages.

It is a race, which is why it looked random and only appeared after many videos.

src/video_safety.py bounds that join. ffpyplayer has already been told to quit
and its thread woken before the join, so limiting the wait does not leak work;
it only stops an unresponsive thread from taking the whole player down. The
guard is installed before the Video widget is constructed, because the decode
thread is created during play().

The intro video had the same hazard on the main thread (state='stop' followed by
unload() inside the state callback) and is now torn down on a worker thread like
playlist videos.

2. CRASH in SDL2_mixer.dll (0xc0000005) on a video with no audio stream

Triggered when a silent 4K clip entered the playlist while the item was marked
audio: on. ffpyplayer initialises SDL2_mixer from the FIRST audio file it opens
and reuses those parameters, so a file with no audio stream (rate/channels 0)
makes SDL2_mixer dereference garbage. Muting via volume=0.0 does NOT avoid it -
the audio stream itself must be disabled.

play_video now probes the file with ffprobe and forces mute when it has no audio
track, so such a file can never reach ffpyplayer with sound enabled. The probe
fails safe (assumes audio present) if ffprobe is unavailable.

3. 24/7 supervision (solution A + C)

windows/watchdog.ps1 + start_player_watchdog.bat restart the player when it
crashes (process gone) or hangs (process alive but .player_heartbeat stale),
with a crash-loop breaker that backs off when it cannot stay up. This is the
Windows counterpart of the proven Linux start.sh watchdog.

The exit-screen password remains the only supported way to stop the player. On
success it writes .player_stop_requested next to the .exe and the watchdog
stands down instead of restarting. The flag is SESSION SCOPED: the watchdog
clears it on every start, so launching again begins a new session and there is
no file to delete by hand. Clearing on start also means a power cut cannot leave
the player permanently off.

Deliberately NOT done: a Windows service. A service runs in session 0 with no
desktop, so the player could not render to the screen at all. A login-triggered
startup entry is the correct Windows analogue of the Pi's systemd unit.

Important detail: the packaged player is TWO processes (PyInstaller bootloader
parent plus the child that owns the SDL window), so any kill uses taskkill /T or
the visible window survives and the next launch collides with it.

Verified in the packaged exe over a 7-hour run: 170 playlist restarts, 1365
items, 171 web links launched/visible/ended with zero failures, and no crashes,
no hangs and no leaked browser processes.

Tests: windows/test_video_hang.py and windows/test_watchdog.py. The hang test
deliberately holds the heartbeat open with an exclusive Windows lock (share mode
0) so the player's own write fails - backdating the file's mtime does NOT
simulate a hang, because the healthy player rewrites it immediately and the test
would then pass for the wrong reason.
2026-09-13 10:14:42 +03:00

274 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
description: "Use when building, compiling, packaging or releasing the Kiwy Signage Player: PyInstaller build, .exe generation, build.spec, hiddenimports, code signing, Smart App Control, Windows development, Raspberry Pi deployment, or adding new modules under src/. Covers the exact build commands, environment constraints, bundling rules and verification steps."
name: "Kiwy Build & Development"
---
# Kiwy Signage Player — Build & Development
Cross-platform Kivy digital signage player.
- **Raspberry Pi / Linux** — `src/main.py` is the entry point (root `install.sh`, `start.sh`).
- **Windows** — `windows/run_win.py` is the entry point; it sets Windows env vars, imports
`main.py`, then monkey-patches platform differences. Packaged to `.exe` with PyInstaller.
## Ground Rules
- **Keep `src/main.py` cross-platform.** Windows-specific behaviour belongs in
`windows/run_win.py` (see `_patch_main()`), Pi-specific behaviour in `main.py` guarded
by capability checks. Do not add Windows-only imports to `main.py`.
- **Rebuild is mandatory.** The `.exe` bundles `src/`, so Python edits are invisible until
you rebuild. There is no hot reload in the packaged app.
- **`dist/` is a runtime folder, not just build output.** It holds `config/`, `media/`,
`playlists/`, `logs/`, `.kiosk-profile/` and `.player_heartbeat`. Never wipe `dist/`
blindly; a `--clean` rebuild preserves it, but a manual delete destroys player state.
## Environment
| Item | Value |
|------|-------|
| Interpreter | **Python 3.12.9** (64-bit) — project venv at `windows\venv` |
| Kivy | 2.3.1 |
| PyInstaller | 6.21.0 |
| Linux target | Python 3.13 (`repo/python-wheels/`, `repo/system-packages/`) |
- **Python 3.13+/3.14 is NOT supported for the Windows build** — Kivy 2.3.1 has no wheels
for them.
- `cefpython3` is **not installed** in the project venv (its wheels stop at Python 3.9).
The embedded-CEF engine in `windows/cef_browser.py` is therefore dormant on this
build — do not rely on it.
- **Web links use embedded WebView2** (`windows/webview2_browser.py`), driven through
`pythonnet` (installed). Two extra things ship with the exe:
- `windows/webview2_sdk/` — ~860 KB of SDK DLLs (`Microsoft.Web.WebView2.Core.dll`,
`WebView2Loader.dll`). **Tracked in git on purpose**: without them WebView2
silently downgrades to the Chrome/Edge subprocess engine.
- `windows/webview2_runtime/MicrosoftEdgeWebview2Setup.exe` — ~1.7 MB bootstrapper,
used to install the Runtime on a machine that lacks it. The ~203 MB offline
standalone installer is **git-ignored**; fetch it with
`windows/webview2_runtime/download_runtime_installers.ps1 -Offline` when you need
to deploy to machines with no internet (it makes the exe ~305 MB).
## Build
From `windows\`:
```
venv\Scripts\python.exe -m PyInstaller build.spec --clean --noconfirm
```
Or the full one-click path (creates venv, installs deps, builds, then signs):
```
build_win.bat
```
Output (folder mode, via `COLLECT`):
```
windows\dist\KiwySignagePlayer\KiwySignagePlayer.exe <- the real build output
```
**Deployment hazard:** `windows\dist\KiwySignagePlayer.exe` (one level up) is a stale
leftover from an older single-file build of the same name. It is not refreshed by the
current spec, so deploying it ships old code. Always take the executable from the
`KiwySignagePlayer\` subfolder, and delete the stray copy if it reappears.
### `build.spec` facts
- Entry point `run_win.py`; `pathex=[BUILD_DIR, SRC_DIR]` so `src/` modules resolve.
- `runtime_hooks=[pyi_runtime_hook.py]` — sets per-monitor DPI awareness **before** SDL/Kivy
initialise. Keep DPI work here; it must run before any window is created.
- `datas += Tree(src)` bundles all of `src/`. `console=True` so startup errors and the Kivy
log remain visible — do not flip this to `False` without a reason.
- `hiddenimports` must list modules that PyInstaller's static analysis **cannot** see:
lazily imported ones (`cef_browser` is imported inside a function) or dynamic imports
(`getattr`, `importlib`). Top-level imports such as `weblink_session` are found
automatically via `pathex`, but listing them is harmless insurance.
**When you add a module under `src/` that is imported lazily or by string, add it to
`hiddenimports` or the packaged exe will fail at runtime with `ModuleNotFoundError`.**
- `excluded_imports` drops Linux-only packages (`evdev`, `gi`, GStreamer) and the
non-matching `cefpython3` `.pyd` variants.
## First-run setup (no bundled credentials)
`config/app_config.json` is **deliberately NOT bundled** into the exe, and
`player_auth.json` is excluded from `Tree(src)`. Shipping either one planted the
build machine's `server_ip` / `screen_name` / `auth_code` into every install, so
a fresh machine silently connected to the wrong server (or played a stale
playlist) instead of asking.
What happens on a machine with no usable config:
1. The splash video (`config/resources/intro1.mp4`) plays.
2. A notice appears: *"Player is not configured"*.
3. After `SETUP_NOTICE_SECONDS` (**5 s**, `src/main.py`) the Settings screen
opens automatically.
4. Saving valid values writes `config/app_config.json` next to the **.exe** and
playback starts immediately — no restart needed.
On a machine that **is** configured, the notice and Settings are skipped and the
cached playlist plays straight away.
Key functions in `src/main.py`:
| Name | Role |
|------|------|
| `config_is_configured(config)` | Single source of truth. Missing file, empty/unparseable JSON, missing keys and leftover placeholders (`localhost`, `kivy-player`, `1234567`, `127.0.0.1`) all count as **unconfigured**. |
| `DEFAULT_CONFIG` | Blank-credential starting point, so a fresh install can never look configured. |
| `SignagePlayer.on_intro_finished()` | The one decision point after the splash: setup vs. playback. Both intro paths (video end and "no intro file") go through it. |
| `show_setup_required_notice()` | Shows the notice, then opens Settings after 5 s. |
| `on_first_run_config_saved()` | Re-syncs and starts playback. |
`config_is_configured()` is covered by `windows/test_first_run_setup.py` — run it
after touching that logic.
> When adding a new **required** config key, add it to `CONFIG_REQUIRED_KEYS`
> (and to `CONFIG_PLACEHOLDER_VALUES` if it has a placeholder default), or the
> first-run flow will not ask for it.
## 24/7 Supervision (Windows)
A signage player must survive crashes and hangs unattended. On **Linux/Pi** this is
`start.sh` (systemd or `./start.sh`). On **Windows** it is `windows/watchdog.ps1`,
launched by `windows/start_player_watchdog.bat`.
It restarts the player when it:
- **crashed** — the process disappeared; or
- **hung** — the process is alive but `.player_heartbeat` has gone stale
(the player rewrites it every 10 s; the watchdog treats >60 s as hung). A frozen
player is otherwise indistinguishable from a working one.
It also backs off if the player cannot stay up (crash-loop breaker:
`CrashLoopMaxFailures` failures in `CrashLoopWindowMin` minutes without ever
becoming healthy → `CrashLoopBackoffMin` minutes of quiet).
### The stop flag is SESSION-SCOPED
The only supported way to stop the player is the **exit-screen password**. On
success the player writes `.player_stop_requested` next to its `.exe`, and the
watchdog then **stands down instead of restarting**.
The flag is cleared **every time the watchdog starts**, which is what makes it
session-scoped: the password exit ends the *current* session, and the next launch
begins a new one. There is no file to delete by hand.
> **Ordering matters:** the flag is the operationally-correct choice over a
> "remember the last exit" setting, because a reboot must come back up playing.
> Clearing on start also means a power cut cannot leave the player permanently off.
### Two failure modes that must not be confused
| Situation | Correct reaction |
|---|---|
| Was healthy, then heartbeat went stale | **Restart promptly** (this is a hang) |
| Running but never became healthy (slow start, bad install, the **first-run setup screen**) | **Be patient**, then restart after `2 × StartupGraceSec` |
Never kill a player that has not yet been healthy — it may be an operator entering
server settings on the first-run setup screen.
### Important Windows-specific facts
- The packaged player is **TWO processes** (PyInstaller bootloader parent + the
child that owns the SDL window). Any kill must use `taskkill /T` or the visible
window survives and the next launch collides with it.
- The watchdog can only *restart* an install that already has a working config.
It cannot help before the player has first run.
- Hiding the console window (`console=False`) is **not** done: the console is the
only way to see a start-up failure, and the build instructions warn against
flipping it without reason.
- **No Windows service.** A service runs in session 0 with no desktop, so the
player could not render to the screen. A login-triggered task is the correct
Windows counterpart to the Pi's systemd unit — that is what
`start_player_watchdog.bat` is for (add it to the Startup folder for autostart).
### Verify the watchdog
```
cd windows
venv\Scripts\python.exe test_watchdog.py # crash + hang + stop-flag + new-session
```
Check state at any time (read-only):
```
powershell -NoProfile -ExecutionPolicy Bypass -File windows\watchdog.ps1 -Status
```
> When testing a hang, do **not** just backdate the heartbeat file's mtime — the
> healthy player rewrites it immediately, so nothing is actually stale and the
> test passes for the wrong reason. Hold the file open with an exclusive Windows
> lock (share mode 0) so the player's own write fails; that reproduces a genuine
> stale heartbeat while the process stays alive.
## Code Signing (production constraint)
Production PCs run with **Smart App Control enforced** (`VerifiedAndReputablePolicyState=1`,
UMCI enforced) with **no "Run anyway" bypass** — an unsigned exe is blocked at kernel level.
- A **self-signed certificate does NOT satisfy Smart App Control.** A cert from a reputable
public CA is required. `create_self_signed_cert.ps1` is dev-only.
- Provide a cert as `KIWY_SIGN_PFX` (+ optional `KIWY_SIGN_PFX_PASSWORD`) or drop
`kiwy_signing.pfx` in `windows\`; `build_win.bat` then signs via `sign_exe.ps1`
(signtool with RFC3161 timestamp when available).
- See `documentation/CODE_SIGNING_SMART_APP_CONTROL.md`.
## Verify Before Committing
Fast syntax gate (no build, seconds):
```
python -m py_compile src/main.py src/weblink_session.py windows/run_win.py windows/webview2_browser.py windows/webview2_runtime.py
```
Web-link engine checks (no build, no player):
```
cd windows
venv\Scripts\python.exe test_webview2_embed.py # embeds WebView2 in a bare Win32 window
venv\Scripts\python.exe test_webview2_runtime.py # Runtime detection + installer discovery
```
Rebuild warning: the bundled offline installer makes the exe ~305 MB. If you do not
need offline machines, delete
`windows/webview2_runtime/MicrosoftEdgeWebView2RuntimeInstallerX64.exe` before building.
**Close the running player before rebuilding.** A running
`dist\KiwySignagePlayer\KiwySignagePlayer.exe` locks the output and the build fails with
"Access is denied". Chrome/Edge kiosk processes left over from web-link playback can also
hold locks; if the build fails, check for stray `msedge.exe` / `chrome.exe` before retrying.
Because `.exe` is a **reserved Windows device name** as well as a real file, use
`Test-Path -LiteralPath` when probing for the executable, otherwise the path silently
resolves to the console device.
Development run — **use this on a SAC-locked host**, since the unsigned exe cannot launch:
```
cd windows
venv\Scripts\activate
python run_win.py
```
Diagnose playback transitions via the trace log (`logs\playback_trace.log`), written by
`src/playback_trace.py`.
## Release Checklist
1. Bump `PLAYER_VERSION` in `src/main.py` **and** `filevers`/`prodvers`/`FileVersion`/
`ProductVersion` in `windows\version_info.txt`; keep them in sync.
2. Rebuild (`build_win.bat`).
3. Confirm the exe is signed (`Get-AuthenticodeSignature`); unsigned builds are blocked on
production hosts.
4. Smoke-test a mixed playlist: image → video → weblink → image, verifying durations, audio
(`audio: off` / `muted`), and a clean exit/restart.
## Commit Hygiene
- Never commit `windows\build\` or `windows\dist\` (git-ignored). Tracked build output such
as `windows\archive_list.txt` and `windows\build_last.txt` is the exception, not the rule.
- **Do not commit `player_auth.json` or `src/player_auth.json`** — they contain live
credentials (`auth_code`, `player_id`, `server_url`). These are currently tracked; treat any
future change to them as a deliberate, reviewed decision.
- Do not commit `config\app_config.json` values that are host-specific without checking
whether they belong in the repo default.