fix(rpi4): enable pcscd — without it the kiosk hangs before drawing

This is the white screen. Not graphics, not the bundle, not pairing.

The app constructs @pokusew/pcsclite at startup. That calls
SCardEstablishContext(), which calls SCardCheckDaemonAvailability(), which
— finding no pcscd — BUSY-LOOPS in fstatat64 at ~92% CPU rather than
returning an error. It runs on Electron's main thread before the
BrowserWindow is created, so no window is ever made and the panel stays
white.

It is a hang, not a crash, which is why it presented so badly. Nothing
throws. Nothing is logged after "[StateStore] Initialized database". The
process looks healthy: systemd reports the service active, Electron is
running, and there is even a gpu-process. But a setInterval registered
before startup never fires once in 32 seconds, the main thread sits in
state R, and the remote debugger reports zero page targets.

V8's own tooling cannot see it either, because the thread never yields to
the inspector: Debugger.pause returns nothing and Profiler.stop times out.
A native backtrace was the only thing that worked:

  #0 fstatat64                     libc
  #1 SCardCheckDaemonAvailability  libpcsclite
  #2 SCardEstablishContext         libpcsclite
  #3 PCSCLite::PCSCLite()          pcsclite.node
  #4 PCSCLite::New(...)

Ruled out along the way, each by direct test on the machine: graphics (it
fails identically with the GPU fully disabled), Electron on aarch64 (a
minimal app renders fine), the Vue bundle (loading the real index.html
from a minimal main process mounts the app and reaches "[ATM] State
machine initialized"), the preload script, the CSP, kiosk and fullscreen
window options, better-sqlite3, /dev/shm, memory, page size, X
authorisation, and isDev.

upboard.nix and batm3.nix both enable pcscd for their real readers, which
is why no x86 machine has ever hit this. This module did not, and that was
the entire difference. pcscd with no reader attached simply idles, so
enabling it costs nothing.

Worth noting for the wider fleet: any future board that omits pcscd
inherits this, and it presents as a blank screen with a healthy-looking
service. The robust fix is for the app to not block its main thread on a
card-reader handshake at all — the NFC path is already documented as
best-effort — but that is an app change and this unblocks the hardware.
This commit is contained in:
Padreug 2026-09-25 22:09:36 +02:00
commit 2572a14c61

View file

@ -122,6 +122,41 @@
hardware.enableRedistributableFirmware = true;
# pcscd MUST be enabled, and not because this board has a card reader.
#
# The app constructs @pokusew/pcsclite at startup. That calls
# SCardEstablishContext(), which calls SCardCheckDaemonAvailability(), which
# — when there is no pcscd to find — BUSY-LOOPS in fstatat64 at ~92% CPU
# instead of returning an error. It runs on Electron's main thread, before
# the BrowserWindow is created, so the window never appears and the panel
# stays white forever. Nothing is logged, nothing throws, and V8's own
# inspector cannot be serviced because the thread never yields: CDP
# Debugger.pause and Profiler.stop both hang. It took a native gdb backtrace
# to see it at all:
#
# #0 fstatat64 libc
# #1 SCardCheckDaemonAvailability libpcsclite
# #2 SCardEstablishContext libpcsclite
# #3 PCSCLite::PCSCLite() pcsclite.node
#
# The x86 machines never hit this because upboard.nix and batm3.nix both
# enable pcscd for their actual readers. This module did not, which is the
# entire difference. A running pcscd with no reader attached just idles, so
# this is cheap insurance rather than a claim about the hardware.
services.pcscd.enable = true;
# pcscd gates client access via polkit; without a rule the `bitspire` service
# user is "Rejected unauthorized PC/SC client". Same wiring as upboard.nix.
security.polkit.extraConfig = ''
polkit.addRule(function(action, subject) {
if ((action.id == "org.debian.pcsc-lite.access_pcsc" ||
action.id == "org.debian.pcsc-lite.access_card") &&
subject.user == "bitspire") {
return polkit.Result.YES;
}
});
'';
# Kiosk: never sleep.
systemd.targets = {
sleep.enable = false;