Skip to content

Support user-hosted daemon execution under systemd --user - #148

Open
leynos wants to merge 6 commits into
mainfrom
user-hosted-execution
Open

Support user-hosted daemon execution under systemd --user#148
leynos wants to merge 6 commits into
mainfrom
user-hosted-execution

Conversation

@leynos

@leynos leynos commented Jul 19, 2026

Copy link
Copy Markdown
Owner

Summary

This branch makes Comenq practical to host as a systemd --user service. The daemon and client now agree on a per-user socket under $XDG_RUNTIME_DIR, the GitHub Personal Access Token can be read from a file (enabling systemd LoadCredential integration), the cooldown gains a command-line override plus an optional random flutter, and a packaged user unit with a matching example configuration documents the whole arrangement. Deploying the result also surfaced a startup bug in the supervisor — the queue worker could never start because both queue sides were opened twice — which this branch fixes.

Review walkthrough

  • Start with src/lib.rs for the socket resolution and discovery helpers shared by both binaries: the daemon defaults to $XDG_RUNTIME_DIR/comenq/comenq.sock when a user runtime directory exists, and the client connects to the first existing socket among the user and system paths.
  • Then crates/comenq/src/lib.rs: the client's --socket becomes an optional override with a COMENQ_SOCKET environment variable, resolved in Args::socket_path at connect time. Clap's default_value_os_t caches computed defaults in a process-wide static, so resolving lazily is deliberate.
  • crates/comenqd/src/config.rs adds github_token_file (read at startup, trimmed contents override github_token, ${VAR} prefix expanded from the environment for ${CREDENTIALS_DIRECTORY}/token), the cooldown_flutter_seconds key, and the --github-token-file and --cooldown-period-seconds overrides, with validation that some source yields a non-empty token.
  • crates/comenqd/src/worker.rs applies the flutter: every wait is the full cooldown plus a fresh uniform random duration up to the flutter ceiling, using saturating_add — flutter only ever lengthens the wait.
  • crates/comenqd/src/supervisor.rs carries the bug fix: the supervisor previously opened a full yaque::channel() for the writer, the worker, and writer respawns, so the worker always failed with "sender side already in use" and restarted forever. Each site now opens exactly one side (Sender::open / Receiver::open); a regression test spawns the worker while a sender is held.
  • crates/comenqd/src/listener.rs now creates the socket's parent directory so manual runs work without RuntimeDirectory=.
  • Finish with the packaging: packaging/linux/comenqd-user.service (%h paths, RuntimeDirectory=comenq, StateDirectory=comenq, LoadCredential=token:%h/pandalump-token, WantedBy=default.target), packaging/config/comenqd-user.toml, the updated man pages under packaging/man, and the configuration table plus §4.2.1 in docs/comenq-design.md.
  • Behavioural coverage lives in tests/features/config.feature, tests/features/cli.feature, and tests/features/packaging.feature.

Validation

  • make lint (clippy with warnings denied plus the Whitaker Dylint suite): clean. Test modules moved to config/tests.rs and worker/tests.rs to respect the 400-line module cap; comenq_lib joins the sanctioned test-support exclusions in dylint.toml.
  • make test: 136 unit and behavioural tests passed; all cucumber scenarios passed.
  • make markdownlint and make nixie: clean.
  • Live smoke test: the daemon runs under systemd --user with the packaged unit, credential-fed token, socket at /run/user/1000/comenq/comenq.sock, and queue in ~/.local/state/comenq/queue; the client discovers the socket without flags.

Notes

  • The supervisor fix affects system deployments as well as user-mode hosting; the worker restart loop was present on main but invisible to the existing test suite, which exercised the tasks individually rather than the assembled topology.
  • Flutter deliberately has no command-line flag; it is configurable via the file or COMENQD_COOLDOWN_FLUTTER_SECONDS.

leynos added 5 commits July 19, 2026 14:51
Add socket path resolution helpers to `comenq-lib` so both binaries
agree on where a user-hosted daemon lives:

- `user_socket_path` derives `$XDG_RUNTIME_DIR/comenq/comenq.sock`
  when a user runtime directory is available.
- `default_socket_path` prefers the user runtime path and falls back
  to the system-wide `/run/comenq/comenq.sock`.
- `discover_socket_path` returns the first existing socket among the
  user and system paths so clients find whichever daemon is running.

The daemon's default `socket_path` now uses the context-aware
resolution, and the listener creates the socket's parent directory
when absent so a user-hosted daemon works without systemd's
`RuntimeDirectory=` support.

The client's `--socket` flag becomes optional, resolves through
discovery at connect time, and gains a `COMENQ_SOCKET` environment
override. Resolution happens in `Args::socket_path` rather than
clap's `default_value_os_t` because clap caches computed defaults in
a process-wide static and would ignore environment changes between
parses.

Exclude `comenq_lib` from the `no_std_fs_operations` Whitaker lint:
its new discovery tests create placeholder sockets in ambient
tempdirs, the same sanctioned test-support category as the cucumber
crate. Move the daemon config tests to `config/tests.rs` to respect
the 400-line module cap.
Add an optional `github_token_file` key to the daemon configuration.
When set, the file is read at startup and its trimmed contents
override `github_token`; an empty or unreadable file fails loudly.
A leading `${VAR}` placeholder in the path is expanded from the
environment, so a systemd unit can pass
`LoadCredential=token:%h/pandalump-token` and the configuration can
reference `github_token_file = "${CREDENTIALS_DIRECTORY}/token"`
without hard-coding the credentials directory.

`github_token` is no longer required by deserialization; instead a
validation step demands that one of the two sources yields a
non-empty token, preserving the previous failure mode for configs
with no token at all.

Also add `--github-token-file` and `--cooldown-period-seconds` to
the daemon's command-line overrides, so the posting interval can be
set without editing the configuration file. Both keys remain
settable through `COMENQD_*` environment variables as before.
Introduce a `cooldown_flutter_seconds` configuration key
(default 0, disabled). After each processed request the worker now
waits the full configured cooldown plus a fresh uniformly random
duration between zero and the flutter ceiling. Flutter only ever
lengthens the wait, so the guaranteed minimum spacing between posts
is preserved while the cadence stops being perfectly regular.

Use the `rand` crate for sampling and `saturating_add` so extreme
configurations cannot overflow. Move the worker unit tests to
`worker/tests.rs` to stay within the 400-line module cap.
Ship `packaging/linux/comenqd-user.service` for hosting the daemon
under `systemd --user`:

- `%h`-based paths for the binary and configuration file
- `RuntimeDirectory=comenq` for the socket under `$XDG_RUNTIME_DIR`
- `StateDirectory=comenq` plus `COMENQD_QUEUE_PATH` for a queue in
  `~/.local/state/comenq/queue`
- `LoadCredential=token:%h/pandalump-token` feeding the PAT to the
  daemon via `github_token_file =
  "${CREDENTIALS_DIRECTORY}/token"`
- `WantedBy=default.target`

Add the matching example configuration
`packaging/config/comenqd-user.toml`, document the new
`github_token_file` and `cooldown_flutter_seconds` keys in the system
example config, and update the man pages, README, and design document
for socket discovery, token files, flutter, and user-mode
deployment. Cover the user unit with a packaging scenario.
The daemon opened a full `yaque::channel()` in three places: at
startup for the writer's sender, in the worker spawn for its
receiver, and again on writer respawn. Each `channel()` call acquires
both of yaque's per-side lock files, so the worker could never start
while the writer held the sender — it failed with "sender side
already in use" and sat in a permanent restart loop, and a writer
respawn would likewise have contended with the worker's receiver.

Open exactly the required side instead: `Sender::open` for the
writer (startup and respawn) and `Receiver::open` for the worker.
Add a regression test that spawns the worker while a sender is held
and asserts it starts and shuts down cleanly.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @leynos, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 41 minutes

Your organization has reached its usage spending cap. Adjust your spending cap in the billing tab.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 07ade739-3ccd-45ef-add1-483f7e1e45dd

📥 Commits

Reviewing files that changed from the base of the PR and between 9a03a76 and 0603c3c.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (28)
  • Cargo.toml
  • README.md
  • crates/comenq/Cargo.toml
  • crates/comenq/src/client.rs
  • crates/comenq/src/lib.rs
  • crates/comenqd/Cargo.toml
  • crates/comenqd/src/config.rs
  • crates/comenqd/src/config/tests.rs
  • crates/comenqd/src/listener.rs
  • crates/comenqd/src/supervisor.rs
  • crates/comenqd/src/supervisor/tests.rs
  • crates/comenqd/src/worker.rs
  • crates/comenqd/src/worker/tests.rs
  • docs/comenq-design.md
  • dylint.toml
  • packaging/config/comenqd-user.toml
  • packaging/config/comenqd.toml
  • packaging/linux/comenqd-user.service
  • packaging/man/comenq.1
  • packaging/man/comenqd.1
  • src/lib.rs
  • tests/features/cli.feature
  • tests/features/config.feature
  • tests/features/packaging.feature
  • tests/steps/cli_steps.rs
  • tests/steps/client_main_steps.rs
  • tests/steps/config_steps.rs
  • tests/steps/packaging_steps.rs
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch user-hosted-execution

Comment @coderabbitai help to get the list of available commands.

@leynos
leynos marked this pull request as ready for review July 19, 2026 16:17

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 10389af8c0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/lib.rs Outdated
[user_socket_path(), Some(PathBuf::from(DEFAULT_SOCKET_PATH))]
.into_iter()
.flatten()
.find(|candidate| candidate.exists())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fall back when the preferred socket is stale

When a user daemon exits outside the packaged systemd unit, run_listener drops the listener without unlinking its socket, so the path can continue to exist even though nothing is listening. If a healthy system daemon is also available, this existence check always selects the stale user socket; UnixStream::connect then fails without trying /run/comenq/comenq.sock, contradicting the advertised fallback. Probe candidates by connecting in order, or otherwise remove/skip stale sockets before selecting the path.

Useful? React with 👍 / 👎.

A daemon that exits outside the packaged unit leaves its socket file
behind, and the existence-based discovery always selected that stale
user socket, so `UnixStream::connect` failed without ever trying the
healthy system daemon — contradicting the advertised fallback.

Replace `discover_socket_path` with `socket_candidates`, which
returns the user runtime path (when available) followed by the
system path, and have the client connect to each candidate in order,
using the first socket that accepts. A stale file fails to connect
(typically `ECONNREFUSED`) and is skipped with a warning; the last
connection error is surfaced when every candidate fails. An explicit
`--socket` or `COMENQ_SOCKET` still yields exactly that path with no
fallback.

Add regression tests binding and dropping a listener so the socket
file persists: the client must fall past it to a live socket, and
report the connection error when all candidates fail.
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.

1 participant