Skip to content

Commit aa1c4aa

Browse files
authored
add support for timeout on plugins (#4)
1 parent 5367529 commit aa1c4aa

32 files changed

Lines changed: 523 additions & 176 deletions

File tree

.gitattributes

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Auto-detect text files and always use LF everywhere (Linux, macOS and
2+
# Windows). CRLF is never checked out, so no autocrlf warnings on Windows.
3+
* text=auto eol=lf
4+
5+
# Rust source code
6+
*.rs linguist-language=Rust text
7+
8+
# Configuration files
9+
*.toml linguist-language=TOML text
10+
*.jsonc linguist-language=JSON text
11+
*.json linguist-language=JSON text
12+
Cargo.lock linguist-language=TOML text
13+
14+
# Scripts and CI
15+
*.sh linguist-language=Shell text eol=lf
16+
*.ps1 linguist-language=PowerShell text
17+
*.yml linguist-language=YAML text
18+
*.yaml linguist-language=YAML text
19+
20+
# Documentation
21+
*.md linguist-language=Markdown text
22+
LICENSE linguist-language=Text text
23+
*.txt linguist-language=Text text
24+
25+
# Git configuration
26+
.gitignore linguist-language=Ignore-List text
27+
.gitattributes linguist-language=Git-Attributes text

.github/workflows/rust-tests.yml

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
name: Rust Tests
1+
name: CI
22

33
on:
44
push:
@@ -7,9 +7,18 @@ on:
77
pull_request:
88

99
jobs:
10-
test:
11-
runs-on: ubuntu-latest
10+
ci:
11+
runs-on: ${{ matrix.os }}
12+
strategy:
13+
matrix:
14+
os: [ubuntu-latest, macos-latest, windows-latest]
1215
steps:
1316
- uses: actions/checkout@v4
1417
- uses: dtolnay/rust-toolchain@stable
15-
- run: cargo test --workspace
18+
- name: CI (Linux/macOS)
19+
if: runner.os != 'Windows'
20+
run: bash ci/unix.sh
21+
- name: CI (Windows)
22+
if: runner.os == 'Windows'
23+
shell: pwsh
24+
run: ./ci/windows.ps1

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Changelog
2+
3+
## 2026-08-19
4+
5+
### Timeout Standard
6+
7+
- All plugins now wrap their work in `with_timeout` with an own runtime budget (2–25 s); on timeout they respond with fallback lines or exit gracefully, so a hung plugin can never hang xfetch.
8+
- A plugin without a runtime limit is rejected — enforced by CI (`ci/unix.sh`, `ci/windows.ps1`, running on Linux, macOS and Windows). PRs must pass CI.
9+
- Requires `xfetch-plugin-api` with `with_timeout` (see the `api` repo).
10+
11+
### Plugins (as of 2026-08-19)
12+
13+
- `animate-logo` — logo animation for the daemon
14+
- `display-resolution` — screen resolution
15+
- `docker` — container stats
16+
- `github-stats` — GitHub profile statistics
17+
- `music-player` — MPD/Spotify status
18+
- `temperature` — CPU/thermal zone temperatures
19+
- `theme-detection` — GTK theme detection
20+
- `theme-manager` — theme registry management
21+
- `timezone` — local time and UTC offset
22+
- `user-info` — user, host and groups
23+
- `weather` — weather via wttr.in
24+
25+
Each plugin has its own CHANGELOG with its specific budget and changes.

CONTRIBUTING.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,16 @@
1212
<li>Fork the repository and create a feature branch.</li>
1313
<li>Create or update a plugin directory at the repository root.</li>
1414
<li>Run <code>cargo test --workspace</code>.</li>
15+
<li>
16+
Run the full CI locally before opening the PR:
17+
<code>bash ci/unix.sh</code> (Linux/macOS) or <code>./ci/windows.ps1</code>
18+
(Windows). The CI checks tests <strong>and</strong> the plugin standard.
19+
</li>
1520
<li>Document the plugin in its own <code>README.md</code> and in <a href="./README.md">README.md</a>.</li>
16-
<li>Open a pull request with usage details and any required external dependencies.</li>
21+
<li>
22+
Open a pull request with usage details and any required external
23+
dependencies. PRs that fail CI are rejected.
24+
</li>
1725
</ol>
1826

1927
<h2>Plugin Rules</h2>
@@ -23,6 +31,14 @@
2331
<li>Keep plugins focused on a single responsibility.</li>
2432
<li>Write errors to stderr and exit with a non-zero status on failure.</li>
2533
<li>Prefer stable, actively maintained dependencies and keep them minimal.</li>
34+
<li>
35+
<strong>Every plugin MUST have a runtime limit.</strong> Wrap all work in
36+
<code>with_timeout</code> (from <code>xfetch_plugin_api</code>) with a
37+
<code>const BUDGET</code> that fits the plugin (local probes: ~2 s,
38+
network calls: 15–25 s) and respond with fallback lines when the budget
39+
elapses. A plugin without a timeout is rejected: it could hang xfetch
40+
forever. This is enforced by CI.
41+
</li>
2642
</ul>
2743

2844
<h2>Protocol Guide</h2>

Cargo.lock

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ci/unix.sh

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
#!/usr/bin/env bash
2+
# CI for Linux/macOS: build, test and enforce the plugin standard.
3+
set -euo pipefail
4+
cd "$(dirname "$0")/.."
5+
6+
cargo test --workspace
7+
8+
# Standard: every plugin must wrap its work in with_timeout (CONTRIBUTING.md).
9+
for f in plugins/*/src/main.rs; do
10+
grep -q "with_timeout" "$f" || {
11+
echo "::error::$f must use xfetch_plugin_api::with_timeout" >&2
12+
exit 1
13+
}
14+
done
15+
echo "All plugins use with_timeout."

ci/windows.ps1

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# CI for Windows: build, test and enforce the plugin standard.
2+
$ErrorActionPreference = "Stop"
3+
Set-Location (Join-Path $PSScriptRoot "..")
4+
5+
cargo test --workspace
6+
7+
# Standard: every plugin must wrap its work in with_timeout (CONTRIBUTING.md).
8+
foreach ($f in Get-ChildItem "plugins\*\src\main.rs") {
9+
if (-not (Select-String -Path $f.FullName -Pattern "with_timeout" -Quiet)) {
10+
Write-Error "$($f.FullName) must use xfetch_plugin_api::with_timeout"
11+
exit 1
12+
}
13+
}
14+
Write-Host "All plugins use with_timeout."

plugins/animate-logo/CHANGELOG.md

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,4 @@
11
# Changelog
22

3-
## 2026-08-15 — v0.1.0
4-
5-
### Full Frame Cycle for Frame-Style Animations
6-
7-
- Fixed frame truncation in `frame` style: when `duration_ms` is not set in the logo animation config, the plugin no longer caps the output to the 1200 ms default (e.g. 14 frames at 12 fps), which caused the animation to be cut short and restart from the beginning.
8-
- When `duration_ms` is absent and `style` is `frame` with source frames available, the plugin now emits every source frame exactly once (e.g. all 36 frames of a kitty animation), letting the host loop the complete animation.
9-
- Behavior for generated styles (`sweep`, `wave`, `rainbow`, `sparkle`, `breathing`, `none`) and for explicit `duration_ms` values is unchanged.
3+
## 2026-08-19
4+
- Wrapped work in `with_timeout` with a 10 s budget; on timeout it exits with an error instead of hanging.

plugins/animate-logo/src/main.rs

Lines changed: 44 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,56 @@
1+
use std::time::Duration;
12
use xfetch_plugin_api::{
2-
AnimationFrame, read_logo_animation_request, write_logo_animation_frames,
3+
AnimationFrame, read_logo_animation_request, with_timeout, write_logo_animation_frames,
34
};
45

6+
/// Frame generation is CPU-bound; the user-controlled duration can demand
7+
/// thousands of frames, so the budget is generous.
8+
const BUDGET: Duration = Duration::from_secs(10);
9+
510
fn main() {
6-
let request = match read_logo_animation_request() {
7-
Ok(value) => value,
8-
Err(err) => {
9-
eprintln!("{}", err);
10-
std::process::exit(1);
11-
}
12-
};
11+
let frames: Vec<AnimationFrame> = match with_timeout(BUDGET, || {
12+
let request = match read_logo_animation_request() {
13+
Ok(value) => value,
14+
Err(err) => {
15+
eprintln!("{}", err);
16+
std::process::exit(1);
17+
}
18+
};
1319

14-
let args = request.args;
15-
let fps = clamp(args.fps.unwrap_or(12), 1, 60);
16-
let frame_delay = 1000 / fps;
17-
let style = args.style.as_deref().unwrap_or("sweep");
20+
let args = request.args;
21+
let fps = clamp(args.fps.unwrap_or(12), 1, 60);
22+
let frame_delay = 1000 / fps;
23+
let style = args.style.as_deref().unwrap_or("sweep");
1824

19-
let frame_count = if args.duration_ms.is_none()
20-
&& style == "frame"
21-
&& request.frames.as_ref().is_some_and(|sets| !sets.is_empty())
22-
{
23-
request.frames.as_ref().unwrap().len() as u64
24-
} else {
25-
let duration_ms = std::cmp::max(frame_delay, args.duration_ms.unwrap_or(1200));
26-
std::cmp::max(1, duration_ms / frame_delay)
27-
};
25+
let frame_count = if args.duration_ms.is_none()
26+
&& style == "frame"
27+
&& request.frames.as_ref().is_some_and(|sets| !sets.is_empty())
28+
{
29+
request.frames.as_ref().unwrap().len() as u64
30+
} else {
31+
let duration_ms = std::cmp::max(frame_delay, args.duration_ms.unwrap_or(1200));
32+
std::cmp::max(1, duration_ms / frame_delay)
33+
};
2834

29-
let frame_sets = request.frames.unwrap_or_default();
35+
let frame_sets = request.frames.unwrap_or_default();
3036

31-
let frames: Vec<AnimationFrame> = match style {
32-
"frame" if !frame_sets.is_empty() => {
33-
generate_ascii_frame_animation(&frame_sets, frame_count, frame_delay)
37+
match style {
38+
"frame" if !frame_sets.is_empty() => {
39+
generate_ascii_frame_animation(&frame_sets, frame_count, frame_delay)
40+
}
41+
"wave" => generate_wave_animation(&request.lines, frame_count, frame_delay),
42+
"rainbow" => generate_rainbow_animation(&request.lines, frame_count, frame_delay),
43+
"sparkle" => generate_sparkle_animation(&request.lines, frame_count, frame_delay),
44+
"breathing" => generate_breathing_animation(&request.lines, frame_count, frame_delay),
45+
"none" => generate_static_animation(&request.lines, frame_count, frame_delay),
46+
_ => generate_sweep_animation(&request.lines, frame_count, frame_delay),
47+
}
48+
}) {
49+
Ok(frames) => frames,
50+
Err(_) => {
51+
eprintln!("animate-logo: timed out");
52+
std::process::exit(1);
3453
}
35-
"wave" => generate_wave_animation(&request.lines, frame_count, frame_delay),
36-
"rainbow" => generate_rainbow_animation(&request.lines, frame_count, frame_delay),
37-
"sparkle" => generate_sparkle_animation(&request.lines, frame_count, frame_delay),
38-
"breathing" => generate_breathing_animation(&request.lines, frame_count, frame_delay),
39-
"none" => generate_static_animation(&request.lines, frame_count, frame_delay),
40-
_ => generate_sweep_animation(&request.lines, frame_count, frame_delay),
4154
};
4255

4356
if let Err(err) = write_logo_animation_frames(frames) {
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Changelog
2+
3+
## 2026-08-19
4+
- Wrapped work in `with_timeout` with a 2 s budget; on timeout it responds with a fallback line.

0 commit comments

Comments
 (0)