Skip to content

fix(lemonade): give the server a runtime dir when XDG_RUNTIME_DIR is unset - #262

Draft
rominf wants to merge 1 commit into
mainfrom
fix-lemonade-missing-xdg-runtime-dir
Draft

fix(lemonade): give the server a runtime dir when XDG_RUNTIME_DIR is unset#262
rominf wants to merge 1 commit into
mainfrom
fix-lemonade-missing-xdg-runtime-dir

Conversation

@rominf

@rominf rominf commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Fixes #259.

Symptom

rocm serve --engine lemonade --managed fails on any Linux host with no
XDG_RUNTIME_DIR. It is fully deterministic and independent of the model or the serve
path:

Error: request_failed: Lemonade server did not become ready: exit status: 1;
Lemonade startup log tail (.../engines/lemonade/runtime/install-lemond.log):
[Error] (main) Error: Unable to resolve writable runtime directory from
XDG_RUNTIME_DIR or RUNTIME_DIRECTORY

The same command works from an interactive desktop or SSH login on the same machine, so
it reads as a machine quirk when it is really a property of how the process was started:
a systemd unit, a cron job, a container, ssh host 'rocm serve ...' without lingering,
or any CI runner.

Root cause

The message comes from the spawned server, not from rocm-cli — the string appears nowhere
in this tree and RUNTIME_DIRECTORY is never referenced here. The server needs a writable
runtime directory and consults exactly two sources, neither of which exists in a headless
context:

  • XDG_RUNTIME_DIR, populated by pam_systemd at login and therefore absent for every
    non-login process;
  • RUNTIME_DIRECTORY, which only exists for a systemd unit declaring RuntimeDirectory=.

lemonade_process_environment_vars is the single place the child environment is
assembled. It translated ROCM_PATH, PATH, LD_LIBRARY_PATH, HIP_VISIBLE_DEVICES and
LEMONADE_API_KEY, but nothing about the runtime directory. rocm-cli cannot change what
the server requires, but it owns the environment it hands over, so this is the layer that
can satisfy it.

Fix

Reuse the precedence this repo already has rather than inventing a second one.
dashboard_socket_path already solves "pick a writable, user-owned runtime directory with
a fallback chain", as a pure function taking its env inputs as arguments:

  1. $XDG_RUNTIME_DIR
  2. $HOME/.rocm/data/…
  3. temp_dir()/rocm-<user>, with the user name sanitized so a separator or .. cannot
    escape the subdirectory

That tier chain moves into rocm_core::user_runtime_dir; dashboard_socket_path becomes
a thin caller over it and resolves to byte-identical paths in all three tiers (its three
existing tests are unchanged and still pass). The engine calls the same helper when the
parent environment has no usable value, creates the directory mode 0700 before spawning,
and passes it down.

Why not fix this in CI

A workflow-level fix was considered and rejected. Exporting XDG_RUNTIME_DIR in
.github/workflows/e2e-selfhosted.yml, or setting it for piped scenarios in the E2E
harness, would turn the GPU lane green and leave every headless user exactly as broken.

pty_env (tests/e2e-cucumber/tests/e2e.rs) does set HOME and XDG_RUNTIME_DIR, but
only for PTY scenarios — its comment records that piped scenarios keep their historical
HOME/XDG environment, and the serve scenarios are piped. That is correct and stays: it is
precisely why the suite reproduces the real headless case instead of hiding it.

Decisions worth reviewing

A distinct directory for the engine, not the dashboard's. In the fallback tiers the
engine gets a lemonade leaf ($HOME/.rocm/data/lemonade,
temp_dir()/rocm-<user>/lemonade). Sharing one directory would put engine scratch state
next to the telemetry socket, so a crashing or misbehaving server could disturb the
dashboard. The isolation costs one path component.

A shared helper, but only for the two callers that can share one. The tier chain is
extracted into rocm-core, used by dashboard_socket_path and by the engine, so there
are still two copies of this logic rather than three. rocm-dash-core keeps its own
mirror deliberately: it is a standalone library with a lean dependency set, and depending
on rocm-core to share ~30 lines would pull in that crate's whole graph. Its "keep the
two in sync" comment now names the shared helper so the sync target is unambiguous, and
the mirrored tests on both sides still catch divergence.

XDG_RUNTIME_DIR rather than RUNTIME_DIRECTORY. The server reads either.
RUNTIME_DIRECTORY is narrower, but it is a systemd-unit contract implying systemd
created the directory and will clean it up — untrue here, and misleading to anyone
debugging. XDG_RUNTIME_DIR is the freedesktop-standard variable whose absence is the bug;
supplying a valid one is strictly better for any descendant process than leaving it unset.
It is never overridden: a value already in the parent environment is passed through
unchanged.

Hardening. The tier-3 fallback lands under a shared temp dir, so a pre-existing path
there may not be ours. A symlink is refused rather than chmod-ed through, and a chmod
failure is a hard error naming the path.

Verification

Ran locally on a headless Linux host (no XDG_RUNTIME_DIR, no RUNTIME_DIRECTORY):

  • cargo fmt --all --check
  • cargo clippy --locked --workspace --all-targets -- -D warnings
  • cargo clippy --locked -p e2e-cucumber --test e2e -- -D warnings
  • cargo test --workspace --all-targets, plus the existing dashboard_socket_path and
    rocm-dash-core mirror tests
  • driving the real resolver against this host's actual environment: it produced
    $HOME/.rocm/data/lemonade, created drwx------

Five new unit tests on lemonade_process_environment_vars cover synthesis when the parent
has no value, an exported-but-empty value, the no-HOME tier, pass-through of an existing
value, and refusal of a symlinked directory. All five fail before this change and pass
after. They take the environment as arguments rather than mutating process-global env, so
they behave identically under cargo test and nextest.

Two pre-existing failures in -p rocm --bin rocm
(therock::tests::extracting_the_sdk_archive_removes_it,
providers::tests::local_provider_default_chat_requires_builtin_qwen_assistant) reproduce
identically on unmodified main under a shared-process cargo test and pass in isolation;
they are unrelated to this change.

The real verification is the GPU lane, and it is now green. No GPU here, so a full
managed serve could not be run locally; @id:serve-lemonade-inference and
@id:serve-hf-checkpoint-inference (@requires-gpu @requires-engine:lemonade) exercise
this on E2E tests (GPU), the MI300X lane where the failure reproduces.

before (job, the run in #259) after (job)
"Unable to resolve writable runtime directory" in the log 2 occurrences none
serve-hf-checkpoint-inference failed passes
reconciliation 1 unexpected failure — lane red 0 unexpected failures — lane green

serve-lemonade-inference still xfails, but on the unrelated EAI-7423 symptom it is
already registered for ("did not serve model within 90s"), and it now gets past server
startup instead of exiting immediately.

Worth being explicit about the shape of that coverage: the scenarios are piped, so they
inherit the runner's environment rather than asserting on it — they regress this bug
because that runner is headless, not because the harness forces it. Making it explicit
would mean touching the piped scenarios' environment, which is the thing this fix
deliberately avoids.

tests/e2e-cucumber/expectations.toml was searched: no row describes this failure mode.
The lemonade Linux rows there are EAI-7423 ("reaches ready then shuts down immediately") on
the Strix Halo Ubuntu lane, a different symptom on a different host, and are left alone.

…unset

`rocm serve --engine lemonade --managed` failed deterministically on any
Linux host without XDG_RUNTIME_DIR: the spawned server exits during
startup with "Unable to resolve writable runtime directory from
XDG_RUNTIME_DIR or RUNTIME_DIRECTORY". XDG_RUNTIME_DIR is populated by
pam_systemd at login, so it is absent for every non-login process (cron
jobs, CI runners, systemd-run, a bare container exec), and
RUNTIME_DIRECTORY only exists for a systemd unit declaring
RuntimeDirectory=.

rocm-cli assembles the child environment, so it owns the seam. Reuse the
runtime-directory precedence the dashboard socket already uses
(XDG_RUNTIME_DIR, then $HOME/.rocm/data, then temp_dir()/rocm-<user>) by
extracting its tier chain into rocm-core as `user_runtime_dir`, and give
the engine a `lemonade` leaf inside the fallback tiers so a crashing
server cannot disturb the telemetry socket sharing the same root. The
directory is created mode 0700 before the server is spawned, and a
symlink there is refused rather than chmod'ed through. A value already
present in the parent environment is passed through unchanged.

The readiness poll re-spawns a status probe twice a second, so it now
resolves the child environment once instead of repeating the directory
preparation on every attempt.

Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
@rominf
rominf marked this pull request as ready for review August 14, 2026 13:25
@rominf
rominf requested a review from a team as a code owner August 14, 2026 13:25
@rominf
rominf marked this pull request as draft August 14, 2026 13:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

rocm serve --engine lemonade fails on any headless host where XDG_RUNTIME_DIR is unset

1 participant