Aller au contenu

One seat: what pupitre-seat does

/usr/lib/pupitre/pupitre-seat <N> runs one seat, that is one Dell Wyse 1010 client, as one X session. It is the ExecStart of pupitre-seat@N.service, which the manager starts when the client mapped to seat N broadcasts. It is scoped by seat number so that a dozen copies coexist on one host, and it never kills a process by name: only PIDs it started itself.

Per seat paths

What Where
Environment from the manager /run/pupitre/seat-N.env
X display :(100+N), seat 3 is :103
Runtime dir /run/pupitre/seat-N/
Named pipes /run/pupitre/seat-N/{video,input,audio}
Xvfb framebuffer /run/pupitre/seat-N/fb/Xvfb_screen0
XDG_RUNTIME_DIR /run/pupitre/seat-N/xdg
User pupitre-seat-N, created on first use; the desktop, its applications, PulseAudio and the key's mount run as this user
X cookie /run/pupitre/seat-N/xauthority, owned by the seat's user, passed to Xvfb with -auth
HOME /var/lib/pupitre/home/seat-N (created on demand, owned by the seat's user)
Logs /var/log/pupitre/seat-N/{session,xvfb,desktop,input,encoder}.log, plus pulseaudio.log, curseur.log, cle.log, cle-fuse.log and lightdm.log depending on the features in use
fast_tcp2 log /var/log/pupitre/seat-N/fast_tcp2_<date>_<client IP>.log, 0600 (it carries input reports), the 10 most recent kept, path printed in session.log

Step by step

  1. Environment. Reads /run/pupitre/seat-N.env unless systemd already loaded it (EnvironmentFile=). PUPITRE_MAC, PUPITRE_SERVER_IP and PUPITRE_IFACE are required; the launcher exits 1 with a clear message otherwise.
  2. Directories and pipes. Creates the runtime dir, the log dir and the home, wipes the framebuffer dir, recreates the video and input pipes. With PUPITRE_AUDIO=1 it also creates the audio pipe and exports WYSE_AUDIO, then starts the seat's PulseAudio (null sink wyse) and the parec bridge into the pipe (on by default, see the install guide).
  3. Xvfb on the seat display, 1280x1024x24, -fbdir so the encoder can mmap the framebuffer, -nolisten tcp. Waits for the X socket instead of a fixed sleep.
  4. Appearance. pupitre-apparence <display> <config home> writes the four xfconf channels the session is about to read: the PrimTux 9 look, and above all a flat backdrop, Xfce's gradient wallpaper costing 6.4 times its RLE on every full screen. Optional and degrading on its own, PUPITRE_APPARENCE=0 skips it. See docs/apparence.md.
  5. Desktop. dbus-run-session -- xfce4-session with DISPLAY, HOME, XDG_RUNTIME_DIR and the XDG config, cache and data dirs pointing into the seat's home.
  6. Input injector. input_inject.py --tube <input pipe> --display :D. It injects key codes and pointer events through XTEST with python3-xlib, and blocks on the pipe until fast_tcp2 opens it for writing.
  7. Protocol environment. Exports the WYSE_* variables that fast_tcp2 reads (it inherits them through the session process). Each group has a comment in the script saying why; the two that matter most are WYSE_EXTRA_TUNNELS=1 (the keyboard and mouse tunnels) and WYSE_POLL_ALL=1 (without it not one input report comes back).
  8. Session process with retry. Runs python3 -m pupitre.main --ip <server ip> --iface <iface> --mac <mac> (UDP discovery for that MAC only, then fast_tcp2 and the video handshake). One handshake in four fails on the device side, so up to five attempts, each judged ready by two conditions read from the fast_tcp2 log with awk (the log contains NUL bytes, grep would treat it as binary): the flux vivant line, meaning the client reached its frame loop, and a win= value of at least 2048 and below the default window, meaning the device armed a useful 4321 window. A failed attempt gets fast_tcp2 SIGTERMed first, then the session, then a cooldown: under about 20 s the device does not answer the next attempt at all. The cooldown starts at 25 s and DOUBLES after each degraded attempt, up to 300 s, and is not slept after the last one.
  9. Encoder. Once ready, encoder.py --sans-xvfb --tube <video pipe> --fbdir <fb> --fps 8 --jobs 1 --double 3 --duree 0 (0 means no time limit) turns dirty stripes of the framebuffer into RLE stripes on the video pipe. --double matches WYSE_HEAD_FLIP=1: double buffering with the flip in each stripe head, which is what removes tearing. Mode 3, the default (PUPITRE_DOUBLE), sends each buffer only what it lacks; mode 1 sends every changed zone to both, twice the bytes.
  10. Watch. Every 2 s checks that the session process, the encoder and Xvfb are alive, and exits 1 as soon as one is gone. systemd restarts the instance (Restart=on-failure, the delay growing geometrically from RestartSec=10 to RestartMaxDelaySec=900), and the manager restarts it on the client's next broadcast anyway.

Shutdown order

On exit, SIGTERM or SIGINT the launcher tears its own tree down in a fixed order:

  1. SIGTERM to the fast_tcp2 child of the session process, alone, then 2 s. Its signal handler sends a FIN on every connection. Without this the device keeps its connections open and refuses the next session until it is power cycled.
  2. SIGTERM to the session process, the encoder, the injector, the desktop tree and Xvfb.
  3. 2 s later, SIGKILL to what is left of that tree, then the pipes are removed.

The unit uses KillMode=mixed and TimeoutStopSec=15: systemd signals the launcher only and lets it do the above, then kills the whole cgroup if it has not finished.

Running one seat by hand

With the package installed, stop the manager's instance first so that two launchers do not fight for the same device, then run the launcher in a terminal:

sudo systemctl stop pupitre-seat@3
sudo env PUPITRE_SEAT=3 PUPITRE_MAC=00:80:64:aa:bb:cc PUPITRE_CLIENT_IP=192.0.2.42 \
         PUPITRE_SERVER_IP=192.0.2.6 PUPITRE_IFACE=enp1s0 \
         /usr/lib/pupitre/pupitre-seat 3

Or write /run/pupitre/seat-3.env by hand and pass only the seat number: the launcher reads the file when PUPITRE_MAC is not in its environment. Ctrl-C runs the ordered shutdown.

When the pupil switches the device off

A client that DIES is seen at once: the launcher watches four vital processes and rebuilds the link. A client that SURVIVES while talking into the void is the ordinary case: the pupil switches the device off and on again, or unplugs the network. The driver stays alive, the status file still says ready, and the screen stays blank for the rest of the lesson.

The witness is the client's own log: every packet RECEIVED from the device leaves a 1234< t=..., 4321< t=... or 1235< t=... line there, unconditionally. The launcher samples the last one every two seconds and calls the link dead once it has not moved for PUPITRE_SILENCE seconds.

In normal operation the largest gap between two inbound packets is about 2 s, so fifteen seconds leaves a wide margin. It does not fire on a device stall either: during a stall the device keeps acknowledging at win=24 every few milliseconds, and stall tolerance stays where it belongs, in the client (twelve stalled frames in a row).

The desktop is kept. A dead link does not restart the seat: only the link is rebuilt, and Xvfb, the desktop, PulseAudio and the injector stay up, so the pupil finds their windows where they left them. The line is not "what produces pixels" but what survives the pipe closing:

Side Processes Why
session Xvfb, xfce4-session, PulseAudio, the parec bridge, the injector the injector reopens its pipe instead of exiting, and the bridge is already restarted in a loop
link pupitre.main and its fast_tcp2, the encoder, the hardware cursor, the key watcher they open their pipe ONCE and have nobody left once the client is gone

Two consequences worth knowing. The encoder is killed explicitly on every reconnection even when it looks dead already: it only takes its BrokenPipeError on the first write after the reader leaves, and a still desktop writes nothing, so on a fixed screen it survives the client with its descriptor still open. Left alive, it and its successor would both write into the same pipe, interleaved. The key watcher is cycled too: its socket is created by fast_tcp2, and a FUSE left mounted on a dead socket would never give the key back.

A desktop that dies is a different matter: nothing here can repair it, the launcher exits 1 and systemd restarts the whole seat.

PUPITRE_GRACE (600 s, 0 to never give up) bounds the wait. Past it the seat releases its memory rather than holding a desktop nobody is watching, and it exits 0, NOT 1: a failed unit raises the manager's restart backoff, while a clean exit leaves the unit inactive and the device's next announce is served at once. The grace is judged at the top of the loop, and only a session of at least PUPITRE_UTILE seconds (60) resets its clock: a link that opens and hangs up at once would otherwise keep going through the success branch and the seat would run forever on a failing device. The status file says attente while the desktop is up and the device is away; the console files that under "starting", which is fair, and while the device stays off it sees the seat "offline" from its announces anyway.

Knobs, all environment variables: PUPITRE_AUDIO=0 (no sound; by default the seat has its own PulseAudio, a null sink wyse and a parec bridge into the audio pipe), PUPITRE_FPS (8), PUPITRE_DEBIT_MAX (0, the video throughput cap in KiB/s, 0 for none), PUPITRE_ATTEMPTS (5), PUPITRE_READY_TIMEOUT (45 s per attempt), PUPITRE_RETRY_COOLDOWN (25 s), PUPITRE_SILENCE (15 s, 0 to disable: how long the device may stay silent before the link is called dead, see below), PUPITRE_GRACE (600 s, 0 to never give up: how long a seat keeps its desktop up without a link), PUPITRE_UTILE (60 s, below which a session counts as a flap), PUPITRE_QUIET_KEYS=0 to log key contents while debugging (never in production, a session may contain a password), PUPITRE_PYTHON to use another interpreter, PUPITRE_CLE=0 to disable the USB key. With the key enabled (the default), fast_tcp2 serves the key plugged into the client on /run/pupitre/seat-N/cle.sock, readable by the seat user only, and nothing is mounted by default. pupitre-cle N surveiller, started by the seat, keeps wyse_cle_fuse running in /run/pupitre/seat-N/cle-img (the key as a plain file disque.img, 0 bytes while no key is open) and puts a "Clé USB" launcher on the desktop while a key is plugged in. A double click (pupitre-cle N monter) mounts the key's FAT with fusefatfs in ~/Clé USB and opens it; the launcher becomes "Éjecter la clé USB" (pupitre-cle N ejecter). A key pulled out while mounted is unmounted and its launcher removed. The kernel never mounts the key. tools/cle_fuse_essai.sh tests the file layer without a client; mdir -i <dir>/disque.img@@<offset> :: reads the image directly, the offset being the partition's first sector times 512.

From a source checkout, without the package, scripts/pupitre-seat finds the tools in tools/ and puts the tree on PYTHONPATH when /usr/lib/pupitre/fast_tcp2 does not exist; src/fast_tcp2/fast_tcp2 must be built and given its capabilities (scripts/setcap.sh), and the nft rule installed (scripts/nft-setup.sh). PUPITRE_LIBDIR overrides the tools directory.

To watch a seat: journalctl -fu pupitre-seat@3 for the launcher, tail -f /var/log/pupitre/seat-3/session.log for the session driver, and the fast_tcp2 log it names.

Pupils' logins (PUPITRE_CONNEXION=eleves)

By default (PUPITRE_CONNEXION=fixe) a seat opens one fixed desktop. In eleves mode the seat shows a LightDM login screen on its own Xvfb instead, and each pupil gets a session under their own account. The Xvfb and the fast_tcp2 link are never restarted: a logout brings the login screen back in about a second.

Enabling it, per seat. A systemd drop-in, then a restart of that seat:

# /etc/systemd/system/pupitre-seat@N.service.d/eleves.conf
[Service]
Environment=PUPITRE_CONNEXION=eleves

systemctl daemon-reload && systemctl restart pupitre-seat@N

Restart LightDM once, after the package is first installed. LightDM reads its configuration (/usr/share/lightdm/lightdm.conf.d/50-pupitre.conf, not a conffile) only at start, and restarting it closes every open session, which is why the package does not do it. Until then pupils cannot log in (LightDM authenticates through its own PAM service, no home is created). The seat logs LightDM started before pupitre's configuration when it sees this: it compares LightDM's start with /var/lib/pupitre/lightdm-config.stamp, which the postinst renews only when the snippet's content changes (first install, new snippet). An upgrade that leaves the snippet as it was does not warn. It only warns.

PUPITRE_FERMETURE (600 s, 0 to keep it until PUPITRE_GRACE stops the seat): how long a pupil's session survives the device being switched off, after which it is closed. The device coming back cancels the timer. PUPITRE_GRACE bounds it: after PUPITRE_GRACE seconds without link the seat stops, and stopping closes the session. The effective delay is therefore min(PUPITRE_FERMETURE, PUPITRE_GRACE) when PUPITRE_GRACE is not 0, and PUPITRE_FERMETURE=0 means "until PUPITRE_GRACE". The seat logs a warning at start in mode eleves when the value set cannot hold.

Homes are /home/eleves/<user>, mode 0700, created at first login from /etc/skel. Never under /var/lib/pupitre, which two root services own.

The root hook, /usr/lib/pupitre/pupitre-connexion debut|fin|fermer, is LightDM's session setup and cleanup script. debut refuses names outside letters, digits, ., _, -, allows the pupil alone on the seat's X display (xhost +SI:localuser:) and on its sound socket (an ACL), closes the same account's session on another seat, and writes the seat's session file. fin undoes all of it, stops the key watcher (so the key is unmounted) before closing the seat's session. A session is closed by stopping its own systemd scope. XDG_SEAT is emptied only on seat displays (:1??), so the server's console keeps its seat.

Beyond the seat's own session, the hooks act on pupil accounts only: uid at least UID_MIN (/etc/login.defs, 1000 by default), not root, in neither sudo nor adm. For a pupil, debut also closes their sessions on the other seat displays (:101 to :199), and fin stops their whole user-UID.slice; user@UID and the slice are stopped only when logind shows no other session of that uid (ssh, console, another seat). An adult who tries a seat with their own account loses nothing but that seat's session.

Known limits. - Restarting LightDM closes every session; the seat adds its LightDM seat again (about 1.7 s). - logind sees the session as seatless but active. - A personal PulseAudio still starts for each pupil, unused (the sound goes to the seat's). - The greeter's accessibility bus leaks into the session: a harmless dbind-WARNING.

Behaviour in the cases that matter.

  1. Reinstalling the package during a session: the session survives, and so does the device link, since the postinst reloads the nft rule in place instead of restarting its unit.
  2. LightDM restarted with a pupil in session: the session is closed and the login screen is back in 1.7 s.
  3. Sound: the session reaches the seat's PulseAudio (a personal one still runs, unused). Another pupil cannot use the seat's sound: the connection is refused.
  4. USB key (unmounted before the session ends): the key is offered, mounted, read and ejected in the pupil's session. After logout nothing of the first pupil is left (no process, no FUSE mount), and the next pupil can neither read the first one's home nor see the key mounted.
  5. Device switched off (PUPITRE_FERMETURE): a link drop shorter than the delay keeps the pupil's session; a longer absence closes it once the delay has elapsed since the link fell, and the seat returns to the login screen when the device comes back.
  6. Same pupil on two devices: the other seat's session is closed.