Implements the four plumbing items of design/gamescope-multiuser.md — the same-uid slice of multi-user-profiles.md §7.3, and (per the planning repo's revived multi-seat design of record) the WP1/WP2 plumbing foundation of that program: concurrent, independent bare-spawn gamescope sessions on one box, each driving and hearing only its own session. Work plan with the full scope table, risk register and acceptance matrix: punktfunk-planning/design/gamescope-multiuser-implementation-plan.md.
What changed
Per-instance EIS relay — an isolated spawn writes its LIBEI_SOCKET to $XDG_RUNTIME_DIR/punktfunk-gamescope-{id}-ei instead of the global file two concurrent instances used to overwrite. SessionIsolation rides the backend instance (the set_gamescope_route pattern), and GamescopeProc cleans up its own relay on drop.
Session-pinned injector — InjectorService::start_at(relay): same thread/lazy-open/backoff discipline as the shared service, but it never follows the published SESSION_BACKEND. The input thread sends through a swappable InputRoute, so the mid-stream Gaming↔Desktop switch and the capture-loss rebuild re-point a live thread between pinned and shared. Isolated sessions also stop publishing set_backend_id — the last-write-wins shared slot used to steal input from concurrent shared-desktop viewers.
Per-session audio — the capturer opens its null sink under a session-chosen name (punktfunk-speaker-iso-{id}) and the spawn pins the nested apps to it via PULSE_SINK; env-pinned streams don't follow a concurrent session's default-sink claim. The claim itself stays as the orphan-stream safety net (D4). Isolated capturers bypass the shared park slot both ways.
Per-session mic — punktfunk-mic-{id} via MicPump::start_named, matched by PULSE_SOURCE on the spawn; torn down with the session.
The keep-alive registry's reuse key learns the isolation identity, which is derived from the client cert fingerprint — stable, so a reconnecting client gets its kept gamescope (with the baked-in relay path + audio env) back, and another client never does.
Scope (deliberate)
Native punktfunk/1 plane + Spawn route only. kwin/mutter/wlroots, managed/attach gamescope, and the GameStream plane keep the shared host-lifetime planes — shared is the correct semantics there, and managed/attach are single-occupant by nature.
PUNKTFUNK_GAMESCOPE_ISOLATE (default on, explicit-off) is the one-release escape hatch while the new input path soaks.
CHANGELOG deferred to the version bump, per the audio-sink-clock-isolation precedent.
Rejected paths
Backend::GamescopeEiAt(PathBuf) variant (the §7.3 sketch) — costs the enum its Copy, which SESSION_BACKEND and the knob parser lean on; a pinned open fn does the same job.
Per-create unique isolation ids — defeats keep-alive reuse on reconnect (the kept spawn's env is baked in).
Skipping the default-sink claim for isolated sessions — loses the self-heal for streams orphaned by a capturer teardown; env pinning already provides the isolation.
Known ceilings (risk register in the plan doc)
PipeWire 1.4 mics: a non-default Audio/Source recorded by target is never driven (silent) — per-session mic needs the 1.6 daemon; downlink audio is unaffected.
Host desktop apps' audio still lands in the newest claim's sink — the multi-user box leak the per-uid worker (multi-user-profiles §7.3) closes for real.
Same fingerprint connecting twice concurrently shares one identity (last-write-wins on the relay, same as today's global file, scoped to one human).
A host killed hard leaves its per-session relay file(s) behind (the crash property the global file always had; readers re-poll, tmpfs clears on boot) — observed in the smoke.
Gates
Compile/unit (omarchy-1, this branch): cargo fmt --check clean; cargo clippy --no-deps -D warnings clean on every touched crate (the two remaining clippy errors are pre-existing files untouched here, tripped only by clippy 1.98); cargo test: 690 punktfunk-host + 456 leaf-crate tests, 0 failed.
Headless smoke (home-worker-5 / .21, CachyOS, PipeWire 1.6.8, RTX 5070 Ti — driver: the mu-smoke headless NativeClient against punktfunk1-host --source virtual --allow-tofu --max-concurrent 2), all green:
Single isolated session: per-session relay written, punktfunk-speaker-iso-<id> + punktfunk-mic-<id> minted, pinned injector connected to the relayed EIS socket, 734 frames/20 s.
Two concurrent sessions (720p+1080p): two distinct relay files, sink/mic pairs and pinned injectors; both clients ~940 frames; audio isolation proven — a 440 Hz tone PULSE_SINK-pinned into session A's sink reached only client A (1299 pkts, avg 160 B), client B received 0. Relay files removed on teardown.
Env pin: the nested app's environ carries its session's PULSE_SINK/PULSE_SOURCE.
PUNKTFUNK_GAMESCOPE_ISOLATE=0: global relay, "host-lifetime" injector, non-iso sink name, no PULSE pins, zero isolation lines — legacy exactly.
Still owed — the acceptance: the plan doc's §5 on-glass matrix with real clients (keystrokes visibly landing in the right nested app, real mic uplink, disconnect + linger-reuse reconnect on a stable pairing identity). Nothing should merge before it.
Implements the four plumbing items of `design/gamescope-multiuser.md` — the same-uid slice of `multi-user-profiles.md` §7.3, and (per the planning repo's revived **multi-seat** design of record) the WP1/WP2 plumbing foundation of that program: concurrent, **independent** bare-spawn gamescope sessions on one box, each driving and hearing only its own session. Work plan with the full scope table, risk register and acceptance matrix: `punktfunk-planning/design/gamescope-multiuser-implementation-plan.md`.
## What changed
1. **Per-instance EIS relay** — an isolated spawn writes its `LIBEI_SOCKET` to `$XDG_RUNTIME_DIR/punktfunk-gamescope-{id}-ei` instead of the global file two concurrent instances used to overwrite. `SessionIsolation` rides the backend instance (the `set_gamescope_route` pattern), and `GamescopeProc` cleans up its own relay on drop.
2. **Session-pinned injector** — `InjectorService::start_at(relay)`: same thread/lazy-open/backoff discipline as the shared service, but it never follows the published `SESSION_BACKEND`. The input thread sends through a swappable `InputRoute`, so the mid-stream Gaming↔Desktop switch and the capture-loss rebuild re-point a live thread between pinned and shared. Isolated sessions also stop publishing `set_backend_id` — the last-write-wins shared slot used to steal input from concurrent shared-desktop viewers.
3. **Per-session audio** — the capturer opens its null sink under a session-chosen name (`punktfunk-speaker-iso-{id}`) and the spawn pins the nested apps to it via `PULSE_SINK`; env-pinned streams don't follow a concurrent session's default-sink claim. The claim itself stays as the orphan-stream safety net (D4). Isolated capturers bypass the shared park slot both ways.
4. **Per-session mic** — `punktfunk-mic-{id}` via `MicPump::start_named`, matched by `PULSE_SOURCE` on the spawn; torn down with the session.
The keep-alive registry's reuse key learns the isolation identity, which is derived from the **client cert fingerprint** — stable, so a reconnecting client gets its kept gamescope (with the baked-in relay path + audio env) back, and another client never does.
## Scope (deliberate)
- Native punktfunk/1 plane + `Spawn` route only. kwin/mutter/wlroots, managed/attach gamescope, and the GameStream plane keep the shared host-lifetime planes — shared is the correct semantics there, and managed/attach are single-occupant by nature.
- `PUNKTFUNK_GAMESCOPE_ISOLATE` (default **on**, explicit-off) is the one-release escape hatch while the new input path soaks.
- CHANGELOG deferred to the version bump, per the audio-sink-clock-isolation precedent.
## Rejected paths
- `Backend::GamescopeEiAt(PathBuf)` variant (the §7.3 sketch) — costs the enum its `Copy`, which `SESSION_BACKEND` and the knob parser lean on; a pinned open fn does the same job.
- Per-`create` unique isolation ids — defeats keep-alive reuse on reconnect (the kept spawn's env is baked in).
- Skipping the default-sink claim for isolated sessions — loses the self-heal for streams orphaned by a capturer teardown; env pinning already provides the isolation.
## Known ceilings (risk register in the plan doc)
- **PipeWire 1.4 mics**: a non-default `Audio/Source` recorded by target is never driven (silent) — per-session mic needs the 1.6 daemon; downlink audio is unaffected.
- Host *desktop* apps' audio still lands in the newest claim's sink — the multi-user box leak the per-uid worker (multi-user-profiles §7.3) closes for real.
- Same fingerprint connecting twice concurrently shares one identity (last-write-wins on the relay, same as today's global file, scoped to one human).
- A host killed hard leaves its per-session relay file(s) behind (the crash property the global file always had; readers re-poll, tmpfs clears on boot) — observed in the smoke.
## Gates
**Compile/unit** (omarchy-1, this branch): `cargo fmt --check` clean; `cargo clippy --no-deps -D warnings` clean on every touched crate (the two remaining clippy errors are pre-existing files untouched here, tripped only by clippy 1.98); `cargo test`: **690 punktfunk-host + 456 leaf-crate tests, 0 failed**.
**Headless smoke** (home-worker-5 / `.21`, CachyOS, PipeWire 1.6.8, RTX 5070 Ti — driver: the `mu-smoke` headless `NativeClient` against `punktfunk1-host --source virtual --allow-tofu --max-concurrent 2`), all green:
1. Single isolated session: per-session relay written, `punktfunk-speaker-iso-<id>` + `punktfunk-mic-<id>` minted, pinned injector connected to the relayed EIS socket, 734 frames/20 s.
2. Two concurrent sessions (720p+1080p): two distinct relay files, sink/mic pairs and pinned injectors; both clients ~940 frames; **audio isolation proven** — a 440 Hz tone `PULSE_SINK`-pinned into session A's sink reached only client A (1299 pkts, avg 160 B), client B received 0. Relay files removed on teardown.
3. Env pin: the nested app's environ carries its session's `PULSE_SINK`/`PULSE_SOURCE`.
4. `PUNKTFUNK_GAMESCOPE_ISOLATE=0`: global relay, "host-lifetime" injector, non-iso sink name, no PULSE pins, zero isolation lines — legacy exactly.
**Still owed — the acceptance**: the plan doc's §5 on-glass matrix with real clients (keystrokes visibly landing in the right nested app, real mic uplink, disconnect + linger-reuse reconnect on a stable pairing identity). Nothing should merge before it.
An independent multi-user gamescope spawn must not share the global
LIBEI_SOCKET relay file (two concurrent instances overwrite each other's
socket name) or the box-wide audio routing. SessionIsolation carries a
stable per-client identity on the backend instance, the spawn wrapper
writes its relay to a per-session file, and the nested apps are pinned
to the session's own sink/mic by PULSE_SINK/PULSE_SOURCE — an env-pinned
stream does not follow a concurrent session's default-sink claim.
The keep-alive registry learns the identity as part of its reuse key: a
kept spawn has the relay path and audio env baked into its process, so
only the same client may ever get it back. Managed/attach routes and the
GameStream plane keep the global relay unchanged.
Same-uid slice of design/gamescope-multiuser.md (multi-user-profiles.md
section 7.3 items 1 and 3-4, producer side).
InjectorService::start_at pins the worker to one session's EIS relay
file: same lazy-open, backoff and reopen-on-death discipline as the
shared service, but it never follows the published session backend —
its target cannot change, only die. open_gamescope_at is a function
rather than a Backend variant so Backend stays Copy (the published
SESSION_BACKEND slot and the knob parser lean on that).
The libei worker already polls the relay file, which is what makes
creating the injector before its gamescope spawns safe (the ordering
nuance design/gamescope-multiuser.md item 2 called out).
An isolated session mints its sink/mic node names before its gamescope
spawns, so the spawn env and the capture side meet at the same nodes:
PwAudioCapturer::open_named captures exactly the sink the nested apps
are PULSE_SINK-pinned to (default-sink claim kept as the orphan-stream
safety net), PwMicSource::open_named and MicPump::start_named give the
session its own punktfunk-mic-{id} source torn down with the session.
Monitor-mode capture owns no sink, so per_session_sink_possible lets
the caller skip isolation's audio half there. PipeWire 1.4 never
drives a non-default source recorded by target — per-session mic needs
the 1.6 daemon; the open_named doc carries the trap.
Wire the per-session planes end to end for the native plane's bare-spawn
route (design/gamescope-multiuser.md): serve_session mints a fingerprint-
stable SessionIsolation after the handshake, starts a pinned injector and
a per-session mic pump, and the audio thread opens the session's named
sink outside the shared park slot (a parked capturer cannot be seen to
capture the wrong sink by the channel/rate test alone).
Input rides a swappable InputRoute so the mid-stream Gaming/Desktop
switch and the capture-loss rebuild can re-point a live input thread
between the pinned and shared services; an isolated session also stops
publishing the last-write-wins shared backend, which used to steal input
from concurrent shared-desktop viewers. One predicate
(session_is_isolated) gates every site, behind the
PUNKTFUNK_GAMESCOPE_ISOLATE escape hatch (default on).
Shared-desktop backends, managed/attach gamescope and the GameStream
plane keep the shared host-lifetime planes — correct semantics there.
The runnable check for per-session gamescope isolation: a headless
NativeClient that connects with CompositorPref::Gamescope, counts
frames and audio payload bytes, and wiggles the pointer so the pinned
injector opens. It drove the 2026-09-01 four-phase smoke on .21
(concurrent sessions, tone-based audio-isolation proof, hatch-off
regression) and is what the still-owed on-glass matrix reuses.
Build: cargo build -p punktfunk-core --features quic --example mu-smoke
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Implements the four plumbing items of
design/gamescope-multiuser.md— the same-uid slice ofmulti-user-profiles.md§7.3, and (per the planning repo's revived multi-seat design of record) the WP1/WP2 plumbing foundation of that program: concurrent, independent bare-spawn gamescope sessions on one box, each driving and hearing only its own session. Work plan with the full scope table, risk register and acceptance matrix:punktfunk-planning/design/gamescope-multiuser-implementation-plan.md.What changed
LIBEI_SOCKETto$XDG_RUNTIME_DIR/punktfunk-gamescope-{id}-eiinstead of the global file two concurrent instances used to overwrite.SessionIsolationrides the backend instance (theset_gamescope_routepattern), andGamescopeProccleans up its own relay on drop.InjectorService::start_at(relay): same thread/lazy-open/backoff discipline as the shared service, but it never follows the publishedSESSION_BACKEND. The input thread sends through a swappableInputRoute, so the mid-stream Gaming↔Desktop switch and the capture-loss rebuild re-point a live thread between pinned and shared. Isolated sessions also stop publishingset_backend_id— the last-write-wins shared slot used to steal input from concurrent shared-desktop viewers.punktfunk-speaker-iso-{id}) and the spawn pins the nested apps to it viaPULSE_SINK; env-pinned streams don't follow a concurrent session's default-sink claim. The claim itself stays as the orphan-stream safety net (D4). Isolated capturers bypass the shared park slot both ways.punktfunk-mic-{id}viaMicPump::start_named, matched byPULSE_SOURCEon the spawn; torn down with the session.The keep-alive registry's reuse key learns the isolation identity, which is derived from the client cert fingerprint — stable, so a reconnecting client gets its kept gamescope (with the baked-in relay path + audio env) back, and another client never does.
Scope (deliberate)
Spawnroute only. kwin/mutter/wlroots, managed/attach gamescope, and the GameStream plane keep the shared host-lifetime planes — shared is the correct semantics there, and managed/attach are single-occupant by nature.PUNKTFUNK_GAMESCOPE_ISOLATE(default on, explicit-off) is the one-release escape hatch while the new input path soaks.Rejected paths
Backend::GamescopeEiAt(PathBuf)variant (the §7.3 sketch) — costs the enum itsCopy, whichSESSION_BACKENDand the knob parser lean on; a pinned open fn does the same job.createunique isolation ids — defeats keep-alive reuse on reconnect (the kept spawn's env is baked in).Known ceilings (risk register in the plan doc)
Audio/Sourcerecorded by target is never driven (silent) — per-session mic needs the 1.6 daemon; downlink audio is unaffected.Gates
Compile/unit (omarchy-1, this branch):
cargo fmt --checkclean;cargo clippy --no-deps -D warningsclean on every touched crate (the two remaining clippy errors are pre-existing files untouched here, tripped only by clippy 1.98);cargo test: 690 punktfunk-host + 456 leaf-crate tests, 0 failed.Headless smoke (home-worker-5 /
.21, CachyOS, PipeWire 1.6.8, RTX 5070 Ti — driver: themu-smokeheadlessNativeClientagainstpunktfunk1-host --source virtual --allow-tofu --max-concurrent 2), all green:punktfunk-speaker-iso-<id>+punktfunk-mic-<id>minted, pinned injector connected to the relayed EIS socket, 734 frames/20 s.PULSE_SINK-pinned into session A's sink reached only client A (1299 pkts, avg 160 B), client B received 0. Relay files removed on teardown.PULSE_SINK/PULSE_SOURCE.PUNKTFUNK_GAMESCOPE_ISOLATE=0: global relay, "host-lifetime" injector, non-iso sink name, no PULSE pins, zero isolation lines — legacy exactly.Still owed — the acceptance: the plan doc's §5 on-glass matrix with real clients (keystrokes visibly landing in the right nested app, real mic uplink, disconnect + linger-reuse reconnect on a stable pairing identity). Nothing should merge before it.
An isolated session mints its sink/mic node names before its gamescope spawns, so the spawn env and the capture side meet at the same nodes: PwAudioCapturer::open_named captures exactly the sink the nested apps are PULSE_SINK-pinned to (default-sink claim kept as the orphan-stream safety net), PwMicSource::open_named and MicPump::start_named give the session its own punktfunk-mic-{id} source torn down with the session. Monitor-mode capture owns no sink, so per_session_sink_possible lets the caller skip isolation's audio half there. PipeWire 1.4 never drives a non-default source recorded by target — per-session mic needs the 1.6 daemon; the open_named doc carries the trap.