Skip to content

Systemd Service Management and Network Topology

The Portal platform runs on an HP EliteBook Ultra G1q (“Spaceboard”) powered by a Snapdragon X Elite SoC. Every component — from the Wayfire compositor to the on-device LLM — is orchestrated through systemd unit files that encode precise startup ordering, dependency binding, crash-recovery policies, and security sandboxing. This page maps the complete service dependency graph, explains the dual-network topology (Wi-Fi AP + STA), and documents the configuration patterns that keep 15+ daemons coordinated on a single ARM64 device.

The service architecture follows a layered fan-out pattern: a root compositor service (portal.service) anchors the display layer, and all downstream services either bind to its lifetime or sequence after it. Two sysinit-stage oneshot services run before multi-user.target to fix hardware quirks.

graph TD
    subgraph "Sysinit Stage"
        BINFMT["portal-binfmt-fix.service<br/>(oneshot)"]
        IRIS["portal-iris-fixup.service<br/>(oneshot)"]
    end

    subgraph "Multi-User Target"
        PORTAL["portal.service<br/>Wayfire Compositor<br/>Creates /run/portal"]
        HEALTH["portal-health-monitor.service"]
        NET["portal-network.service<br/>Wi-Fi AP (uap0)"]
        WLO1["wlo1-watchdog.service<br/>STA auto-reconnect"]
        FILEWATCH["portal-file-watcherd.service"]
        WINEMON["portal-wine-monitor.service"]
    end

    PORTAL -->|"After"| INPUT["portal-input.service<br/>BindsTo=portal.service"]
    PORTAL -->|"After"| WM["portal-wm.service"]
    PORTAL -->|"After"| STREAM["portal-stream.service<br/>PartOf=portal.service"]
    PORTAL -->|"After"| LLM["portal-llm.service"]
    PORTAL -->|"After"| DBUS["portal-dbus.service<br/>PartOf=portal.service"]
    DBUS -->|"Before"| PCP["portal-pcp.service"]
    LLM -->|"After"| VOICE["portal-voice.service"]
    PORTAL -->|"After"| VOICE
    PORTAL -->|"After"| LAUNCHER["portal-launcher.service<br/>BindsTo + PartOf=portal.service"]

    NET -.->|"uap0 AP"| GLASSES["INMO AIR3<br/>Glasses"]
    STREAM -.->|"RTP/UDP"| GLASSES
    VOICE -.->|"RTP TTS Audio"| GLASSES

    style PORTAL fill:#4a90d9,color:#fff
    style NET fill:#e87d3e,color:#fff
    style GLASSES fill:#50c878,color:#fff

The diagram reveals two critical dependency chains. First, the PCP chain: portal.serviceportal-dbus.serviceportal-pcp.service, where the D-Bus session bus must be ready before the capability protocol daemon can register its AT-SPI2 adapters. Second, the voice chain: portal.serviceportal-llm.serviceportal-voice.service, where the GenieX LLM daemon must initialize before the voice pipeline can route fallback queries to it.

Sources: portal/systemd/portal.service#L1-L29, portal/systemd/portal-pcp.service#L1-L25, portal/systemd/portal-voice.service#L1-L27, docs/x-elite-deployment-state.md#L27-L51

Every Portal service runs as the portal system user (UID 1000) and shares a common XDG_RUNTIME_DIR=/run/portal for Wayland socket access. The table below summarizes all production services, their startup triggers, and restart semantics.

Service Binary Type Restart Policy Group Key Dependency
portal /usr/bin/wayfire simple on-failure (5s) video After seatd.service
portal-input /usr/local/bin/portal-keyboardd simple always (3s) portal BindsTo portal.service
portal-wm /usr/local/bin/portal-wm simple always (3s) video After portal.service
portal-stream /usr/local/bin/portal_stream simple always (3s) video PartOf portal.service
portal-voice /usr/local/bin/portal-voiced simple always (5s) audio After portal-llm.service
portal-llm /usr/local/bin/portal-llmd simple always (5s) audio After portal.service
portal-pcp /usr/local/bin/portal-pcpd simple on-failure (3s) video After portal-dbus.service
portal-dbus /usr/bin/dbus-daemon forking on-failure (2s) video PartOf portal.service
portal-launcher /usr/local/bin/portal-launcherd simple always (3s) video BindsTo + PartOf portal.service
portal-network /usr/local/sbin/hostapd forking on-failure (5s) After NetworkManager-wait-online
portal-health-monitor /usr/local/bin/portal-health-monitor simple always (2s) After multi-user.target
portal-file-watcherd /usr/local/bin/portal-file-watcherd simple on-failure (5s) portal After network.target
portal-wine-monitor /usr/local/bin/portal-wine-monitor simple on-failure (5s) portal After network.target
portal-binfmt-fix shell oneshot oneshot After systemd-binfmt.service
portal-iris-fixup shell oneshot oneshot After systemd-modules-load.service
wlo1-watchdog /usr/local/bin/wlo1-watchdog simple always (5s) After NetworkManager.service

Sources: portal/systemd/portal-input.service#L1-L45, portal/systemd/portal-launcher.service#L1-L43, portal/systemd/portal-dbus.service#L1-L18, docs/x-elite-deployment-state.md#L27-L51

Portal’s services use three distinct systemd dependency directives, each with different lifecycle implications. Understanding the distinction is essential for debugging startup failures and restart cascades.

After= is purely an ordering constraint. The dependent service will not start until the named unit has reached its activation state, but a failure or stop of the named unit does not propagate. For example, portal-wm.service uses After=portal.service — if Wayfire crashes and restarts, the window manager continues running and reconnects to the new socket.

BindsTo= is the strongest coupling. The dependent unit’s lifetime is entirely controlled by the target. If portal.service stops, portal-input.service (which uses BindsTo=portal.service) is stopped too — systemd treats them as a single lifecycle unit. This is appropriate for services that are meaningless without the compositor.

PartOf= propagates stop and restart events from the target to the dependent, but not start events. portal-stream.service uses PartOf=portal.service, meaning that restarting the compositor restarts the stream daemon, but the stream daemon must be independently enabled for boot. portal-launcher.service combines both BindsTo= and PartOf=, giving it the tightest coupling possible.

graph LR
    subgraph "After= (ordering only)"
        A1["portal.service"] --> A2["portal-wm.service"]
        A2 -.->|"survives<br/>restart of A1"| A2
    end
    subgraph "BindsTo= (lifetime bound)"
        B1["portal.service"] --> B2["portal-input.service"]
        B1 -.->|"stops with A1"| B2
    end
    subgraph "PartOf= (stop/restart propagate)"
        C1["portal.service"] --> C2["portal-stream.service"]
        C1 -.->|"restarts with A1<br/>but not started by it"| C2
    end

Sources: portal/systemd/portal-input.service#L4, portal/systemd/portal-launcher.service#L5-L6, portal/systemd/portal-stream.service#L4, portal/systemd/portal-wm.service#L3

A critical architectural decision underpins all Portal service files: the use of /run/portal as the shared XDG_RUNTIME_DIR rather than the conventional /run/user/1000. The deployment logs document why: systemd-logind recreates /run/user/1000 when sessions start, deleting Wayfire’s socket and breaking every downstream service that depends on it.

The portal.service unit creates /run/portal in its ExecStartPre phase with explicit ownership, and a tmpfiles configuration ensures the directory persists across reboots:

d /run/portal 0775 portal video -

Every downstream service then uses /run/portal as its XDG_RUNTIME_DIR, and most implement a polling guard in ExecStartPre that waits up to 15 seconds for the Wayland socket to appear before starting. This pattern prevents a race condition where a service starts before Wayfire has bound the socket.

Sources: portal/systemd/portal-tmpfiles.conf#L1-L2, portal/systemd/portal.service#L19-L23, portal/systemd/portal-wm.service#L12, docs/x-elite-deployment-state.md#L115-L116

The Spaceboard operates a dual-interface Wi-Fi topology using the WCN6855 chipset’s concurrent STA+AP capability. One interface (wlo1) connects to an upstream network for SSH access and updates, while a second virtual interface (uap0) hosts a dedicated access point for the AR glasses.

graph TB
    subgraph "Spaceboard (X Elite)"
        NM["NetworkManager<br/>manages wlo1"]
        UAP0["uap0 (virtual AP)<br/>192.168.50.1/24<br/>Unmanaged by NM"]
        HOSTAPD["hostapd 2.10 (patched)<br/>5GHz ch36 SSID=Spaceboard"]
        DNSMASQ["dnsmasq<br/>DHCP 192.168.50.100-120"]
        STREAM["portal-stream.service<br/>RTP/UDP → 192.168.50.255:5000"]
        VOICE["portal-voice.service<br/>TTS RTP → glasses IP"]
    end

    subgraph "External Network"
        ROUTER["Upstream Router<br/>(e.g. MyPissefWifi)"]
    end

    subgraph "Glasses Network"
        GLASSES1["INMO AIR3 #1<br/>DHCP 192.168.50.x"]
        GLASSES2["INMO AIR3 #2<br/>DHCP 192.168.50.y"]
    end

    ROUTER <-->|"wlo1 STA"| NM
    NM --- PHY0["phy0 (WCN6855)"]
    PHY0 ---|"iw phy phy0 interface add"| UAP0
    UAP0 --> HOSTAPD
    UAP0 --> DNSMASQ
    HOSTAPD -.->|"5GHz AP"| GLASSES1
    HOSTAPD -.->|"5GHz AP"| GLASSES2
    STREAM -.->|"RTP H.265 video"| GLASSES1
    STREAM -.->|"RTP H.265 video"| GLASSES2
    VOICE -.->|"RTP TTS audio"| GLASSES1

    style UAP0 fill:#e87d3e,color:#fff
    style HOSTAPD fill:#4a90d9,color:#fff
    style GLASSES1 fill:#50c878,color:#fff
    style GLASSES2 fill:#50c878,color:#fff

The network separation is enforced at the NetworkManager level. A dedicated configuration file in conf.d/ explicitly marks uap0 as unmanaged, preventing NM from interfering with the AP interface:

[keyfile]
unmanaged-devices=interface-name:uap0

Additionally, NetworkManager’s main configuration sets dns=none and managed=false for ifupdown devices, preventing the system dnsmasq instance from conflicting with Portal’s instance on port 53.

Sources: portal/network/10-portal-uap0.conf#L1-L3, portal/network/NetworkManager.conf#L1-L10, docs/x-elite-deployment-state.md#L107-L122

Portal Network Service: AP Bring-up Sequence

Section titled “Portal Network Service: AP Bring-up Sequence”

The portal-network.service unit is one of the most operationally complex services in the stack. It must create a virtual AP interface from the physical radio, configure IP addressing, launch hostapd, render a dnsmasq configuration from environment templates, and start the DHCP server — all in a specific sequence within ExecStartPre, ExecStart, and ExecStartPost phases.

Phase Command Purpose
ExecStartPre iw dev uap0 del Remove stale virtual interface
ExecStartPre iw phy phy0 interface add uap0 type __ap Create virtual AP interface on phy0
ExecStartPre ip addr add ${PORTAL_NETWORK_CIDR:-192.168.50.1/24} dev uap0 Assign AP IP address
ExecStartPre ip link set uap0 up Bring interface up
ExecStart hostapd -B -P /run/portal-hostapd.pid Launch AP daemon (forking)
ExecStartPost sleep 2 Wait for AP to stabilize
ExecStartPost envsubst < template > dnsmasq-portal.conf Render DHCP config from env
ExecStartPost dnsmasq -C /etc/dnsmasq.d/dnsmasq-portal.conf Launch DHCP server
ExecStop Kill dnsmasq + hostapd PIDs Graceful teardown
ExecStopPost ip link set uap0 down; iw dev uap0 del Clean up interface

A critical operational lesson is documented in the deployment state: the StartLimitBurst=3 directive was originally placed in the [Service] section, where systemd silently ignores it. This caused portal-network to restart in an infinite loop every 7 seconds, which crashed the ath11k firmware and caused a full system lockup. The fix was to move it to the [Unit] section where systemd actually honors rate-limiting.

Sources: portal/network/portal-network.service#L1-L37, docs/x-elite-deployment-state.md#L119

The access point runs on 5GHz channel 36 (802.11a/n) with WPA2-CCMP encryption. The hostapd binary is a custom-patched build of version 2.10 — the Qualcomm ath11k driver pre-registers management frames in a way that causes stock hostapd to fail with EALREADY/EBUSY errors when trying to register its own frame handlers. The patched build tolerates these conditions.

Parameter Value Significance
interface uap0 Virtual AP interface
driver nl80211 Standard netlink driver
ssid Spaceboard Network name seen by glasses
hw_mode a 5GHz band
channel 36 Non-DFS, widely compatible
beacon_int 120 120ms beacon interval
ieee80211n 1 802.11n (HT) enabled
wpa 2 WPA2 only
wpa_key_mgmt WPA-PSK Pre-shared key authentication
rsn_pairwise CCMP AES-based encryption
wmm_enabled 1 QoS for multimedia traffic

The WPA passphrase (Spaceboard2026) is baked into the hostapd configuration. For production deployments, this file should be treated as sensitive and protected by filesystem permissions.

Sources: portal/network/hostapd-portal.conf#L1-L13, docs/x-elite-deployment-state.md#L113

DHCP and IP Allocation: Template-Driven Configuration

Section titled “DHCP and IP Allocation: Template-Driven Configuration”

Portal uses envsubst to render the dnsmasq configuration at service startup time, pulling values from an environment file. This approach allows network parameters to be changed by editing /etc/portal/portal-network.env without modifying the service unit or rebuilding the template.

Variable Default Used By
PORTAL_NETWORK_CIDR 192.168.50.1/24 ip addr add on uap0
PORTAL_DHCP_RANGE 192.168.50.100,192.168.50.120,255.255.255.0,24h dnsmasq dhcp-range
PORTAL_DHCP_GATEWAY 192.168.50.1 dnsmasq DHCP option 3

The dnsmasq template binds only to the uap0 interface (via bind-dynamic) and advertises Google DNS (8.8.8.8, 8.8.4.4) as DHCP option 6 to the glasses clients.

Sources: portal/network/dnsmasq-portal.conf.tmpl#L1-L9, portal/network/portal-network.env.example#L1-L22

Environment File Pattern: Configuration Externalization

Section titled “Environment File Pattern: Configuration Externalization”

A consistent pattern across all Portal services is the use of EnvironmentFile=- (with the leading hyphen) to load deployment-specific configuration from /etc/portal/. The hyphen tells systemd to not fail if the file is missing, allowing each service to ship with sensible defaults while still supporting per-deployment overrides.

Service Environment File Key Variables
portal-stream /etc/portal/portal-stream.env PORTAL_STREAM_TARGET (required), STREAM_PORT
portal-voice /etc/portal/portal-voice.env PORTAL_GLASSES_IP, PORTAL_VENV_DIR (required)
portal-llm /etc/portal/portal-voice.env PORTAL_VENV_DIR, PORTAL_PYTHON_BIN
portal-network /etc/portal/portal-network.env PORTAL_NETWORK_CIDR, DHCP range
portal-pcp /etc/default/portal-pcp PORTAL_UID

The v0.2.1 hardening effort introduced a policy that deployment-specific IPs must never be hardcoded into source code or unit files. The portal-stream.service exemplifies this: PORTAL_STREAM_TARGET is required with no fallback, and if the environment file is missing this variable, the streaming binary exits non-zero with a clear error message.

Sources: portal/systemd/portal-stream.service#L10-L24, portal/systemd/portal-stream.env.example#L1-L22, portal/systemd/portal-voice.env.example#L1-L23, portal/systemd/portal-pcp.env#L1-L3

Portal applies graduated security hardening across its services, with the most aggressive sandboxing on portal-input.service (which has direct device access) and portal-launcher.service (which processes user input). The hardening directives restrict kernel attack surface by eliminating capabilities, syscall access, and namespace creation.

Directive portal-input portal-launcher portal-llm
NoNewPrivileges
ProtectSystem strict strict
ProtectHome true read-only
PrivateTmp
MemoryDenyWriteExecute
SystemCallFilter @system-service + mincore @system-service
RestrictNamespaces
LockPersonality
ProtectKernelTunables
ProtectKernelModules
ProtectControlGroups
RestrictAddressFamilies AF_UNIX, AF_INET, AF_INET6
CapabilityBoundingSet (empty)
MemoryMax 128M
ReadOnlyPaths /usr/share/portal, /home/portal

The udev rules file 99-portal-input.rules replaces what was previously an ExecStartPre chmod/chgrp on /dev/uinput inside the service. By giving the portal group standing read/write access at boot time, the service runs with zero capabilities (CapabilityBoundingSet= and AmbientCapabilities= both empty) — a significant security improvement documented as Wave 3.A SEC-02.

Sources: portal/systemd/portal-input.service#L24-L41, portal/systemd/portal-launcher.service#L22-L40, portal/systemd/99-portal-input.rules#L1-L6, portal/systemd/portal-llm.service#L20-L21

Health Monitoring and Crash Loop Prevention

Section titled “Health Monitoring and Crash Loop Prevention”

The portal-health-monitor.service runs a bash polling loop that watches seven core services for restart storms. It tracks NRestarts counters per service, logs every restart event with journal context, and force-stops any service that exceeds 3 restarts while still in the activating state. This prevents a failing service from consuming CPU in a tight restart loop — the exact failure mode that previously caused ath11k firmware crashes.

The monitor also watches system load average, logging top-CPU processes and memory state when /proc/loadavg exceeds 20.0. All output goes to /var/log/portal-health.log alongside the wlo1 watchdog.

stateDiagram-v2
    [*] --> Monitoring: Service starts (5s interval)
    Monitoring --> Monitoring: All services healthy
    Monitoring --> Logging: Service restart detected
    Logging --> Monitoring: Logged restart + journal context
    Monitoring --> CrashLoop: NRestarts ≥ 3 AND state = activating
    CrashLoop --> Stopped: systemctl stop <service>
    Stopped --> Diagnostics: Dump status + journal
    Diagnostics --> Monitoring: Continue watching others
    Monitoring --> LoadAlert: loadavg ≥ 20
    LoadAlert --> Monitoring: Logged top-CPU + memory

Sources: portal/scripts/portal-health-monitor.sh#L1-L51, portal/systemd/portal-health-monitor.service#L1-L15, docs/x-elite-deployment-state.md#L46

The wlo1-watchdog.service addresses a well-documented ath11k driver bug: the WCN6855 chipset drops the STA connection under concurrent STA+AP load. The watchdog polls every 10 seconds using nmcli, and when it detects that wlo1 is not in the connected state, it issues an nmcli device connect wlo1 command, waits 5 seconds, and verifies recovery.

This watchdog is essential for remote management: without SSH access via the upstream network, a dropped STA connection would require physical console access to recover. Both the health monitor and the wlo1 watchdog write to the same log file (/var/log/portal-health.log), providing a unified operational timeline.

Sources: portal/scripts/wlo1-watchdog.sh#L1-L31, portal/systemd/wlo1-watchdog.service#L1-L15, docs/x-elite-deployment-state.md#L121

Two oneshot services run at sysinit.target — before multi-user.target and before any Portal daemon starts — to work around hardware and firmware quirks specific to the Snapdragon X Elite platform.

portal-binfmt-fix.service disables qemu-x86_64 and qemu-i386 binfmt_misc registrations by writing 0 to their /proc/sys/fs/binfmt_misc/ entries. This ensures that box64 (an ARM64-to-x86-64 dynamic recompiler) handles x86 ELF execution instead of the slower qemu-user emulation. This is critical for Wine compatibility: the portal-wine-monitor.service and portal-file-watcherd.service depend on correct binfmt_misc state to transparently launch Windows applications.

portal-iris-fixup.service rebinds the Qualcomm IRIS video encoder driver (aa00000.video-codec) after a 2-second delay. The initial kernel module load picks up the wrong firmware variant; unbinding and rebinding forces the driver to reload with the correct X1E80100 HP-signed firmware. Without this fixup, portal-stream.service cannot use hardware H.265 encoding via /dev/video1.

Sources: portal/systemd/portal-binfmt-fix.service#L1-L13, portal/systemd/portal-iris-fixup.service#L1-L13, docs/x-elite-deployment-state.md#L43-L48

Boot Prerequisites and Target Configuration

Section titled “Boot Prerequisites and Target Configuration”

The Spaceboard is configured for headless operation with multi-user.target as the default. Several system-level preparations are required for the full service stack to boot cleanly:

Prerequisite Command Reason
Default target systemctl set-default multi-user.target Headless boot (no display manager)
Mask getty systemctl mask getty@tty1 Prevent VT conflict with Wayfire DRM
Mask system dnsmasq systemctl mask dnsmasq Avoid port 53 conflict with portal-network
NM dns=none /etc/NetworkManager/NetworkManager.conf Prevent DNS port conflict
NM uap0 unmanaged /etc/NetworkManager/conf.d/10-portal-uap0.conf NM must not control AP interface
/run/portal exists Created by portal.service ExecStartPre + tmpfiles Shared XDG_RUNTIME_DIR
portal-network starts after NM online After=NetworkManager-wait-online.service PHY must be ready for virtual interface

Sources: docs/x-elite-deployment-state.md#L179-L186, portal/network/NetworkManager.conf#L1-L10, portal/network/portal-network.service#L3-L4

The deployment follows a clear separation between repository sources and deployed paths on the Spaceboard. Understanding this mapping is essential for applying configuration changes.

Repository Path Deployed Path Purpose
portal/systemd/*.service /etc/systemd/system/ Service unit files
portal/systemd/99-portal-input.rules /etc/udev/rules.d/ uinput device permissions
portal/systemd/99-portal-tty.rules /etc/udev/rules.d/ TTY device permissions
portal/systemd/portal-tmpfiles.conf /etc/tmpfiles.d/ Runtime directory creation
portal/network/hostapd-portal.conf /etc/hostapd/hostapd-portal.conf AP configuration
portal/network/dnsmasq-portal.conf.tmpl /etc/portal/templates/ DHCP template
portal/network/NetworkManager.conf /etc/NetworkManager/NetworkManager.conf NM global config
portal/network/10-portal-uap0.conf /etc/NetworkManager/conf.d/ NM unmanaged interface
portal/compositor/wayfire.ini /etc/portal/wayfire.ini Wayfire compositor config
portal/systemd/portal-stream.env.example /etc/portal/portal-stream.env Stream env (copy & edit)
portal/systemd/portal-voice.env.example /etc/portal/portal-voice.env Voice env (copy & edit)
portal/network/portal-network.env.example /etc/portal/portal-network.env Network env (copy & edit)

Sources: docs/x-elite-deployment-state.md#L126-L136, portal/systemd/portal-stream.env.example#L1-L22

ALSA-Only Mode: Development Without Glasses

Section titled “ALSA-Only Mode: Development Without Glasses”

For development and debugging without the INMO AIR3 glasses, Portal ships an alternative voice service file (portal-voice.service.alsa) that replaces the UDP network audio capture with local ALSA input. Swapping between modes is a two-step process: copy the desired service file into place and restart.

Mode Service File Capture Source TTS Output Glasses Dependency
Network (production) portal-voice.service --capture-mode udp RTP to glasses IP Required
ALSA (development) portal-voice.service.alsa --capture-mode alsa --alsa-device plughw:2,0 Local playback None

The ALSA variant includes the same security hardening as the production service (NoNewPrivileges, ProtectSystem=strict, MemoryDenyWriteExecute) but restricts ReadWritePaths to /dev/snd only, and adds ProtectHome=read-only since no glasses IP needs to be resolved.

Sources: portal/systemd/portal-voice.service.alsa#L1-L39, portal/systemd/portal-voice.service#L1-L27

The following commands cover the most common operational tasks when managing Portal services on the Spaceboard:

Terminal window
# Check all Portal services at once
systemctl status portal* portal-network wlo1-watchdog
# View the dependency tree from portal.service
systemctl list-dependencies portal.service
# Restart the entire display stack (compositor + bound services)
sudo systemctl restart portal.service
# Reload environment files after editing
sudo systemctl daemon-reload
sudo systemctl restart portal-stream portal-voice portal-llm
# View health monitor log
tail -f /var/log/portal-health.log
# Verify Wayland socket exists
test -S /run/portal/wayland-1 && echo "OK" || echo "MISSING"
# Check which services are bound to portal.service
systemctl show portal-input -p BindsTo
systemctl show portal-launcher -p BindsTo,PartOf

Sources: docs/x-elite-deployment-state.md#L138-L151, portal/scripts/portal-health-monitor.sh#L1-L51