---
title: "07 — Dual-Use: Inference Server ⇄ Gaming/Desktop"
description: "## The problem"
section: ai-docs
raw: "07-dual-use-gaming.md"
source: ai-generated
tags: ai, llama.cpp, docker
last-updated: 2026-09-15
---

# 07 — Dual-Use: Inference Server ⇄ Gaming/Desktop

> Turning a headless AI box into a desktop (or gaming machine) on demand — udev-driven auto-switching between serving models and using the GPUs interactively.

## The problem

A local AI machine idles its GPUs serving models — which is great, except when *you* want to use the machine: to play a game, do some desktop work, or just look at it. Manually stopping the inference servers, starting the display manager, then reversing it later is a pain and easy to forget (a desktop compositor eating 1–2 GB of VRAM while the models are resident is a subtle way to get OOMs under load).

The fix on this box: a **USB monitor switcher** (a small USB device you plug in when you want the desktop) drives a **udev rule** that toggles the machine's whole role automatically. Plug it in → headless inference stops and the desktop starts. Pull it out → the desktop stops and inference comes back. No `systemctl` juggling, nothing to remember.

## How it works

Three small pieces:

| Piece | What it is |
| --- | --- |
| `99-usb-switcher.rules` | udev rule in `/etc/udev/rules.d/` matching the switcher's USB vendor/product ID, on `add` and `remove` |
| `usb-switcher-on.sh` | runs on plug-in: stops inference, starts the display manager, switches the monitor input |
| `usb-switcher-off.sh` | runs on pull-out: the reverse |

The udev rule identifies the device by fingerprint, so nothing else plugged into the box can trigger the switch:

```udev
ACTION=="add",    SUBSYSTEM=="usb", ENV{DEVTYPE}=="usb_device", \
    ATTRS{idVendor}=="05e3", ATTRS{idProduct}=="0610", \
    RUN+="/usr/local/bin/usb-switcher-on.sh"
ACTION=="remove", SUBSYSTEM=="usb", ENV{DEVTYPE}=="usb_device", \
    ATTRS{idVendor}=="05e3", ATTRS{idProduct}=="0610", \
    RUN+="/usr/local/bin/usb-switcher-off.sh"
```

Find your device's IDs with `lsusb` while it's plugged in.

### The on-script (headless → desktop)

```bash
#!/bin/bash
# Runs as root via udev — use absolute paths only
LOG=/var/log/usb-switcher.log
echo "[$(date '+%F %T')] switcher connected" >> "$LOG"

/usr/bin/systemctl stop llama-primary.service    2>>"$LOG"
/usr/bin/systemctl stop llama-secondary.service  2>>"$LOG"
/usr/bin/systemctl start gdm.service            2>>"$LOG"

# Switch the monitor to the DisplayPort/USB-C input
/usr/bin/ddcutil setvcp 60 0x1b 2>>"$LOG" || true
```

The off-script mirrors this: stop `gdm`, start both llama units, `ddcutil setvcp 60 0x11` back to the other input.

## Tips & tricks learned from running it

- **Ordering matters.** On plug-in, stop the inference servers *before* starting the display manager — free the GPUs first. On pull-out, stop the display manager *before* starting the servers — avoid a window where both want the GPUs. A GPU contention window here is the kind of thing that manifests as a flaky X session or a model failing to load.
- **Inference services must be ordinary startable units.** This whole design works *because* the llama servers are plain systemd units anyone can start/stop, rather than pinned to `always-on`. The dual-use scripts start and stop them directly.
- **udev runs in a minimal environment.** The scripts must use **absolute paths** (`/usr/bin/systemctl`, not `systemctl`) — a relative path that works in your shell will silently fail under udev.
- **Use `|| true` for the cosmetic part.** `ddcutil setvcp` (which switches the monitor's active input over DDC/CI) can fail if the monitor or the DRM context isn't reachable; a failed input switch is logged but non-fatal, so the rest of the transition still happens. Verify your monitor's VCP input values with `ddcutil detect` — `0x1b`/`0x11` are device-specific.
- **Log every transition.** Appending timestamped lines (plus stderr of each command) to `/var/log/usb-switcher.log` makes "the machine was in the wrong state" trivial to diagnose — you can see exactly which step ran and which failed.
- **The default state should be the server.** `WantedBy=multi-user.target` + the display manager stopped at boot means a plain reboot returns the box to headless inference — the desktop is the exception you opt into with the plug.
- **If you don't have a USB switcher**, the same two scripts work behind any trigger: a wall switch on a USB port, a `systemd` path unit on a file, or just a pair of shell aliases. The device fingerprint in the udev rule is what makes it hands-free.

## State model

```text
          plug in switcher (add)                     pull out switcher (remove)
 ┌────────────────────────────────────────┐   ┌────────────────────────────────────────┐
 │ HEADLESS INFERENCING (default)         │   │  DESKTOP / GAMING (interactive)        │
 │ • gdm STOPPED                         │   │  • gdm RUNNING                         │
 │ • llama-primary RUNNING               │   │  • llama-primary STOPPED               │
 │ • llama-secondary RUNNING             │   │  • llama-secondary STOPPED             │
 └────────────────────────────────────────┘   └────────────────────────────────────────┘
```

## Dependencies to verify on your own box

- The display manager unit (`gdm.service` here — could be `lightdm`, `sddm`, …) is what "desktop" means on your system.
- `ddcutil` is installed and can reach the monitor over DDC/CI (usually needs a DRM/X context).
- Your monitor's input-source VCP numbering — check with `ddcutil detect`.
- The USB vendor/product ID of whatever device you use as the trigger.

## Related

- [01 — Architecture](/ai-docs/01-architecture/) — why the inference servers are ordinary, individually-startable units
- [03 — Primary vs Secondary](/ai-docs/03-primary-vs-secondary/) — what gets stopped/started by the switch

---

## Source Disclaimer

- [x] AI Generated
- [ ] Human Generated
- [ ] AI Edited
- [ ] Human Edited
