diff --git a/src-tauri/.cargo/config.toml b/.cargo/config.toml similarity index 60% rename from src-tauri/.cargo/config.toml rename to .cargo/config.toml index 763933f..220bc12 100644 --- a/src-tauri/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,4 +1,4 @@ # TypeScript bindings are generated from Rust models by `cargo test` (ts-rs). [env] -TS_RS_EXPORT_DIR = { value = "../src/types/generated", relative = true } +TS_RS_EXPORT_DIR = { value = "src/types/generated", relative = true } TS_RS_LARGE_INT = "number" diff --git a/.github/workflows/actionlint.yml b/.github/workflows/actionlint.yml new file mode 100644 index 0000000..8e9af66 --- /dev/null +++ b/.github/workflows/actionlint.yml @@ -0,0 +1,24 @@ +name: Workflow lint + +on: + pull_request: + types: [opened, reopened, ready_for_review, synchronize] + paths: [".github/workflows/**"] + workflow_dispatch: + +permissions: + contents: read + +jobs: + actionlint: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: taiki-e/install-action@cfdb7ed4ccc7c7dce37759eae89dc5674b469b39 # v2.90.3 + with: + tool: actionlint@1.7.12 + # shellcheck is preinstalled on GitHub's Ubuntu runners; actionlint uses it for `run:` scripts. + - run: actionlint -color diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5213881..9ed8e39 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,20 +66,22 @@ jobs: - name: Generated TypeScript types are up to date shell: bash - run: git diff --exit-code -- src/types/generated + run: | + git status --porcelain -- src/types/generated + test -z "$(git status --porcelain -- src/types/generated)" bundle: name: Bundle (${{ matrix.os }}) needs: check - # Validate Debian packaging on pull requests; build every bundle manually. - if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && matrix.os == 'ubuntu-22.04') strategy: fail-fast: false matrix: + # Pull requests validate Debian packaging only; a manual run builds every bundle. + # `matrix` is not available in a job-level `if:`, so the OS list is chosen here. # Linux bundles are built on the oldest supported base so the # binary's glibc requirement (2.35) is met by Debian 12, Ubuntu 22.04 # and newer. Building on a newer distro would raise that floor. - os: [windows-latest, ubuntu-22.04] + os: ${{ github.event_name == 'pull_request' && fromJSON('["ubuntu-22.04"]') || fromJSON('["windows-latest", "ubuntu-22.04"]') }} runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 @@ -102,9 +104,9 @@ jobs: with: workspaces: src-tauri - run: npm ci - - run: npx tauri build --config src-tauri/tauri.linux.conf.json + - run: npx tauri build --config src-tauri/tauri.linux.conf.json -- --locked if: runner.os == 'Linux' - - run: npx tauri build + - run: npx tauri build -- --locked if: runner.os == 'Windows' - name: Validate Debian package if: runner.os == 'Linux' diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml index d8e568e..f855c15 100644 --- a/.github/workflows/release-linux.yml +++ b/.github/workflows/release-linux.yml @@ -19,7 +19,13 @@ jobs: - name: Check release version env: TAG: ${{ github.ref_name }} - run: test "${TAG#v}" = "$(jq -r .version src-tauri/tauri.conf.json)" + run: | + version=${TAG#v} + test "$version" = "$(jq -r .version src-tauri/tauri.conf.json)" + test "$version" = "$(jq -r .version package.json)" + grep -qx "version = \"$version\"" src-tauri/Cargo.toml + grep -qx "version = \"$version\"" src-tauri/core/Cargo.toml + head -n 1 src-tauri/linux/tasky.1 | grep -qF "\"Tasky $version\"" - run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev desktop-file-utils - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: @@ -27,9 +33,21 @@ jobs: cache: npm - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable branch with: + components: clippy toolchain: 1.96.0 + - uses: Swatinem/rust-cache@e9d7b9f1e587218970671bac74e5511ed9506e6a # v2 + with: + workspaces: src-tauri - run: npm ci - - run: npx tauri build --bundles deb --config src-tauri/tauri.linux.conf.json + - name: Verify before publishing + run: | + npm run typecheck + npm run lint + npm test + cd src-tauri + cargo clippy --workspace --all-targets --locked -- -D warnings + cargo test --workspace --locked + - run: npx tauri build --bundles deb --config src-tauri/tauri.linux.conf.json -- --locked - name: Validate package and checksum run: | mapfile -t packages < <(find src-tauri/target/release/bundle/deb -maxdepth 1 -type f -name '*.deb' -print) @@ -90,6 +108,15 @@ jobs: APTLY_PASSPHRASE_FILE: ${{ runner.temp }}/aptly-passphrase run: | scripts/publish-apt.sh Tasky.deb "$GITHUB_REF_NAME" public + - name: Remove signing key from runner + if: always() + env: + APTLY_KEY_ID: ${{ vars.APTLY_KEY_ID || secrets.APTLY_KEY_ID }} + run: | + rm -f "$RUNNER_TEMP/aptly-passphrase" + if [ -n "$APTLY_KEY_ID" ] && gpg --batch --list-secret-keys "$APTLY_KEY_ID" >/dev/null 2>&1; then + gpg --batch --yes --delete-secret-keys "$APTLY_KEY_ID" + fi - uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 with: path: public diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index f433ccd..51e4db4 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -27,9 +27,9 @@ jobs: - name: Audit npm lockfile without running package scripts run: npm audit --package-lock-only --audit-level=moderate - name: Install pinned local auditors - run: | - cargo install --locked --version 0.22.2 cargo-audit - cargo install --locked --version 0.20.2 cargo-deny + uses: taiki-e/install-action@cfdb7ed4ccc7c7dce37759eae89dc5674b469b39 # v2.90.3 + with: + tool: cargo-audit@0.22.2,cargo-deny@0.20.2 - name: RustSec (including visible maintenance and unsoundness warnings) working-directory: src-tauri run: cargo audit diff --git a/.gitignore b/.gitignore index 965d537..1d200cc 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,8 @@ dist-ssr # Rust / Tauri src-tauri/target +# ts-rs default output when its export dir isn't configured +src-tauri/core/bindings coverage playwright-report test-results diff --git a/README.md b/README.md index 13e5a8a..f67d4e4 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ then capture the page. - End, force end, suspend, resume, change priority, copy details, open file location. - Confirmation dialogs with escalating warnings. - - Server-side protection for critical system processes and for Task Manager + - Server-side protection for critical system processes and for Tasky itself. - **Benchmarks:** a repeatable CPU benchmark (single-core and multi-core: integer, floating point, SHA-256, compression) and a GPU compute benchmark @@ -104,14 +104,16 @@ then capture the page. ## Supported operating systems -| OS | Status | -| ------------------------------------------ | ---------------------------------------------------- | -| Windows 11 (x64) | Supported (primary) | -| Debian / Ubuntu and other mainstream Linux | Supported; works on KDE Plasma and GNOME | -| macOS | Not yet; the architecture has a platform slot for it | +| OS | Status | +| ---------------------------------- | ---------------------------------------------------- | +| Windows 11 (x64) | Supported (primary) | +| Debian 12+ / Ubuntu 22.04+ (amd64) | Supported; works on KDE Plasma and GNOME | +| Other Linux distributions | Not tested; build from source | +| macOS | Not yet; the architecture has a platform slot for it | **Linux desktops:** the Linux port is available and compatible with both -KDE Plasma and GNOME, on Wayland. Packages: `.deb`, `.rpm` and AppImage (see +KDE Plasma and GNOME, on Wayland. Release builds ship a `.deb` only (APT +repository and GitHub Releases); `.rpm` and AppImage can be built locally (see Building). | Desktop | Status | System tray | Window position | @@ -196,7 +198,25 @@ Platform differences are documented in ### Release notes -**0.1.4** (current) +**0.1.5** (current) + +- **Safer process actions:** menus, the Ports page and Process Details links + act only on the exact process you picked, never on a new one that reused + its PID. Suspending or ending your desktop session now warns first. +- **Drives:** unmounting and powering off ask first, and power-off refuses + unless it can confirm nothing on the drive (including LUKS/LVM volumes) is + still mounted. A dead network mount no longer freezes the metrics; network + mounts are no longer listed. +- **Services:** desktop-session services are locked; stopping SSH, PipeWire + and similar shows a warning. +- **Reliability:** a page error no longer blanks the window, settings changes + can't overwrite each other, and a crashing benchmark reports "Failed" + instead of closing Tasky. +- **Keyboard and screen readers:** the selected process is announced, + Shift+F10 opens its menu, columns resize with the arrow keys, and view + switches support arrow keys. + +**0.1.4** - Fixed the login entry written by "Start with the OS" when Tasky had been upgraded or rebuilt while running. It could point at a path ending in @@ -252,7 +272,8 @@ Platform differences are documented in Prerequisites: - Node.js 20+ (developed with 24). -- Rust stable (1.90+), via [rustup](https://rustup.rs). +- Rust via [rustup](https://rustup.rs); `rust-toolchain.toml` pins the + version CI uses (minimum supported: 1.90). - **Windows:** Microsoft C++ Build Tools ("Desktop development with C++") and the WebView2 runtime, which ships with Windows 11. - **Linux:** @@ -272,7 +293,8 @@ npm run tauri dev ## Building ```sh -npm run tauri build +npm run tauri build # Windows +npm run tauri build -- --config src-tauri/tauri.linux.conf.json # Linux (Tasky name and man page) ``` | Platform | Output (`src-tauri/target/release/bundle/`) | @@ -280,7 +302,8 @@ npm run tauri build | Windows | `msi/` (WiX) and `nsis/` (per-user installer) | | Linux | `deb/`, `rpm/` and `appimage/` | -Build each platform on that platform; CI builds both. To build only the +Build each platform on that platform. Only the `.deb` is published; `.rpm` +and AppImage are local builds and are not tested by CI. To build only the executable, without installers: `npx tauri build --no-bundle`. ## Testing and checks @@ -331,8 +354,8 @@ See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and - Protection rules run in Rust on every request. The UI cannot bypass them. - The app is unprivileged. It never elevates itself; denied operations say so clearly. -- Tauri capabilities are limited to `core:default`. Plugins are used from Rust - only. +- The Tauri capability for the local main window allows only event + listening and the app's own commands. Plugins are used from Rust only. - Command lines are never logged, and are excluded from exports unless you opt in. diff --git a/SECURITY.md b/SECURITY.md index d620d5f..64912f5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -17,5 +17,5 @@ and coordinate a fix before disclosure. No guaranteed response SLA is currently published; an acknowledgement or fix date should not be assumed. See [the application security model](docs/SECURITY.md), -[audit findings](docs/SECURITY_AUDIT.md), and +[2026-11-03 audit findings](docs/archive/security-2026-11-03/SECURITY_AUDIT.md), and [release checklist](docs/SECURITY_CHECKLIST.md). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 754c08e..a8dec9e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,6 @@ # Architecture -Task Manager is a Tauri 2 desktop app. A Rust backend samples the operating +Tasky is a Tauri 2 desktop app. A Rust backend samples the operating system; a React frontend renders what it is sent. This document explains how the pieces fit and why they are shaped this way. diff --git a/docs/BENCHMARKS.md b/docs/BENCHMARKS.md index 5189ad2..054cbfa 100644 --- a/docs/BENCHMARKS.md +++ b/docs/BENCHMARKS.md @@ -1,7 +1,7 @@ # Benchmarks What the Benchmarks page measures, how, and what the numbers do and do not -mean. Implementation plan: [`LINUX_BENCHMARK_IMPLEMENTATION.md`](LINUX_BENCHMARK_IMPLEMENTATION.md). +mean. Implementation plan: [`LINUX_BENCHMARK_IMPLEMENTATION.md`](archive/plans/LINUX_BENCHMARK_IMPLEMENTATION.md). Validation data: [`LINUX_BENCHMARK_VALIDATION.md`](LINUX_BENCHMARK_VALIDATION.md). **Status:** the CPU, GPU, disk and internet benchmarks exist. The full system diff --git a/docs/DEBIAN_PACKAGING.md b/docs/DEBIAN_PACKAGING.md index 27a782f..8cff65e 100644 --- a/docs/DEBIAN_PACKAGING.md +++ b/docs/DEBIAN_PACKAGING.md @@ -86,13 +86,15 @@ Releases. revocation certificate. 5. Commit the desired application version, push it, create the matching - annotated tag (for example `git tag -a v0.1.0 -m 'Release v0.1.0'`), and - push that tag. The release fails if the tag differs from the Tauri version. + annotated tag (for example `git tag -a vX.Y.Z -m 'Release vX.Y.Z'`), and + push that tag. The release fails if the tag differs from the version in + `src-tauri/tauri.conf.json`, `package.json`, both `Cargo.toml` files or the + `tasky(1)` man page. The signing job imports the key only after the protected environment grants access, verifies the complete fingerprint, builds the repository, and checks -both `InRelease` and `Release.gpg` signatures before uploading the Pages -artifact. A separate job deploys that verified artifact with only Pages +both `InRelease` and `Release.gpg` signatures. It then deletes the secret key +from the runner keyring and uploads the Pages artifact. A separate job deploys that verified artifact with only Pages permissions. ## Local package checks @@ -101,7 +103,7 @@ After building the Linux package with `npx tauri build --bundles deb --config src-tauri/tauri.linux.conf.json`, run: ```sh -scripts/validate-deb.sh src-tauri/target/release/bundle/deb/Tasky_0.1.0_amd64.deb +scripts/validate-deb.sh src-tauri/target/release/bundle/deb/Tasky_X.Y.Z_amd64.deb ``` The CI and release workflows also install and remove the package in a clean diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 8b6970a..8962e67 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -14,7 +14,7 @@ palette, light/dark/system themes. Ports view with port-conflict diagnosis, Find Process Using File, per-process Open Files, Modules, Memory Map and Threads tabs, and the Activity timeline (starts/exits, CPU spikes, memory jumps, alerts, actions, benchmarks). See -`FEATURE_GAP_ANALYSIS.md` for the research and scoring behind them. +[`FEATURE_GAP_ANALYSIS.md`](archive/plans/FEATURE_GAP_ANALYSIS.md) for the research and scoring behind them. Also done: Applications view in the process table (processes grouped by program), new-process highlighting (configurable, default 10 s), memory- and diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 8283e27..c779c71 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -1,6 +1,6 @@ # Application security model -Task Manager treats the WebView and all OS-provided text as untrusted. It runs +Tasky treats the WebView and all OS-provided text as untrusted. It runs as the normal user and respects OS permission failures. Never run the GUI as root/Administrator to make a feature work. No elevation broker, shell runner, service-control command or arbitrary memory/file/HTTP command is provided. @@ -37,7 +37,7 @@ The UI also explains unsaved-work and suspension risks. Residual limitations: selected start times currently have one-second resolution; Linux setpriority uses a numeric PID; Windows WM_CLOSE has an HWND reuse interval, and thread enumeration is not atomic. These are documented -in SECURITY_AUDIT.md rather than described as fully race-free. +in the [2026-11-03 audit](archive/security-2026-11-03/SECURITY_AUDIT.md) rather than described as fully race-free. ## Files and local history @@ -98,5 +98,5 @@ policy even when the application does not initiate HTTP traffic. No auto-updater is configured. Distributed artifacts still require trustworthy signing/checksums and protected release credentials; a successful local bundle is not signed provenance. Dependency policies, advisories and CI controls are -in SUPPLY_CHAIN_SECURITY.md. Remaining risks and exact validation status are -in SECURITY_AUDIT.md. For private reporting, see the repository SECURITY.md. +in [SUPPLY_CHAIN_SECURITY.md](SUPPLY_CHAIN_SECURITY.md). Remaining risks and exact validation +status are in the [2026-11-03 audit](archive/security-2026-11-03/SECURITY_AUDIT.md). For private reporting, see the repository [SECURITY.md](../SECURITY.md). diff --git a/docs/SECURITY_CHECKLIST.md b/docs/SECURITY_CHECKLIST.md index ca18d2e..e6560b5 100644 --- a/docs/SECURITY_CHECKLIST.md +++ b/docs/SECURITY_CHECKLIST.md @@ -21,7 +21,7 @@ - [ ] Linux production build/package and interactive controls tested unprivileged. - [ ] Real GPU tests run on supported hardware without claiming fake-device tests validate drivers. - [ ] Installers signed where supported; checksums generated and release provenance retained. -- [ ] Review SECURITY_AUDIT.md remaining risks; do not describe unresolved checks as passed. +- [ ] Review [SECURITY_AUDIT.md](archive/security-2026-11-03/SECURITY_AUDIT.md) remaining risks; do not describe unresolved checks as passed. The audit validation results are recorded separately. This reusable checklist is deliberately unchecked: it must be completed for each release. diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index 61feb14..1d77f7e 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -1,6 +1,6 @@ # Threat model -Audit date: 2026-11-03. Baseline: SECURITY_BASELINE.md. This is a normal-user +Audit date: 2026-11-03. Baseline: [SECURITY_BASELINE.md](archive/security-2026-11-03/SECURITY_BASELINE.md). This is a normal-user desktop application, not a privileged broker. A WebView compromise grants access to permitted commands, not unrestricted OS authority. @@ -72,4 +72,4 @@ during the audit must be revalidated before release. account is outside the protection of WebView ACLs. No claim of protection against root, Administrator, kernel or GPU-driver compromise is made. -See SECURITY_AUDIT.md for severity, evidence and unresolved release checks. +See [SECURITY_AUDIT.md](archive/security-2026-11-03/SECURITY_AUDIT.md) for severity, evidence and unresolved release checks. diff --git a/docs/apt-landing.html b/docs/apt-landing.html index 83541d7..b05d7f0 100644 --- a/docs/apt-landing.html +++ b/docs/apt-landing.html @@ -3,30 +3,110 @@ - + + + + Tasky: a private system monitor and task manager for Linux - Tasky — your system at a glance + + + + + + + + + + + + + + + + +
TaskyTasky
-
+
-
A clear view of your system
+
Free and open source system monitor

Know what your computer is doing.

- Tasky is a fast, private system monitor and task manager. Follow live resource use, - inspect processes and keep control from one quiet, focused desktop app. + Tasky is a fast, private task manager and system monitor for Linux and Windows. Follow + live CPU, memory, disk and network use, inspect and control processes, and benchmark + your machine, all without sending anything off your computer.

-

Linux · amd64   Ubuntu 22.04+ and Debian 12+

+
    +
  • MIT licensed
  • +
  • No telemetry
  • +
  • Works offline
  • +
  • Built with Tauri, Rust and React
  • +
-
-
- System overviewLive monitoring -
- - - - - + + - - - - -
-
CPUPer-core load
-
MemoryUsage history
-
StorageDisk activity
-
NetworkInterface rates
-
+ +
The overview page, shown with sample data.
+ +
+ +
+
+

Everything about your system in one quiet app

+

+ Tasky keeps the essentials one click away and stays fast with thousands of processes. +

+
+
+
+

Live overview

+

+ CPU, memory, storage and network cards with 60-second graphs, plus larger history + views from 60 seconds to one hour. +

+
+
+

A fast process table

+

+ Instant search by name, PID, command line or user. Filter presets, a process tree, + sortable columns and heat mapping. +

+
+
+

Safe process control

+

+ End, suspend, resume or reprioritize processes. Critical system processes are + protected, and risky actions ask first. +

+
+
+

Process details

+

+ CPU, memory and disk history, working directory, child processes and TCP and UDP + connections for any process. +

+
+
+

Benchmarks

+

+ Repeatable CPU and GPU compute tests, a disk benchmark on Linux, and run history + with safe comparison between results. +

+ Internet test only runs after you confirm +
+
+

Alerts and tray

+

+ Rules such as “CPU above 80% for 20 s” with desktop notifications, and a tray icon + with a live CPU and memory summary. +

+
+
+

Linux tools

+

+ See and manage systemd services, and mount or unmount external drives from the + Storage page. System-critical items stay locked. +

+ Linux +
+
+

Export and keyboard

+

+ Export processes as CSV or JSON. A command palette (Ctrl+K), search (Ctrl+F) and + full keyboard navigation. +

+
+
+
+ +
+
+

Light and dark themes

+

Follow your system theme, or pick one in Settings.

+
+
+
+ + + Tasky overview in the dark theme + +
Overview, dark theme (sample data).
+
+
+ + + Tasky process table in the light theme with CPU and memory heat mapping + +
Processes, light theme (sample data).
+
+ +
+
+

Supported platforms

+
+
+ + + + + + + + + + + + + + + + + + + + + +
PlatformStatus
Debian 12+ and Ubuntu 22.04+ (amd64) + Signed APT repository below. Tested on KDE Plasma and GNOME under Wayland. The + tray needs StatusNotifier or the AppIndicator extension on GNOME. +
Windows 11 (x64)Supported. See the README for details.
macOSNot yet.
+
+
+
-
+

Install with APT

- Add the signed Tasky repository once. APT will keep it updated with your system - packages. -

-

- Before installing, review the - key verification steps - and compare the full fingerprint with a trusted project announcement. -

-

- View the repository key fingerprint + Add the signed repository once and APT keeps Tasky updated with your other packages. + Verify the signing key before you trust it.

-
-
sudo install -d -m 0755 /usr/share/keyrings
-curl -fsSL https://mikeholp87.github.io/Tasky/tasky-archive-keyring.asc \
-  | gpg --dearmor \
-  | sudo tee /usr/share/keyrings/tasky-archive-keyring.gpg >/dev/null
+          
    +
  1. +

    Install the prerequisites

    +
    +
    sudo apt update
    +sudo apt install ca-certificates curl gnupg
    +
    +
  2. +
  3. +

    Download the key and check its fingerprint

    +

    + The fingerprint printed by gpg must be exactly this. Also compare it + with the one in the + README on GitHub. +

    + 48F8 0DD5 6C89 66D2 59B2 11C8 1D4B 05C4 EEA1 F6B4 +
    +
    curl -fsSLo /tmp/tasky-archive-keyring.asc \
    +  https://mikeholp87.github.io/Tasky/tasky-archive-keyring.asc
    +gpg --show-keys --with-fingerprint /tmp/tasky-archive-keyring.asc
    +
    +
  4. +
  5. +

    Add the repository

    +
    +
    gpg --dearmor --output /tmp/tasky-archive-keyring.gpg \
    +  /tmp/tasky-archive-keyring.asc
    +sudo install -D -m 0644 /tmp/tasky-archive-keyring.gpg \
    +  /usr/share/keyrings/tasky-archive-keyring.gpg
     echo 'deb [arch=amd64 signed-by=/usr/share/keyrings/tasky-archive-keyring.gpg] https://mikeholp87.github.io/Tasky stable main' \
    -  | sudo tee /etc/apt/sources.list.d/tasky.list >/dev/null
    -sudo apt update
    +  | sudo tee /etc/apt/sources.list.d/tasky.list >/dev/null
    +
    +
  6. +
  7. +

    Install Tasky

    +
    +
    sudo apt update
     sudo apt install tasky
    -

    - To update later: sudo apt update && sudo apt upgrade. To remove: - sudo apt remove tasky. -

    +
    +

    + Update later with sudo apt update && sudo apt upgrade. Remove + with sudo apt remove tasky. Prefer a single file? Download + Tasky.deb and its SHA-256 checksum from + GitHub Releases. +

    +
  8. +
+
+ +
+
+

Private by design

+
+
+
+

No telemetry

+

All monitoring stays on your machine. Tasky does not phone home.

+
+
+

Works offline

+

+ The only network use is the optional internet benchmark, which runs only when you + press its button and confirm the dialog. +

+
+
+

Open source and signed

+

+ MIT licensed source on GitHub, and a signed APT repository you can verify yourself. +

+
-
-
-

Processes in context

+ +
+
+

Questions

+
+
+ Does Tasky collect or send any data?

- Search, sort and inspect process trees. End or suspend tasks with safeguards for - critical system processes. + No. There is no telemetry and it works fully offline. The only network use is the + optional internet benchmark, which contacts Cloudflare’s speed endpoints only after + you press its button and confirm.

-
-
-

Private by design

+ +
+ Can it end any process?

- Monitoring stays on your computer. Tasky works offline; network access is used only by - the optional internet benchmark. + It can end, force end, suspend, resume and reprioritize processes you are allowed to + control. Critical system processes and Tasky itself are protected by the backend, and + risky actions show a confirmation first.

-
-
-

Built for Linux desktops

+ +
+ How are service and drive changes authorized?

- Native packages support Ubuntu 22.04 and newer, and Debian 12 and newer on amd64. KDE - Plasma and GNOME are supported. + On Linux, changes to system services use your desktop’s own authorization prompt. + System and internal partitions are never offered for unmounting.

-
+ +
+ Which Linux desktops work? +

+ KDE Plasma and GNOME on Wayland are tested. Other desktops are not verified. The tray + icon needs StatusNotifier or AppIndicator support. See the platform support notes in + the repository for GNOME window-position details. +

+
+
+ Where do I report a bug or a security issue? +

+ Open an issue on + GitHub. For security reports, + read the + security policy + first. +

+
+

+ diff --git a/docs/FEATURE_GAP_ANALYSIS.md b/docs/archive/plans/FEATURE_GAP_ANALYSIS.md similarity index 100% rename from docs/FEATURE_GAP_ANALYSIS.md rename to docs/archive/plans/FEATURE_GAP_ANALYSIS.md diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/archive/plans/IMPLEMENTATION_PLAN.md similarity index 100% rename from docs/IMPLEMENTATION_PLAN.md rename to docs/archive/plans/IMPLEMENTATION_PLAN.md diff --git a/docs/LINUX_BENCHMARK_IMPLEMENTATION.md b/docs/archive/plans/LINUX_BENCHMARK_IMPLEMENTATION.md similarity index 100% rename from docs/LINUX_BENCHMARK_IMPLEMENTATION.md rename to docs/archive/plans/LINUX_BENCHMARK_IMPLEMENTATION.md diff --git a/docs/LINUX_IMPLEMENTATION_PLAN.md b/docs/archive/plans/LINUX_IMPLEMENTATION_PLAN.md similarity index 98% rename from docs/LINUX_IMPLEMENTATION_PLAN.md rename to docs/archive/plans/LINUX_IMPLEMENTATION_PLAN.md index 5d39861..a710451 100644 --- a/docs/LINUX_IMPLEMENTATION_PLAN.md +++ b/docs/archive/plans/LINUX_IMPLEMENTATION_PLAN.md @@ -2,7 +2,7 @@ Written 2026-11-03 after inspecting the repository on the target machine. It records what already existed, what was missing, and what this pass -changes. Results are in [`LINUX_PORT_REPORT.md`](LINUX_PORT_REPORT.md). +changes. Its results report, `LINUX_PORT_REPORT.md`, was never committed. ## 1. Existing architecture @@ -157,7 +157,7 @@ Test the app under the native KDE Wayland session and under XWayland The repository contained **no** benchmark code (CPU, GPU, disk or internet), so there was nothing to port and building a benchmark suite was kept out of this pass. A shared CPU benchmark was added afterwards; see -[`BENCHMARKS.md`](BENCHMARKS.md) and +[`BENCHMARKS.md`](../../BENCHMARKS.md) and [`LINUX_BENCHMARK_IMPLEMENTATION.md`](LINUX_BENCHMARK_IMPLEMENTATION.md). ## 19. Permissions diff --git a/docs/SECURITY_AUDIT.md b/docs/archive/security-2026-11-03/SECURITY_AUDIT.md similarity index 100% rename from docs/SECURITY_AUDIT.md rename to docs/archive/security-2026-11-03/SECURITY_AUDIT.md diff --git a/docs/SECURITY_BASELINE.md b/docs/archive/security-2026-11-03/SECURITY_BASELINE.md similarity index 100% rename from docs/SECURITY_BASELINE.md rename to docs/archive/security-2026-11-03/SECURITY_BASELINE.md diff --git a/docs/SECURITY_COVERAGE.json b/docs/archive/security-2026-11-03/SECURITY_COVERAGE.json similarity index 100% rename from docs/SECURITY_COVERAGE.json rename to docs/archive/security-2026-11-03/SECURITY_COVERAGE.json diff --git a/docs/SECURITY_VALIDATION.md b/docs/archive/security-2026-11-03/SECURITY_VALIDATION.md similarity index 100% rename from docs/SECURITY_VALIDATION.md rename to docs/archive/security-2026-11-03/SECURITY_VALIDATION.md diff --git a/docs/UNSAFE_RUST_AUDIT.md b/docs/archive/security-2026-11-03/UNSAFE_RUST_AUDIT.md similarity index 100% rename from docs/UNSAFE_RUST_AUDIT.md rename to docs/archive/security-2026-11-03/UNSAFE_RUST_AUDIT.md diff --git a/docs/landing/og.png b/docs/landing/og.png new file mode 100644 index 0000000..fefa449 Binary files /dev/null and b/docs/landing/og.png differ diff --git a/docs/landing/overview-dark-1440.webp b/docs/landing/overview-dark-1440.webp new file mode 100644 index 0000000..6a141f6 Binary files /dev/null and b/docs/landing/overview-dark-1440.webp differ diff --git a/docs/landing/overview-dark-720.webp b/docs/landing/overview-dark-720.webp new file mode 100644 index 0000000..59ca68f Binary files /dev/null and b/docs/landing/overview-dark-720.webp differ diff --git a/docs/landing/processes-light-1440.webp b/docs/landing/processes-light-1440.webp new file mode 100644 index 0000000..8c52036 Binary files /dev/null and b/docs/landing/processes-light-1440.webp differ diff --git a/docs/landing/processes-light-720.webp b/docs/landing/processes-light-720.webp new file mode 100644 index 0000000..3da2cb4 Binary files /dev/null and b/docs/landing/processes-light-720.webp differ diff --git a/package-lock.json b/package-lock.json index f59e177..387ad7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "taskmanager", - "version": "0.1.4", + "version": "0.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "taskmanager", - "version": "0.1.4", + "version": "0.1.5", "dependencies": { "@fontsource/ibm-plex-mono": "^5.3.0", "@fontsource/ibm-plex-sans": "^5.3.0", diff --git a/package.json b/package.json index fcc67e1..55a858e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "taskmanager", "private": true, - "version": "0.1.4", + "version": "0.1.5", "type": "module", "scripts": { "dev": "vite", diff --git a/scripts/publish-apt.sh b/scripts/publish-apt.sh index 5cbc0c0..d69cf74 100755 --- a/scripts/publish-apt.sh +++ b/scripts/publish-apt.sh @@ -47,6 +47,8 @@ aptly publish snapshot -batch -gpg-key="$APTLY_KEY_ID" -passphrase-file="$APTLY_ cp -a "$HOME/.aptly/public/." "$stage/" cp docs/apt-landing.html "$stage/index.html" cp src-tauri/icons/128x128.png "$stage/tasky-icon.png" +mkdir "$stage/assets" +cp docs/landing/*.webp docs/landing/og.png "$stage/assets/" gpg --armor --export "$APTLY_KEY_ID" >"$stage/tasky-archive-keyring.asc" printf '%s\n' "$APTLY_KEY_ID" >"$stage/tasky-archive-keyring.fingerprint" printf '%s\n' "$tag" >"$stage/latest" @@ -55,6 +57,9 @@ test -s "$stage/dists/stable/InRelease" test -s "$stage/dists/stable/Release.gpg" test -s "$stage/index.html" test -s "$stage/tasky-icon.png" +test -s "$stage/assets/overview-dark-1440.webp" +test -s "$stage/assets/processes-light-1440.webp" +test -s "$stage/assets/og.png" gpgv --keyring "$temp/archive-keyring.gpg" "$stage/dists/stable/InRelease" gpgv --keyring "$temp/archive-keyring.gpg" "$stage/dists/stable/Release.gpg" "$stage/dists/stable/Release" mv "$stage" "$output" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 881be80..2427430 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3994,7 +3994,7 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "taskmanager" -version = "0.1.4" +version = "0.1.5" dependencies = [ "gdk", "gio", @@ -4512,7 +4512,7 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tm-core" -version = "0.1.4" +version = "0.1.5" dependencies = [ "flate2", "libc", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 303df91..d027424 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "taskmanager" -version = "0.1.4" +version = "0.1.5" description = "Cross-platform task manager and system monitor" authors = ["Task Manager contributors"] license = "MIT" @@ -19,7 +19,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tm-core = { version = "0.1.4", path = "core" } +tm-core = { version = "0.1.5", path = "core" } tauri = { version = "2", features = ["tray-icon"] } serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -27,7 +27,6 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } tauri-plugin-opener = "2" tauri-plugin-notification = "2" -tauri-plugin-autostart = "2" tauri-plugin-window-state = "2" tauri-plugin-dialog = "2" tracing-appender = "0.2" @@ -38,10 +37,16 @@ tauri-plugin-single-instance = "2" gio = "0.18" gdk = "0.18" +[target.'cfg(not(target_os = "linux"))'.dependencies] +# Linux writes its own XDG login entry instead (src/autostart.rs). +tauri-plugin-autostart = "2" + # https://tauri.app/concept/size/#cargo-configuration [profile.release] codegen-units = 1 lto = true opt-level = 3 -panic = "abort" +# Benchmarks rely on catch_unwind to turn a panic (e.g. in a GPU driver) into +# a "Failed" result and release their locks; "abort" would kill the app. +panic = "unwind" strip = true diff --git a/src-tauri/core/Cargo.toml b/src-tauri/core/Cargo.toml index 86cb7bf..03b64a3 100644 --- a/src-tauri/core/Cargo.toml +++ b/src-tauri/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tm-core" -version = "0.1.4" +version = "0.1.5" description = "Monitoring engine and platform layer for Task Manager (no UI dependencies)" license = "MIT" edition = "2024" diff --git a/src-tauri/core/examples/probe.rs b/src-tauri/core/examples/probe.rs index 247cf00..b2929e2 100644 --- a/src-tauri/core/examples/probe.rs +++ b/src-tauri/core/examples/probe.rs @@ -19,8 +19,8 @@ struct Capture { } impl TickSink for Capture { - fn deliver(&self, tick: &MetricsTick, json: Option<&str>) -> bool { - let bytes = json.map_or(0, str::len); + fn deliver(&self, tick: &MetricsTick, json: Option) -> bool { + let bytes = json.map_or(0, |j| j.len()); if let Ok(mut t) = self.ticks.lock() { t.push((tick.engine.poll_duration_ms, bytes, Arc::new(tick.clone()))); } diff --git a/src-tauri/core/src/benchmarks/coordinator.rs b/src-tauri/core/src/benchmarks/coordinator.rs deleted file mode 100644 index 8f7a593..0000000 --- a/src-tauri/core/src/benchmarks/coordinator.rs +++ /dev/null @@ -1,1492 +0,0 @@ -//! Runs one benchmark at a time and owns everything around it: the lock, -//! the state machine, rate-limited progress, cancellation, saving, and -//! cleanup on every exit path (success, failure, cancel, panic). - -use std::panic::{AssertUnwindSafe, catch_unwind}; -use std::path::Path; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; -use std::time::{Duration, Instant}; - -use super::cancel::CancelToken; -use super::cpu::runner::{CpuHooks, CpuRunConfig, Observer, run_cpu}; -use super::disk::engine::run_disk; -use super::disk::plan::{self as disk_plan_rules, DiskConfig}; -use super::disk::{self, DiskRequest}; -use super::gpu::runner::{GpuRunConfig, run_gpu}; -use super::gpu::wgpu_backend::WgpuBackend; -use super::gpu::{self, GpuBackend}; -use super::history::HistoryStore; -use super::internet::provider::{Cloudflare, Provider}; -use super::internet::{self, InternetConfig, run_internet}; -use super::model::{ - BenchmarkKind, BenchmarkProfile, BenchmarkResult, BenchmarkState, BenchmarkStatus, DiskPlan, - DiskResult, DiskTargetInfo, GpuAdapterInfo, GpuDeviceType, GpuResult, InternetPlan, - LiveReading, NetworkPath, -}; -use super::{BenchmarkError, environment, scoring}; -use crate::errors::{AppError, AppResult}; -use crate::models::TemperatureReading; -use crate::monitoring::{Engine, unix_millis}; -use crate::platform; - -/// Least time between two internet tests. The public speed-test endpoints -/// limit repeated large transfers per address (HTTP 429 for about an hour), so -/// back-to-back runs would only fail. -const INTERNET_COOLDOWN: Duration = Duration::from_secs(120); - -/// Most test data one app session may move in total, both directions. -const INTERNET_SESSION_BUDGET_BYTES: u64 = 1_000_000_000; - -/// Other drive traffic above this when a disk test starts earns a warning. -const DISK_BUSY_WARN_BYTES_PER_SEC: f64 = 20e6; - -/// The UI never needs more than this many progress updates per second. -const MIN_EMIT_INTERVAL: Duration = Duration::from_millis(100); - -/// Receives progress. Called from the benchmark thread; must not block. -pub trait ProgressSink: Send + Sync { - fn status(&self, status: &BenchmarkStatus); - fn finished(&self, result: &BenchmarkResult); -} - -struct ActiveRun { - cancel: CancelToken, -} - -struct Shared { - status: Mutex, - /// `Some` while a run owns the benchmark lock. - active: Mutex>, - counter: AtomicU64, -} - -fn lock(m: &Mutex) -> MutexGuard<'_, T> { - m.lock().unwrap_or_else(PoisonError::into_inner) -} - -pub struct Coordinator { - engine: Arc, - history: Arc, - shared: Arc, - gpu: Arc, - provider: Arc, - /// When the last internet test started, for the cooldown. - internet_last: Arc>>, - /// Bytes moved by internet tests since the app started. - internet_used: Arc, - internet_cooldown: Duration, - internet_budget: u64, - /// Tests replace the list built from the mounted filesystems. - targets_override: Option>, - /// Tests run unoptimised; this lets them exercise scoring and saving. - score_development_builds: bool, -} - -/// What the user chose for a run. Everything is an id or a flag; the backend -/// resolves ids to paths and addresses. -#[derive(Debug, Clone, Default)] -pub struct StartOptions { - /// Graphics adapter id (GPU runs). - pub adapter: Option, - /// Disk location id from [`Coordinator::disk_targets`] (disk runs). - pub disk_target: Option, - /// The user agreed to send and receive test data (internet runs). - pub internet_consent: bool, -} - -/// What a run executes. The user chooses only the kind, profile and adapter; -/// explicit configurations exist for tests. -pub enum RunPlan { - Cpu(CpuRunConfig), - Gpu { - /// `None` means "build it from the profile once the adapter is known". - config: Option, - adapter: Option, - }, - Disk { - config: Option, - target: Box, - }, - Internet { - config: Option, - }, -} - -impl From for RunPlan { - fn from(cfg: CpuRunConfig) -> Self { - Self::Cpu(cfg) - } -} - -impl Coordinator { - pub fn new(engine: Arc, data_dir: Option<&Path>) -> Self { - Self { - engine, - history: Arc::new(HistoryStore::load(data_dir)), - shared: Arc::new(Shared { - status: Mutex::new(BenchmarkStatus::idle()), - active: Mutex::new(None), - counter: AtomicU64::new(0), - }), - gpu: Arc::new(WgpuBackend::new()), - provider: Arc::new(Cloudflare::new()), - internet_last: Arc::new(Mutex::new(None)), - internet_used: Arc::new(AtomicU64::new(0)), - internet_cooldown: INTERNET_COOLDOWN, - internet_budget: INTERNET_SESSION_BUDGET_BYTES, - targets_override: None, - score_development_builds: false, - } - } - - /// Replaces the internet provider (tests use a local server). - #[doc(hidden)] - pub fn with_internet_provider(mut self, provider: Arc) -> Self { - self.provider = provider; - self - } - - /// Replaces the internet cooldown and session budget (tests). - #[doc(hidden)] - pub fn with_internet_limits(mut self, cooldown: Duration, budget_bytes: u64) -> Self { - self.internet_cooldown = cooldown; - self.internet_budget = budget_bytes; - self - } - - /// Replaces the disk locations (tests use temporary folders). - #[doc(hidden)] - pub fn with_disk_targets(mut self, targets: Vec) -> Self { - self.targets_override = Some(targets); - self - } - - /// Locations the disk benchmark may use, from the mounted filesystems. - pub fn disk_targets(&self) -> Vec { - if let Some(t) = &self.targets_override { - return t.clone(); - } - let Some(tick) = self.engine.latest() else { - return Vec::new(); - }; - let mounts: Vec = tick - .disks - .iter() - .map(|d| disk::targets::MountInfo { - mount_point: d.mount_point.clone(), - file_system: d.file_system.clone(), - device: d.name.clone(), - total_bytes: d.total_bytes, - free_bytes: d.available_bytes, - read_only: d.read_only, - }) - .collect(); - let home = std::env::var_os("HOME").map(std::path::PathBuf::from); - disk::targets::build_targets(&mounts, home.as_deref(), &std::env::temp_dir(), &|p| { - disk::writable(p) - }) - } - - /// What a disk run on `target_id` would write, or why it cannot happen. - pub fn disk_plan(&self, target_id: &str, profile: BenchmarkProfile) -> AppResult { - let target = self.find_target(target_id)?; - let cfg = DiskConfig::for_profile(profile); - let free = disk::free_space(std::path::Path::new(&target.directory), target.free_bytes); - let planned = disk_plan_rules::plan_file_bytes(cfg.file_bytes, cfg.min_file_bytes, free); - let file_bytes = planned.clone().unwrap_or(0); - Ok(DiskPlan { - target_id: target.id, - directory: target.directory, - file_bytes, - estimated_bytes_written: disk_plan_rules::estimated_bytes_written(&cfg, file_bytes), - estimated_seconds: disk_plan_rules::estimated_seconds(profile), - blocked: planned.err(), - }) - } - - /// What an internet run would send and receive, and to whom. - pub fn internet_plan(&self, profile: BenchmarkProfile) -> InternetPlan { - internet::plan(&*self.provider, profile) - } - - fn find_target(&self, id: &str) -> AppResult { - self.disk_targets() - .into_iter() - .find(|t| t.id == id) - .ok_or_else(|| AppError::InvalidArgument("That location is not available.".into())) - } - - /// Replaces the GPU backend (tests use a fake, so CI needs no GPU). - #[doc(hidden)] - pub fn with_gpu_backend(mut self, backend: Arc) -> Self { - self.gpu = backend; - self - } - - /// Graphics adapters a GPU benchmark could run on. - pub fn gpu_adapters(&self) -> AppResult> { - self.gpu.adapters().map_err(AppError::from) - } - - #[doc(hidden)] - pub fn score_development_builds(mut self, yes: bool) -> Self { - self.score_development_builds = yes; - self - } - - pub fn status(&self) -> BenchmarkStatus { - lock(&self.shared.status).clone() - } - - pub fn history(&self) -> Vec { - self.history.list() - } - - pub fn delete_result(&self, id: &str) -> AppResult<()> { - self.history.delete(id) - } - - /// Asks the running benchmark to stop. No-op when idle. - pub fn cancel(&self) { - if let Some(run) = lock(&self.shared.active).as_ref() { - run.cancel.cancel(); - } - } - - /// `options` carries ids and flags only; options that do not apply to the - /// kind are ignored. - pub fn start( - &self, - kind: BenchmarkKind, - profile: BenchmarkProfile, - options: StartOptions, - sink: Arc, - ) -> AppResult { - let plan = match kind { - BenchmarkKind::Cpu => { - let logical = self.engine.static_info().logical_core_count as usize; - RunPlan::Cpu(CpuRunConfig::for_profile(profile, logical)) - } - BenchmarkKind::Gpu => RunPlan::Gpu { - config: None, - adapter: options.adapter, - }, - BenchmarkKind::Disk => { - let id = options - .disk_target - .ok_or_else(|| AppError::InvalidArgument("Choose a location first.".into()))?; - RunPlan::Disk { - config: None, - target: Box::new(self.find_target(&id)?), - } - } - BenchmarkKind::Internet => { - // Checked here, not only in the UI: nothing leaves the - // machine unless the caller says the user agreed. - if !options.internet_consent { - return Err(AppError::InvalidArgument( - "Agree to send and receive test data before running the internet test." - .into(), - )); - } - self.check_internet_allowed(profile)?; - RunPlan::Internet { config: None } - } - }; - let started = self.start_with(kind, profile, plan, sink)?; - if kind == BenchmarkKind::Internet { - *lock(&self.internet_last) = Some(Instant::now()); - } - Ok(started) - } - - /// Cooldown and session data budget for internet tests. - fn check_internet_allowed(&self, profile: BenchmarkProfile) -> AppResult<()> { - if let Some(last) = *lock(&self.internet_last) { - let waited = last.elapsed(); - if waited < self.internet_cooldown { - let left = (self.internet_cooldown - waited).as_secs() + 1; - return Err(AppError::InvalidArgument(format!( - "Wait about {left} more seconds before running the internet test again. The test server limits repeated transfers." - ))); - } - } - let plan = self.internet_plan(profile); - let used = self.internet_used.load(Ordering::Relaxed); - if used + plan.max_download_bytes + plan.max_upload_bytes > self.internet_budget { - return Err(AppError::InvalidArgument(format!( - "This session has used {} MB of its {} MB internet test allowance. Restart the app to run it again.", - used / 1_000_000, - self.internet_budget / 1_000_000 - ))); - } - Ok(()) - } - - /// [`start`](Self::start) with an explicit plan (tests use tiny ones). - pub fn start_with( - &self, - kind: BenchmarkKind, - profile: BenchmarkProfile, - plan: impl Into, - sink: Arc, - ) -> AppResult { - let plan = plan.into(); - let cancel = CancelToken::new(); - let prefix = match kind { - BenchmarkKind::Cpu => "cpu", - BenchmarkKind::Gpu => "gpu", - BenchmarkKind::Disk => "disk", - BenchmarkKind::Internet => "net", - }; - let id = format!( - "{prefix}-{}-{}", - unix_millis(), - self.shared.counter.fetch_add(1, Ordering::Relaxed) - ); - let initial = BenchmarkStatus { - state: BenchmarkState::Preparing, - run_id: Some(id.clone()), - kind: Some(kind), - profile: Some(profile), - stage: "Starting".into(), - ..BenchmarkStatus::idle() - }; - { - // Claim the lock and publish the first status together. - let mut active = lock(&self.shared.active); - if active.is_some() { - return Err(AppError::BenchmarkAlreadyRunning); - } - *active = Some(ActiveRun { - cancel: cancel.clone(), - }); - *lock(&self.shared.status) = initial.clone(); - } - sink.status(&initial); - self.engine.record_activity( - crate::monitoring::activity::Pending::new( - crate::models::ActivityKind::BenchmarkStarted, - ) - .detail(format!("{kind:?} benchmark, {profile:?} profile")), - ); - - let ctx = RunContext { - engine: Arc::clone(&self.engine), - history: Arc::clone(&self.history), - shared: Arc::clone(&self.shared), - gpu: Arc::clone(&self.gpu), - provider: Arc::clone(&self.provider), - internet_used: Arc::clone(&self.internet_used), - sink, - score_development_builds: self.score_development_builds, - id, - kind, - profile, - plan, - cancel, - }; - let shared = Arc::clone(&self.shared); - std::thread::Builder::new() - .name("benchmark".into()) - .spawn(move || ctx.run()) - .map_err(|e| { - *lock(&shared.active) = None; - *lock(&shared.status) = BenchmarkStatus::idle(); - AppError::BenchmarkFailed(format!("could not start the worker thread: {e}")) - })?; - Ok(initial) - } -} - -struct RunContext { - engine: Arc, - history: Arc, - shared: Arc, - gpu: Arc, - provider: Arc, - internet_used: Arc, - sink: Arc, - score_development_builds: bool, - id: String, - kind: BenchmarkKind, - profile: BenchmarkProfile, - plan: RunPlan, - cancel: CancelToken, -} - -/// Clears the benchmark lock on any exit, including a panic that escapes -/// the normal finalisation. -struct LockGuard(Option>); -impl Drop for LockGuard { - fn drop(&mut self) { - if let Some(shared) = &self.0 { - *lock(&shared.active) = None; - } - } -} - -impl RunContext { - fn run(self) { - let mut guard = LockGuard(Some(Arc::clone(&self.shared))); - let reporter = Reporter::new(&self); - let outcome = catch_unwind(AssertUnwindSafe(|| self.execute(&reporter))); - let (state, message, result) = match outcome { - Ok(Ok(result)) => { - let saved = if result.environment.development_build { - Ok(()) - } else { - self.history.add(result.clone()) - }; - let message = saved - .err() - .map(|e| format!("The result could not be saved: {e}")); - (BenchmarkState::Completed, message, Some(result)) - } - Ok(Err(BenchmarkError::Cancelled)) => ( - BenchmarkState::Cancelled, - Some("Cancelled. Nothing was saved.".to_owned()), - None, - ), - Ok(Err(e)) => (BenchmarkState::Failed, Some(e.to_string()), None), - Err(_) => ( - BenchmarkState::Failed, - Some("The benchmark stopped unexpectedly.".to_owned()), - None, - ), - }; - tracing::info!(id = %self.id, ?state, "benchmark finished"); - - // Publish the final status and release the lock as one step, so a - // new run can never be overwritten by the old run's last update. - let final_status = { - let mut active = lock(&self.shared.active); - let mut status = lock(&self.shared.status); - status.state = state; - status.message = message; - if state == BenchmarkState::Completed { - status.fraction = 1.0; - status.stage = "Done".into(); - } - status.live = None; - let snapshot = status.clone(); - *active = None; - // A subsequent run may start as soon as this mutex is released. - // The old run's panic guard must never clear the new run's lock. - guard.0 = None; - snapshot - }; - if let Some(result) = &result { - self.sink.finished(result); - } - self.sink.status(&final_status); - // Also recorded when cancelled or failed; the detail says which. - self.engine.record_activity( - crate::monitoring::activity::Pending::new( - crate::models::ActivityKind::BenchmarkCompleted, - ) - .detail(format!("{:?} benchmark: {state:?}", self.kind)), - ); - } - - /// Combined read and write rate of all drives right now, in bytes/s. - fn other_disk_activity(&self) -> Option { - let tick = self.engine.latest()?; - Some( - tick.disks - .iter() - .map(|d| d.read_bytes_per_sec.unwrap_or(0.0) + d.write_bytes_per_sec.unwrap_or(0.0)) - .sum(), - ) - } - - /// The interface carrying internet traffic and whether a tunnel is up. - fn network_path(&self) -> NetworkPath { - let nets = self - .engine - .latest() - .map(|t| t.networks.clone()) - .unwrap_or_default(); - let kind_of = |name: &str| { - nets.iter() - .find(|n| n.name == name) - .map_or(crate::models::InterfaceKind::Unknown, |n| n.kind) - }; - let others: Vec<_> = nets.iter().map(|n| (n.name.clone(), n.kind)).collect(); - internet::route::describe_path(internet::route::read_default_route(), &kind_of, &others) - } - - fn execute(&self, reporter: &Reporter) -> Result { - let started_ms = unix_millis(); - let started = Instant::now(); - reporter.set_state( - BenchmarkState::Preflight, - match self.plan { - RunPlan::Cpu(_) => "Checking the system", - RunPlan::Gpu { .. } => "Checking graphics adapters", - RunPlan::Disk { .. } => "Checking the drive", - RunPlan::Internet { .. } => "Checking the connection", - }, - ); - - let background = self.engine.latest().map(|t| t.cpu.usage_percent); - let mut env = environment::collect(self.engine.static_info(), background); - if self.score_development_builds { - env.development_build = false; - } - let mut warnings = environment::warnings(&env); - if self.cancel.is_cancelled() { - return Err(BenchmarkError::Cancelled); - } - - let mut result = BenchmarkResult { - id: self.id.clone(), - kind: self.kind, - profile: self.profile, - started_at_ms: started_ms, - duration_ms: 0, - method: scoring::cpu_method(), - environment: env.clone(), - workloads: Vec::new(), - sustained: None, - scores: None, - gpu: None, - gpu_score: None, - disk: None, - disk_score: None, - internet: None, - warnings: Vec::new(), - stability: super::model::Stability::High, - outlier_method: "1.5×IQR; flagged, none removed".into(), - affinity: "not pinned".into(), - }; - - match &self.plan { - RunPlan::Cpu(cfg) => { - let hooks = EngineHooks(&self.engine); - let out = run_cpu(cfg, &hooks, &self.cancel, reporter)?; - reporter.set_state(BenchmarkState::Finalizing, "Calculating results"); - let scores = if env.development_build { - None - } else { - scoring::cpu_scores(&out.workloads) - }; - if scores.is_none() && !env.development_build { - warnings.push( - "Some measurements were unavailable, so no score was calculated.".into(), - ); - } - result.stability = scoring::stability(&out.workloads, &warnings); - result.workloads = out.workloads; - result.sustained = out.sustained; - result.scores = scores; - result.affinity = out.affinity; - } - RunPlan::Gpu { config, adapter } => { - let profile = self.profile; - let (info, mut exec, cfg) = self.gpu.open(adapter.as_deref(), &|a| { - config.clone().unwrap_or_else(|| { - GpuRunConfig::for_profile(profile, a.device_type == GpuDeviceType::Discrete) - }) - })?; - let out = run_gpu(&mut *exec, &cfg, &self.cancel, reporter)?; - // Release the device before the (possibly slow) bookkeeping. - drop(exec); - reporter.set_state(BenchmarkState::Finalizing, "Calculating results"); - let software = info.device_type == GpuDeviceType::Software; - let score = if env.development_build || software { - None - } else { - scoring::gpu_score(&out.workloads) - }; - if score.is_none() && !env.development_build && !software { - warnings.push( - "Some measurements were unavailable, so no score was calculated.".into(), - ); - } - result.method = scoring::gpu_method(); - result.stability = scoring::gpu_stability(&out.workloads, &warnings); - result.gpu_score = score; - result.sustained = None; - result.gpu = Some(GpuResult { - notes: gpu::notes(&info, out.timing_method), - adapter: info, - timing_method: out.timing_method, - workloads: out.workloads, - sustained: out.sustained, - }); - } - RunPlan::Disk { config, target } => { - let cfg = config - .clone() - .unwrap_or_else(|| DiskConfig::for_profile(self.profile)); - let parent = std::path::PathBuf::from(&target.directory); - disk::remove_stale(&parent); - let busy = self.other_disk_activity(); - let req = DiskRequest { - free_bytes: disk::free_space(&parent, target.free_bytes), - parent, - run_id: self.id.clone(), - config: cfg, - want_direct: true, - }; - let out = run_disk(&req, &self.cancel, reporter)?; - reporter.set_state(BenchmarkState::Finalizing, "Calculating results"); - let mut notes = out.io_mode_note.iter().cloned().collect::>(); - notes.extend(target.notes.iter().cloned()); - if out.io_mode == super::model::IoMode::Buffered { - warnings.push( - "Buffered I/O was used, so the operating system's cache is part of what was measured. No score is given." - .into(), - ); - } - if let Some(rate) = busy.filter(|r| *r > DISK_BUSY_WARN_BYTES_PER_SEC) { - warnings.push(format!( - "Other programs were using the drive ({:.0} MB/s) when the test started, which can lower the results.", - rate / 1e6 - )); - } - let score = if env.development_build { - None - } else { - scoring::disk_score(&out.phases, out.io_mode) - }; - result.method = scoring::disk_method(out.io_mode); - result.stability = scoring::disk_stability(&out.phases, &warnings); - result.disk_score = score; - result.disk = Some(DiskResult { - device: target.device.as_deref().and_then(disk::device::device_info), - target: (**target).clone(), - io_mode: out.io_mode, - io_mode_note: out.io_mode_note, - file_bytes: out.file_bytes, - bytes_written: out.bytes_written, - bytes_read: out.bytes_read, - phases: out.phases, - notes, - }); - } - RunPlan::Internet { config } => { - let cfg = config - .clone() - .unwrap_or_else(|| InternetConfig::for_profile(self.profile)); - let out = run_internet( - &*self.provider, - &cfg, - self.network_path(), - &self.cancel, - &self.internet_used, - reporter, - )?; - reporter.set_state(BenchmarkState::Finalizing, "Calculating results"); - let dl = out.download.as_ref().map(|t| t.samples.clone()); - let ul = out.upload.as_ref().map(|t| t.samples.clone()); - result.method = scoring::internet_method(); - result.stability = scoring::internet_stability( - dl.as_deref().unwrap_or(&[]), - ul.as_deref().unwrap_or(&[]), - &warnings, - ); - if out.path.vpn_suspected { - warnings - .push("A VPN or tunnel looks active; results include its overhead.".into()); - } - result.internet = Some(out); - } - } - result.warnings = warnings; - result.duration_ms = started.elapsed().as_millis() as u64; - Ok(result) - } -} - -/// Frequency and temperature come from the monitoring engine's latest tick, -/// so the benchmark adds no sensor reads of its own. -struct EngineHooks<'a>(&'a Engine); - -impl CpuHooks for EngineHooks<'_> { - fn pin_current_thread(&self, slot: usize) -> bool { - platform::pin_current_thread(slot) - } - - fn frequency_mhz(&self) -> Option { - self.0.latest()?.cpu.frequency_mhz.map(|f| f as f64) - } - - fn temperature_c(&self) -> Option { - cpu_temperature(&self.0.latest()?.temperatures) - } -} - -/// The CPU package temperature, only when a sensor label identifies it. A -/// generic `temp1` is never assumed to be the CPU. -pub fn cpu_temperature(readings: &[TemperatureReading]) -> Option { - const PREFERRED: [&str; 6] = [ - "coretemp Package", - "k10temp Tctl", - "k10temp Tdie", - "zenpower Tdie", - "x86_pkg_temp", - "cpu_thermal", - ]; - PREFERRED.iter().find_map(|prefix| { - readings - .iter() - .find(|r| r.label.starts_with(prefix)) - .map(|r| r.celsius) - }) -} - -/// Turns the runner's callbacks into rate-limited status updates. -struct Reporter { - shared: Arc, - sink: Arc, - engine: Arc, - started: Instant, - last_emit: Mutex>, -} - -impl Reporter { - fn new(ctx: &RunContext) -> Self { - Self { - shared: Arc::clone(&ctx.shared), - sink: Arc::clone(&ctx.sink), - engine: Arc::clone(&ctx.engine), - started: Instant::now(), - last_emit: Mutex::new(None), - } - } - - fn set_state(&self, state: BenchmarkState, stage: &str) { - self.update(|s| { - s.state = state; - s.stage = stage.to_owned(); - }); - } - - /// Applies `change`, then emits unless an update went out less than - /// [`MIN_EMIT_INTERVAL`] ago and the state did not change. - fn update(&self, change: impl FnOnce(&mut BenchmarkStatus)) { - let tick = self.engine.latest(); - let (snapshot, state_changed) = { - let mut status = lock(&self.shared.status); - // A finished or cancelled run's late callbacks must not revive it. - if !status.state.is_active() { - return; - } - let before = status.state; - change(&mut status); - status.elapsed_ms = self.started.elapsed().as_millis() as u64; - if let Some(t) = tick { - status.cpu_percent = Some(t.cpu.usage_percent); - status.frequency_mhz = t.cpu.frequency_mhz.map(|f| f as f64); - status.temperature_c = cpu_temperature(&t.temperatures); - } - (status.clone(), status.state != before) - }; - let mut last = lock(&self.last_emit); - if state_changed || last.is_none_or(|t| t.elapsed() >= MIN_EMIT_INTERVAL) { - *last = Some(Instant::now()); - drop(last); - self.sink.status(&snapshot); - } - } -} - -impl Observer for Reporter { - fn stage(&self, state: BenchmarkState, label: &str, fraction: f32) { - self.update(|s| { - s.state = state; - s.stage = label.to_owned(); - s.fraction = fraction.clamp(0.0, 1.0); - }); - } - - fn live(&self, reading: Option) { - self.update(|s| s.live = reading); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::benchmarks::model::{CpuScores, PowerSource}; - use crate::models::MonitoringState; - - #[derive(Default)] - struct Recorder { - statuses: Mutex>, - results: Mutex>, - } - impl ProgressSink for Recorder { - fn status(&self, s: &BenchmarkStatus) { - lock(&self.statuses).push(s.clone()); - } - fn finished(&self, r: &BenchmarkResult) { - lock(&self.results).push(r.clone()); - } - } - - fn coordinator() -> Coordinator { - let engine = Arc::new(Engine::start(MonitoringState::default()).expect("engine")); - Coordinator::new(engine, None).score_development_builds(true) - } - - fn wait_until_idle(c: &Coordinator) -> BenchmarkStatus { - let deadline = Instant::now() + Duration::from_secs(60); - loop { - let s = c.status(); - if !s.state.is_active() && lock(&c.shared.active).is_none() { - return s; - } - assert!(Instant::now() < deadline, "benchmark did not finish"); - std::thread::sleep(Duration::from_millis(10)); - } - } - - fn quick( - c: &Coordinator, - sink: &Arc, - cfg: CpuRunConfig, - ) -> AppResult { - let sink: Arc = sink.clone(); - c.start_with(BenchmarkKind::Cpu, BenchmarkProfile::Quick, cfg, sink) - } - - #[test] - fn a_finished_runs_guard_cannot_release_the_next_runs_lock() { - use std::sync::{Barrier, mpsc}; - struct Finishing { - entered: mpsc::Sender<()>, - release: Arc, - dropped: mpsc::Sender<()>, - } - impl ProgressSink for Finishing { - fn status(&self, _: &BenchmarkStatus) {} - fn finished(&self, _: &BenchmarkResult) { - self.entered.send(()).unwrap(); - self.release.wait(); - } - } - impl Drop for Finishing { - fn drop(&mut self) { - let _ = self.dropped.send(()); - } - } - let c = coordinator(); - let (entered, ready) = mpsc::channel(); - let (dropped, done) = mpsc::channel(); - let release = Arc::new(Barrier::new(2)); - c.start_with( - BenchmarkKind::Cpu, - BenchmarkProfile::Quick, - CpuRunConfig::test(), - Arc::new(Finishing { - entered, - release: Arc::clone(&release), - dropped, - }), - ) - .unwrap(); - ready.recv_timeout(Duration::from_secs(30)).unwrap(); - let mut long = CpuRunConfig::test(); - long.int_chunk_steps = u64::MAX / 4096; - let sink = Arc::new(Recorder::default()); - quick(&c, &sink, long).unwrap(); - release.wait(); - done.recv_timeout(Duration::from_secs(10)).unwrap(); - let third = quick(&c, &sink, CpuRunConfig::test()); - c.cancel(); - wait_until_idle(&c); - assert!(matches!(third, Err(AppError::BenchmarkAlreadyRunning))); - } - - #[test] - fn a_full_run_completes_scores_and_saves() { - let c = coordinator(); - let sink = Arc::new(Recorder::default()); - let started = quick(&c, &sink, CpuRunConfig::test()).expect("starts"); - assert_eq!(started.state, BenchmarkState::Preparing); - - let done = wait_until_idle(&c); - assert_eq!(done.state, BenchmarkState::Completed); - assert_eq!(done.fraction, 1.0); - - let results = lock(&sink.results); - assert_eq!(results.len(), 1); - let r = &results[0]; - assert_eq!(r.workloads.len(), 10); - assert!( - matches!(r.scores, Some(CpuScores { single_core, multi_core }) if single_core > 0 && multi_core > 0) - ); - assert_eq!(r.method.method_id, "cpu-method-v1"); - assert!(!r.environment.development_build); - assert_eq!(c.history().len(), 1); - assert_eq!(c.history()[0].id, r.id); - - // The state machine only moves forward through the expected states. - let states: Vec = lock(&sink.statuses).iter().map(|s| s.state).collect(); - let order = |s: &BenchmarkState| match s { - BenchmarkState::Preparing => 0, - BenchmarkState::Preflight => 1, - BenchmarkState::WarmingUp | BenchmarkState::Running => 2, - BenchmarkState::Finalizing => 3, - BenchmarkState::Completed => 4, - other => panic!("unexpected state {other:?}"), - }; - assert!( - states.windows(2).all(|p| order(&p[0]) <= order(&p[1])), - "{states:?}" - ); - assert_eq!(states.last(), Some(&BenchmarkState::Completed)); - } - - #[test] - fn only_one_benchmark_runs_at_a_time() { - let c = coordinator(); - let sink = Arc::new(Recorder::default()); - let mut long = CpuRunConfig::test(); - long.int_chunk_steps = u64::MAX / 4096; - quick(&c, &sink, long).expect("first starts"); - let second = quick(&c, &sink, CpuRunConfig::test()); - assert!(matches!(second, Err(AppError::BenchmarkAlreadyRunning))); - c.cancel(); - wait_until_idle(&c); - // The lock is free again. - quick(&c, &sink, CpuRunConfig::test()).expect("can start again"); - assert_eq!(wait_until_idle(&c).state, BenchmarkState::Completed); - } - - #[test] - fn cancelling_stops_the_run_saves_nothing_and_frees_the_lock() { - let c = coordinator(); - let sink = Arc::new(Recorder::default()); - let mut long = CpuRunConfig::test(); - long.int_chunk_steps = u64::MAX / 4096; - quick(&c, &sink, long).expect("starts"); - std::thread::sleep(Duration::from_millis(150)); - let t = Instant::now(); - c.cancel(); - let done = wait_until_idle(&c); - assert!(t.elapsed() < Duration::from_secs(3), "{:?}", t.elapsed()); - assert_eq!(done.state, BenchmarkState::Cancelled); - assert!( - done.message - .is_some_and(|m| m.contains("Nothing was saved")) - ); - assert!(c.history().is_empty()); - assert!(lock(&sink.results).is_empty()); - } - - #[test] - fn cancelling_while_idle_does_nothing() { - let c = coordinator(); - c.cancel(); - assert_eq!(c.status().state, BenchmarkState::Idle); - } - - #[test] - fn development_builds_are_flagged_unscored_and_not_saved() { - let engine = Arc::new(Engine::start(MonitoringState::default()).expect("engine")); - let c = Coordinator::new(engine, None); // default: treat debug as development - let sink = Arc::new(Recorder::default()); - quick(&c, &sink, CpuRunConfig::test()).expect("starts"); - let done = wait_until_idle(&c); - assert_eq!(done.state, BenchmarkState::Completed); - if cfg!(debug_assertions) { - let results = lock(&sink.results); - let r = &results[0]; - assert!(r.environment.development_build); - assert!(r.scores.is_none()); - assert!(r.warnings.iter().any(|w| w.contains("Development build"))); - assert!(c.history().is_empty()); - } - } - - #[test] - fn progress_updates_are_rate_limited() { - let c = coordinator(); - let sink = Arc::new(Recorder::default()); - let dyn_sink: Arc = sink.clone(); - { - let ctx_status = BenchmarkStatus { - state: BenchmarkState::Running, - ..BenchmarkStatus::idle() - }; - *lock(&c.shared.status) = ctx_status; - } - let reporter = Reporter { - shared: Arc::clone(&c.shared), - sink: dyn_sink, - engine: Arc::clone(&c.engine), - started: Instant::now(), - last_emit: Mutex::new(None), - }; - let t = Instant::now(); - for i in 0..5000 { - reporter.live(Some(LiveReading { - label: "x".into(), - value: f64::from(i), - unit: "u".into(), - })); - } - let emitted = lock(&sink.statuses).len(); - let max = 1 + (t.elapsed().as_millis() / MIN_EMIT_INTERVAL.as_millis()) as usize; - assert!( - emitted >= 1 && emitted <= max, - "{emitted} updates in {:?}", - t.elapsed() - ); - // The latest value is always the one stored, even when not emitted. - assert_eq!( - lock(&c.shared.status).live.as_ref().map(|l| l.value), - Some(4999.0) - ); - // A state change always goes out immediately. - reporter.stage(BenchmarkState::Finalizing, "Done soon", 0.9); - assert_eq!( - lock(&sink.statuses).last().map(|s| s.state), - Some(BenchmarkState::Finalizing) - ); - } - - #[test] - fn late_callbacks_do_not_revive_a_finished_run() { - let c = coordinator(); - let sink = Arc::new(Recorder::default()); - let dyn_sink: Arc = sink.clone(); - *lock(&c.shared.status) = BenchmarkStatus { - state: BenchmarkState::Cancelled, - ..BenchmarkStatus::idle() - }; - let reporter = Reporter { - shared: Arc::clone(&c.shared), - sink: dyn_sink, - engine: Arc::clone(&c.engine), - started: Instant::now(), - last_emit: Mutex::new(None), - }; - reporter.stage(BenchmarkState::Running, "late", 0.5); - assert_eq!(c.status().state, BenchmarkState::Cancelled); - assert!(lock(&sink.statuses).is_empty()); - } - - fn reading(label: &str, c: f32) -> TemperatureReading { - TemperatureReading { - label: label.into(), - celsius: c, - max_celsius: None, - critical_celsius: None, - } - } - - #[test] - fn cpu_temperature_needs_an_identifying_label() { - // `temp1` is never assumed to be the CPU. - assert_eq!( - cpu_temperature(&[ - reading("acpitz temp1", 27.0), - reading("nvme Composite", 40.0) - ]), - None - ); - assert_eq!( - cpu_temperature(&[ - reading("coretemp Core 0", 60.0), - reading("acpitz temp1", 27.0), - reading("coretemp Package id 0", 62.0), - ]), - Some(62.0) - ); - assert_eq!( - cpu_temperature(&[reading("k10temp Tctl", 55.0)]), - Some(55.0) - ); - assert_eq!(cpu_temperature(&[]), None); - // Environment type is available to tests that need it. - let _ = PowerSource::Unknown; - } - - // ------------------------------------------------------------- GPU - use crate::benchmarks::gpu::adapters; - use crate::benchmarks::gpu::runner::GpuExecutor; - use crate::benchmarks::gpu::runner::tests::Fake; - - struct FakeBackend { - adapters: Vec, - fail: Option, - } - - impl GpuBackend for FakeBackend { - fn adapters(&self) -> Result, BenchmarkError> { - Ok(self.adapters.clone()) - } - - fn open( - &self, - id: Option<&str>, - config_for: &dyn Fn(&GpuAdapterInfo) -> GpuRunConfig, - ) -> Result<(GpuAdapterInfo, Box, GpuRunConfig), BenchmarkError> { - if let Some(m) = &self.fail { - return Err(BenchmarkError::Failed(m.clone())); - } - let info = adapters::select(&self.adapters, id)?.clone(); - let cfg = config_for(&info); - Ok((info, Box::new(Fake::default()), cfg)) - } - } - - fn adapter(id: &str, t: GpuDeviceType) -> GpuAdapterInfo { - GpuAdapterInfo { - id: id.into(), - name: format!("Test {id}"), - vendor_id: 1, - device_id: 2, - device_type: t, - backend: "vulkan".into(), - driver: "test".into(), - driver_info: "1.0".into(), - supports_timestamps: true, - max_buffer_bytes: 1 << 30, - max_storage_binding_bytes: 1 << 27, - max_workgroups_per_dimension: 65_535, - recommended: false, - } - } - - fn gpu_coordinator(list: Vec, fail: Option<&str>) -> Coordinator { - coordinator().with_gpu_backend(Arc::new(FakeBackend { - adapters: list, - fail: fail.map(str::to_owned), - })) - } - - fn start_gpu( - c: &Coordinator, - sink: &Arc, - adapter: Option<&str>, - ) -> AppResult { - let sink: Arc = sink.clone(); - c.start_with( - BenchmarkKind::Gpu, - BenchmarkProfile::Quick, - RunPlan::Gpu { - config: Some(GpuRunConfig::test()), - adapter: adapter.map(str::to_owned), - }, - sink, - ) - } - - #[test] - fn a_gpu_run_completes_scores_saves_and_records_the_adapter() { - let c = gpu_coordinator( - vec![ - adapter("cpu-like", GpuDeviceType::Software), - adapter("igpu", GpuDeviceType::Integrated), - ], - None, - ); - let sink = Arc::new(Recorder::default()); - start_gpu(&c, &sink, None).expect("starts"); - let done = wait_until_idle(&c); - assert_eq!(done.state, BenchmarkState::Completed, "{:?}", done.message); - - let r = lock(&sink.results)[0].clone(); - assert_eq!(r.kind, BenchmarkKind::Gpu); - assert_eq!(r.method.method_id, "gpu-compute-v1"); - let g = r.gpu.as_ref().expect("gpu result"); - // The software renderer is never the default. - assert_eq!(g.adapter.id, "igpu"); - assert_eq!(g.workloads.len(), 3); - assert!(g.workloads.iter().all(|w| w.validated)); - assert!(r.gpu_score.is_some_and(|s| s > 0)); - assert!(r.scores.is_none() && r.workloads.is_empty()); - assert!(g.notes.iter().any(|n| n.contains("shared system memory"))); - assert!(r.id.starts_with("gpu-")); - // Saved, and survives a JSON round trip unchanged. - assert_eq!(c.history().len(), 1); - let json = serde_json::to_string(&r).unwrap(); - assert_eq!(serde_json::from_str::(&json).unwrap(), r); - } - - #[test] - fn a_software_renderer_is_run_on_request_but_never_scored() { - let c = gpu_coordinator(vec![adapter("llvmpipe", GpuDeviceType::Software)], None); - let sink = Arc::new(Recorder::default()); - start_gpu(&c, &sink, Some("llvmpipe")).expect("starts"); - assert_eq!(wait_until_idle(&c).state, BenchmarkState::Completed); - let r = lock(&sink.results)[0].clone(); - assert_eq!(r.gpu_score, None); - assert!( - r.gpu - .unwrap() - .notes - .iter() - .any(|n| n.contains("software renderer")) - ); - // Not scored, but also not a failure: no misleading "unavailable" warning. - assert!(!r.warnings.iter().any(|w| w.contains("unavailable"))); - } - - #[test] - fn no_adapter_fails_cleanly_and_frees_the_lock() { - let c = gpu_coordinator(vec![], None); - let sink = Arc::new(Recorder::default()); - start_gpu(&c, &sink, None).expect("starts"); - let done = wait_until_idle(&c); - assert_eq!(done.state, BenchmarkState::Failed); - assert!( - done.message - .is_some_and(|m| m.contains("No graphics adapter")) - ); - assert!(c.history().is_empty()); - // The app keeps working: a CPU benchmark can run right after. - quick(&c, &sink, CpuRunConfig::test()).expect("can start another"); - assert_eq!(wait_until_idle(&c).state, BenchmarkState::Completed); - } - - #[test] - fn a_driver_failure_is_reported_in_plain_words() { - let c = gpu_coordinator( - vec![adapter("igpu", GpuDeviceType::Integrated)], - Some("The graphics adapter could not be opened: driver said no"), - ); - let sink = Arc::new(Recorder::default()); - start_gpu(&c, &sink, None).expect("starts"); - let done = wait_until_idle(&c); - assert_eq!(done.state, BenchmarkState::Failed); - assert!( - done.message - .is_some_and(|m| m.contains("could not be opened")) - ); - } - - #[test] - fn a_choice_of_a_vanished_adapter_is_an_error_not_a_silent_fallback() { - let c = gpu_coordinator(vec![adapter("igpu", GpuDeviceType::Integrated)], None); - let sink = Arc::new(Recorder::default()); - start_gpu(&c, &sink, Some("gone")).expect("starts"); - let done = wait_until_idle(&c); - assert_eq!(done.state, BenchmarkState::Failed); - assert!( - done.message - .is_some_and(|m| m.contains("no longer available")) - ); - } - - #[test] - fn cpu_and_gpu_benchmarks_share_one_lock() { - let c = gpu_coordinator(vec![adapter("igpu", GpuDeviceType::Integrated)], None); - let sink = Arc::new(Recorder::default()); - let mut long = CpuRunConfig::test(); - long.int_chunk_steps = u64::MAX / 4096; - quick(&c, &sink, long).expect("cpu starts"); - assert!(matches!( - start_gpu(&c, &sink, None), - Err(AppError::BenchmarkAlreadyRunning) - )); - c.cancel(); - wait_until_idle(&c); - start_gpu(&c, &sink, None).expect("gpu can start after"); - assert_eq!(wait_until_idle(&c).state, BenchmarkState::Completed); - } - - #[test] - fn development_builds_do_not_score_gpu_runs_either() { - let engine = Arc::new(Engine::start(MonitoringState::default()).expect("engine")); - let c = Coordinator::new(engine, None).with_gpu_backend(Arc::new(FakeBackend { - adapters: vec![adapter("igpu", GpuDeviceType::Integrated)], - fail: None, - })); - let sink = Arc::new(Recorder::default()); - start_gpu(&c, &sink, None).expect("starts"); - assert_eq!(wait_until_idle(&c).state, BenchmarkState::Completed); - if cfg!(debug_assertions) { - let r = lock(&sink.results)[0].clone(); - assert!(r.environment.development_build && r.gpu_score.is_none()); - assert!(c.history().is_empty()); - } - } - - #[test] - fn the_adapter_list_comes_from_the_backend() { - let c = gpu_coordinator( - vec![ - adapter("a", GpuDeviceType::Discrete), - adapter("b", GpuDeviceType::Integrated), - ], - None, - ); - assert_eq!(c.gpu_adapters().unwrap().len(), 2); - } - - fn target(dir: &std::path::Path) -> DiskTargetInfo { - DiskTargetInfo { - id: "test".into(), - label: "Test folder".into(), - directory: dir.to_string_lossy().into_owned(), - mount_point: "/".into(), - file_system: "ext4".into(), - device: None, - free_bytes: 100 << 30, - total_bytes: 200 << 30, - recommended: true, - notes: vec![], - } - } - - // Unix only: the disk engine does not exist on other platforms yet. - #[cfg(unix)] - #[test] - fn a_disk_run_is_saved_with_its_target_mode_and_wear() { - let dir = tempfile::tempdir().unwrap(); - let t = target(dir.path()); - let c = coordinator().with_disk_targets(vec![t.clone()]); - let sink = Arc::new(Recorder::default()); - let sink2: Arc = sink.clone(); - c.start_with( - BenchmarkKind::Disk, - BenchmarkProfile::Quick, - RunPlan::Disk { - config: Some(DiskConfig::test()), - target: Box::new(t), - }, - sink2, - ) - .expect("starts"); - let done = wait_until_idle(&c); - assert_eq!(done.state, BenchmarkState::Completed, "{:?}", done.message); - let results = lock(&sink.results); - let r = &results[0]; - let d = r.disk.as_ref().expect("disk result"); - assert_eq!(d.phases.len(), 6); - assert_eq!(d.target.id, "test"); - assert!(d.bytes_written > 0 && d.bytes_read > 0); - assert!(r.method.method_id.starts_with("disk-")); - assert!(r.method.method_id.ends_with(match d.io_mode { - crate::benchmarks::model::IoMode::Direct => "direct-v1", - crate::benchmarks::model::IoMode::Buffered => "buffered-v1", - })); - assert!(r.id.starts_with("disk-")); - assert_eq!(c.history().len(), 1); - assert!( - !dir.path() - .join(crate::benchmarks::disk::file::DIR_NAME) - .exists(), - "no test file left behind" - ); - } - - #[test] - fn disk_and_internet_runs_refuse_unknown_targets_and_missing_consent() { - let c = coordinator().with_disk_targets(vec![]); - let sink: Arc = Arc::new(Recorder::default()); - let go = |options: StartOptions, kind| { - c.start(kind, BenchmarkProfile::Quick, options, sink.clone()) - }; - assert!(matches!( - go(StartOptions::default(), BenchmarkKind::Disk), - Err(AppError::InvalidArgument(_)) - )); - assert!(matches!( - go( - StartOptions { - disk_target: Some("/etc".into()), - ..Default::default() - }, - BenchmarkKind::Disk - ), - Err(AppError::InvalidArgument(_)) - )); - assert!(matches!( - go(StartOptions::default(), BenchmarkKind::Internet), - Err(AppError::InvalidArgument(_)) - )); - // Nothing started, so the lock is free. - assert!(lock(&c.shared.active).is_none()); - assert_eq!(c.status().state, BenchmarkState::Idle); - } - - #[test] - fn disk_plan_reports_the_write_volume_or_the_reason_it_is_blocked() { - let dir = tempfile::tempdir().unwrap(); - let t = target(dir.path()); - let c = coordinator().with_disk_targets(vec![t]); - let p = c.disk_plan("test", BenchmarkProfile::Standard).unwrap(); - assert!(p.blocked.is_none() || p.file_bytes == 0); - if p.blocked.is_none() { - assert!(p.estimated_bytes_written > p.file_bytes); - } - assert!(c.disk_plan("nope", BenchmarkProfile::Quick).is_err()); - } - - #[test] - fn an_internet_run_with_consent_is_saved_without_a_score() { - use crate::benchmarks::internet::provider::{Cloudflare, test_server}; - let server = test_server::start(test_server::Mode::Normal, 2_500_000); - let c = coordinator().with_internet_provider(Arc::new(Cloudflare::with_base( - &server.base, - Duration::from_secs(5), - ))); - let sink = Arc::new(Recorder::default()); - let sink2: Arc = sink.clone(); - c.start_with( - BenchmarkKind::Internet, - BenchmarkProfile::Quick, - RunPlan::Internet { - config: Some(InternetConfig::test()), - }, - sink2, - ) - .expect("starts"); - let done = wait_until_idle(&c); - assert_eq!(done.state, BenchmarkState::Completed, "{:?}", done.message); - let results = lock(&sink.results); - let r = &results[0]; - let n = r.internet.as_ref().expect("internet result"); - assert!(n.download.as_ref().unwrap().median_mbps > 10.0); - assert!(r.scores.is_none() && r.disk_score.is_none() && r.gpu_score.is_none()); - assert_eq!(r.method.method_id, "internet-cloudflare-v1"); - assert!(r.id.starts_with("net-")); - assert_eq!(c.history().len(), 1); - } - - #[test] - fn internet_tests_have_a_cooldown_and_a_session_data_budget() { - use crate::benchmarks::internet::provider::{Cloudflare, test_server}; - let server = test_server::start(test_server::Mode::Normal, 0); - let sink: Arc = Arc::new(Recorder::default()); - let provider = Arc::new(Cloudflare::with_base(&server.base, Duration::from_secs(5))); - let go = |c: &Coordinator| { - c.start( - BenchmarkKind::Internet, - BenchmarkProfile::Quick, - StartOptions { - internet_consent: true, - ..Default::default() - }, - sink.clone(), - ) - }; - - // Cooldown: a second start right after the first is refused. - let c = coordinator() - .with_internet_provider(provider.clone()) - .with_internet_limits(Duration::from_secs(300), u64::MAX); - assert!(go(&c).is_ok()); - c.cancel(); - wait_until_idle(&c); - let e = go(&c).expect_err("cooling down"); - assert!(e.to_string().contains("more seconds"), "{e}"); - - // Budget: a plan that does not fit what is left is refused up front. - let c = coordinator() - .with_internet_provider(provider) - .with_internet_limits(Duration::ZERO, 100_000_000); - let e = go(&c).expect_err("over budget"); - assert!(e.to_string().contains("allowance"), "{e}"); - assert_eq!(c.status().state, BenchmarkState::Idle); - } -} diff --git a/src-tauri/core/src/benchmarks/coordinator/mod.rs b/src-tauri/core/src/benchmarks/coordinator/mod.rs new file mode 100644 index 0000000..f13ea1e --- /dev/null +++ b/src-tauri/core/src/benchmarks/coordinator/mod.rs @@ -0,0 +1,858 @@ +//! Runs one benchmark at a time and owns everything around it: the lock, +//! the state machine, rate-limited progress, cancellation, saving, and +//! cleanup on every exit path (success, failure, cancel, panic). + +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::path::Path; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use super::cancel::CancelToken; +use super::cpu::runner::{CpuHooks, CpuRunConfig, Observer, run_cpu}; +use super::disk::engine::run_disk; +use super::disk::plan::{self as disk_plan_rules, DiskConfig}; +use super::disk::{self, DiskRequest}; +use super::gpu::runner::{GpuRunConfig, run_gpu}; +use super::gpu::wgpu_backend::WgpuBackend; +use super::gpu::{self, GpuBackend}; +use super::history::HistoryStore; +use super::internet::provider::{Cloudflare, Provider}; +use super::internet::{self, InternetConfig, run_internet}; +use super::model::{ + BenchmarkKind, BenchmarkProfile, BenchmarkResult, BenchmarkState, BenchmarkStatus, DiskPlan, + DiskResult, DiskTargetInfo, GpuAdapterInfo, GpuDeviceType, GpuResult, InternetPlan, + LiveReading, NetworkPath, +}; +use super::{BenchmarkError, environment, scoring}; +use crate::errors::{AppError, AppResult}; +use crate::models::TemperatureReading; +use crate::monitoring::{Engine, unix_millis}; +use crate::platform; +use crate::sync_util::lock; + +/// Least time between two internet tests. The public speed-test endpoints +/// limit repeated large transfers per address (HTTP 429 for about an hour), so +/// back-to-back runs would only fail. +const INTERNET_COOLDOWN: Duration = Duration::from_secs(120); + +/// Most test data one app session may move in total, both directions. +const INTERNET_SESSION_BUDGET_BYTES: u64 = 1_000_000_000; + +/// Other drive traffic above this when a disk test starts earns a warning. +const DISK_BUSY_WARN_BYTES_PER_SEC: f64 = 20e6; + +/// The UI never needs more than this many progress updates per second. +const MIN_EMIT_INTERVAL: Duration = Duration::from_millis(100); + +/// Receives progress. Called from the benchmark thread; must not block. +pub trait ProgressSink: Send + Sync { + fn status(&self, status: &BenchmarkStatus); + fn finished(&self, result: &BenchmarkResult); +} + +struct ActiveRun { + cancel: CancelToken, +} + +struct Shared { + status: Mutex, + /// `Some` while a run owns the benchmark lock. + active: Mutex>, + counter: AtomicU64, +} + +pub struct Coordinator { + engine: Arc, + history: Arc, + shared: Arc, + gpu: Arc, + provider: Arc, + /// When the last internet test started, for the cooldown. + internet_last: Arc>>, + /// Bytes moved by internet tests since the app started. + internet_used: Arc, + internet_cooldown: Duration, + internet_budget: u64, + /// Tests replace the list built from the mounted filesystems. + targets_override: Option>, + /// Tests run unoptimised; this lets them exercise scoring and saving. + score_development_builds: bool, +} + +/// What the user chose for a run. Everything is an id or a flag; the backend +/// resolves ids to paths and addresses. +#[derive(Debug, Clone, Default)] +pub struct StartOptions { + /// Graphics adapter id (GPU runs). + pub adapter: Option, + /// Disk location id from [`Coordinator::disk_targets`] (disk runs). + pub disk_target: Option, + /// The user agreed to send and receive test data (internet runs). + pub internet_consent: bool, +} + +/// What a run executes. The user chooses only the kind, profile and adapter; +/// explicit configurations exist for tests. +pub enum RunPlan { + Cpu(CpuRunConfig), + Gpu { + /// `None` means "build it from the profile once the adapter is known". + config: Option, + adapter: Option, + }, + Disk { + config: Option, + target: Box, + }, + Internet { + config: Option, + }, +} + +impl From for RunPlan { + fn from(cfg: CpuRunConfig) -> Self { + Self::Cpu(cfg) + } +} + +impl Coordinator { + pub fn new(engine: Arc, data_dir: Option<&Path>) -> Self { + Self { + engine, + history: Arc::new(HistoryStore::load(data_dir)), + shared: Arc::new(Shared { + status: Mutex::new(BenchmarkStatus::idle()), + active: Mutex::new(None), + counter: AtomicU64::new(0), + }), + gpu: Arc::new(WgpuBackend::new()), + provider: Arc::new(Cloudflare::new()), + internet_last: Arc::new(Mutex::new(None)), + internet_used: Arc::new(AtomicU64::new(0)), + internet_cooldown: INTERNET_COOLDOWN, + internet_budget: INTERNET_SESSION_BUDGET_BYTES, + targets_override: None, + score_development_builds: false, + } + } + + /// Replaces the internet provider (tests use a local server). + #[doc(hidden)] + pub fn with_internet_provider(mut self, provider: Arc) -> Self { + self.provider = provider; + self + } + + /// Replaces the internet cooldown and session budget (tests). + #[doc(hidden)] + pub fn with_internet_limits(mut self, cooldown: Duration, budget_bytes: u64) -> Self { + self.internet_cooldown = cooldown; + self.internet_budget = budget_bytes; + self + } + + /// Replaces the disk locations (tests use temporary folders). + #[doc(hidden)] + pub fn with_disk_targets(mut self, targets: Vec) -> Self { + self.targets_override = Some(targets); + self + } + + /// Locations the disk benchmark may use, from the mounted filesystems. + pub fn disk_targets(&self) -> Vec { + if let Some(t) = &self.targets_override { + return t.clone(); + } + let Some(tick) = self.engine.latest() else { + return Vec::new(); + }; + let mounts: Vec = tick + .disks + .iter() + .map(|d| disk::targets::MountInfo { + mount_point: d.mount_point.clone(), + file_system: d.file_system.clone(), + device: d.name.clone(), + total_bytes: d.total_bytes, + free_bytes: d.available_bytes, + read_only: d.read_only, + }) + .collect(); + let home = std::env::var_os("HOME").map(std::path::PathBuf::from); + disk::targets::build_targets(&mounts, home.as_deref(), &std::env::temp_dir(), &|p| { + disk::writable(p) + }) + } + + /// What a disk run on `target_id` would write, or why it cannot happen. + pub fn disk_plan(&self, target_id: &str, profile: BenchmarkProfile) -> AppResult { + let target = self.find_target(target_id)?; + let cfg = DiskConfig::for_profile(profile); + let free = disk::free_space(std::path::Path::new(&target.directory), target.free_bytes); + let planned = disk_plan_rules::plan_file_bytes(cfg.file_bytes, cfg.min_file_bytes, free); + let file_bytes = planned.clone().unwrap_or(0); + Ok(DiskPlan { + target_id: target.id, + directory: target.directory, + file_bytes, + estimated_bytes_written: disk_plan_rules::estimated_bytes_written(&cfg, file_bytes), + estimated_seconds: disk_plan_rules::estimated_seconds(profile), + blocked: planned.err(), + }) + } + + /// What an internet run would send and receive, and to whom. + pub fn internet_plan(&self, profile: BenchmarkProfile) -> InternetPlan { + internet::plan(&*self.provider, profile) + } + + fn find_target(&self, id: &str) -> AppResult { + self.disk_targets() + .into_iter() + .find(|t| t.id == id) + .ok_or_else(|| AppError::InvalidArgument("That location is not available.".into())) + } + + /// Replaces the GPU backend (tests use a fake, so CI needs no GPU). + #[doc(hidden)] + pub fn with_gpu_backend(mut self, backend: Arc) -> Self { + self.gpu = backend; + self + } + + /// Graphics adapters a GPU benchmark could run on. + pub fn gpu_adapters(&self) -> AppResult> { + self.gpu.adapters().map_err(AppError::from) + } + + #[doc(hidden)] + pub fn score_development_builds(mut self, yes: bool) -> Self { + self.score_development_builds = yes; + self + } + + pub fn status(&self) -> BenchmarkStatus { + lock(&self.shared.status).clone() + } + + pub fn history(&self) -> Vec { + self.history.list() + } + + pub fn delete_result(&self, id: &str) -> AppResult<()> { + self.history.delete(id) + } + + /// Asks the running benchmark to stop. No-op when idle. + pub fn cancel(&self) { + if let Some(run) = lock(&self.shared.active).as_ref() { + run.cancel.cancel(); + } + } + + /// `options` carries ids and flags only; options that do not apply to the + /// kind are ignored. + pub fn start( + &self, + kind: BenchmarkKind, + profile: BenchmarkProfile, + options: StartOptions, + sink: Arc, + ) -> AppResult { + let plan = match kind { + BenchmarkKind::Cpu => { + let logical = self.engine.static_info().logical_core_count as usize; + RunPlan::Cpu(CpuRunConfig::for_profile(profile, logical)) + } + BenchmarkKind::Gpu => RunPlan::Gpu { + config: None, + adapter: options.adapter, + }, + BenchmarkKind::Disk => { + let id = options + .disk_target + .ok_or_else(|| AppError::InvalidArgument("Choose a location first.".into()))?; + RunPlan::Disk { + config: None, + target: Box::new(self.find_target(&id)?), + } + } + BenchmarkKind::Internet => { + // Checked here, not only in the UI: nothing leaves the + // machine unless the caller says the user agreed. + if !options.internet_consent { + return Err(AppError::InvalidArgument( + "Agree to send and receive test data before running the internet test." + .into(), + )); + } + self.check_internet_allowed(profile)?; + RunPlan::Internet { config: None } + } + }; + let started = self.start_with(kind, profile, plan, sink)?; + if kind == BenchmarkKind::Internet { + *lock(&self.internet_last) = Some(Instant::now()); + } + Ok(started) + } + + /// Cooldown and session data budget for internet tests. + fn check_internet_allowed(&self, profile: BenchmarkProfile) -> AppResult<()> { + if let Some(last) = *lock(&self.internet_last) { + let waited = last.elapsed(); + if waited < self.internet_cooldown { + let left = (self.internet_cooldown - waited).as_secs() + 1; + return Err(AppError::InvalidArgument(format!( + "Wait about {left} more seconds before running the internet test again. The test server limits repeated transfers." + ))); + } + } + let plan = self.internet_plan(profile); + let used = self.internet_used.load(Ordering::Relaxed); + if used + plan.max_download_bytes + plan.max_upload_bytes > self.internet_budget { + return Err(AppError::InvalidArgument(format!( + "This session has used {} MB of its {} MB internet test allowance. Restart the app to run it again.", + used / 1_000_000, + self.internet_budget / 1_000_000 + ))); + } + Ok(()) + } + + /// [`start`](Self::start) with an explicit plan (tests use tiny ones). + pub fn start_with( + &self, + kind: BenchmarkKind, + profile: BenchmarkProfile, + plan: impl Into, + sink: Arc, + ) -> AppResult { + let plan = plan.into(); + let cancel = CancelToken::new(); + let prefix = match kind { + BenchmarkKind::Cpu => "cpu", + BenchmarkKind::Gpu => "gpu", + BenchmarkKind::Disk => "disk", + BenchmarkKind::Internet => "net", + }; + let id = format!( + "{prefix}-{}-{}", + unix_millis(), + self.shared.counter.fetch_add(1, Ordering::Relaxed) + ); + let initial = BenchmarkStatus { + state: BenchmarkState::Preparing, + run_id: Some(id.clone()), + kind: Some(kind), + profile: Some(profile), + stage: "Starting".into(), + ..BenchmarkStatus::idle() + }; + { + // Claim the lock and publish the first status together. + let mut active = lock(&self.shared.active); + if active.is_some() { + return Err(AppError::BenchmarkAlreadyRunning); + } + *active = Some(ActiveRun { + cancel: cancel.clone(), + }); + *lock(&self.shared.status) = initial.clone(); + } + sink.status(&initial); + self.engine.record_activity( + crate::monitoring::activity::Pending::new( + crate::models::ActivityKind::BenchmarkStarted, + ) + .detail(format!("{kind:?} benchmark, {profile:?} profile")), + ); + + let ctx = RunContext { + engine: Arc::clone(&self.engine), + history: Arc::clone(&self.history), + shared: Arc::clone(&self.shared), + gpu: Arc::clone(&self.gpu), + provider: Arc::clone(&self.provider), + internet_used: Arc::clone(&self.internet_used), + sink, + score_development_builds: self.score_development_builds, + id, + kind, + profile, + plan, + cancel, + }; + let shared = Arc::clone(&self.shared); + std::thread::Builder::new() + .name("benchmark".into()) + .spawn(move || ctx.run()) + .map_err(|e| { + *lock(&shared.active) = None; + *lock(&shared.status) = BenchmarkStatus::idle(); + AppError::BenchmarkFailed(format!("could not start the worker thread: {e}")) + })?; + Ok(initial) + } +} + +struct RunContext { + engine: Arc, + history: Arc, + shared: Arc, + gpu: Arc, + provider: Arc, + internet_used: Arc, + sink: Arc, + score_development_builds: bool, + id: String, + kind: BenchmarkKind, + profile: BenchmarkProfile, + plan: RunPlan, + cancel: CancelToken, +} + +/// Clears the benchmark lock on any exit, including a panic that escapes +/// the normal finalisation. +struct LockGuard(Option>); +impl Drop for LockGuard { + fn drop(&mut self) { + if let Some(shared) = &self.0 { + *lock(&shared.active) = None; + } + } +} + +impl RunContext { + fn run(self) { + let mut guard = LockGuard(Some(Arc::clone(&self.shared))); + let reporter = Reporter::new(&self); + let outcome = catch_unwind(AssertUnwindSafe(|| self.execute(&reporter))); + let (state, message, result) = match outcome { + Ok(Ok(result)) => { + let saved = if result.environment.development_build { + Ok(()) + } else { + self.history.add(result.clone()) + }; + let message = saved + .err() + .map(|e| format!("The result could not be saved: {e}")); + (BenchmarkState::Completed, message, Some(result)) + } + Ok(Err(BenchmarkError::Cancelled)) => ( + BenchmarkState::Cancelled, + Some("Cancelled. Nothing was saved.".to_owned()), + None, + ), + Ok(Err(e)) => (BenchmarkState::Failed, Some(e.to_string()), None), + Err(_) => ( + BenchmarkState::Failed, + Some("The benchmark stopped unexpectedly.".to_owned()), + None, + ), + }; + tracing::info!(id = %self.id, ?state, "benchmark finished"); + + // Publish the final status and release the lock as one step, so a + // new run can never be overwritten by the old run's last update. + let final_status = { + let mut active = lock(&self.shared.active); + let mut status = lock(&self.shared.status); + status.state = state; + status.message = message; + if state == BenchmarkState::Completed { + status.fraction = 1.0; + status.stage = "Done".into(); + } + status.live = None; + let snapshot = status.clone(); + *active = None; + // A subsequent run may start as soon as this mutex is released. + // The old run's panic guard must never clear the new run's lock. + guard.0 = None; + snapshot + }; + if let Some(result) = &result { + self.sink.finished(result); + } + self.sink.status(&final_status); + // Also recorded when cancelled or failed; the detail says which. + self.engine.record_activity( + crate::monitoring::activity::Pending::new( + crate::models::ActivityKind::BenchmarkCompleted, + ) + .detail(format!("{:?} benchmark: {state:?}", self.kind)), + ); + } + + /// Combined read and write rate of all drives right now, in bytes/s. + fn other_disk_activity(&self) -> Option { + let tick = self.engine.latest()?; + Some( + tick.disks + .iter() + .map(|d| d.read_bytes_per_sec.unwrap_or(0.0) + d.write_bytes_per_sec.unwrap_or(0.0)) + .sum(), + ) + } + + /// The interface carrying internet traffic and whether a tunnel is up. + fn network_path(&self) -> NetworkPath { + let nets = self + .engine + .latest() + .map(|t| t.networks.clone()) + .unwrap_or_default(); + let kind_of = |name: &str| { + nets.iter() + .find(|n| n.name == name) + .map_or(crate::models::InterfaceKind::Unknown, |n| n.kind) + }; + let others: Vec<_> = nets.iter().map(|n| (n.name.clone(), n.kind)).collect(); + internet::route::describe_path(internet::route::read_default_route(), &kind_of, &others) + } + + fn execute(&self, reporter: &Reporter) -> Result { + let started_ms = unix_millis(); + let started = Instant::now(); + reporter.set_state( + BenchmarkState::Preflight, + match self.plan { + RunPlan::Cpu(_) => "Checking the system", + RunPlan::Gpu { .. } => "Checking graphics adapters", + RunPlan::Disk { .. } => "Checking the drive", + RunPlan::Internet { .. } => "Checking the connection", + }, + ); + + let background = self.engine.latest().map(|t| t.cpu.usage_percent); + let mut env = environment::collect(self.engine.static_info(), background); + if self.score_development_builds { + env.development_build = false; + } + let mut warnings = environment::warnings(&env); + if self.cancel.is_cancelled() { + return Err(BenchmarkError::Cancelled); + } + + let mut result = BenchmarkResult { + id: self.id.clone(), + kind: self.kind, + profile: self.profile, + started_at_ms: started_ms, + duration_ms: 0, + method: scoring::cpu_method(), + environment: env, + workloads: Vec::new(), + sustained: None, + scores: None, + gpu: None, + gpu_score: None, + disk: None, + disk_score: None, + internet: None, + warnings: Vec::new(), + stability: super::model::Stability::High, + outlier_method: "1.5×IQR; flagged, none removed".into(), + affinity: "not pinned".into(), + }; + + match &self.plan { + RunPlan::Cpu(cfg) => self.execute_cpu(cfg, reporter, &mut warnings, &mut result)?, + RunPlan::Gpu { config, adapter } => self.execute_gpu( + config.as_ref(), + adapter.as_deref(), + reporter, + &mut warnings, + &mut result, + )?, + RunPlan::Disk { config, target } => self.execute_disk( + config.as_ref(), + target, + reporter, + &mut warnings, + &mut result, + )?, + RunPlan::Internet { config } => { + self.execute_internet(config.as_ref(), reporter, &mut warnings, &mut result)? + } + } + result.warnings = warnings; + result.duration_ms = started.elapsed().as_millis() as u64; + Ok(result) + } + + fn execute_cpu( + &self, + cfg: &CpuRunConfig, + reporter: &Reporter, + warnings: &mut Vec, + result: &mut BenchmarkResult, + ) -> Result<(), BenchmarkError> { + let development_build = result.environment.development_build; + let hooks = EngineHooks(&self.engine); + let out = run_cpu(cfg, &hooks, &self.cancel, reporter)?; + reporter.set_state(BenchmarkState::Finalizing, "Calculating results"); + let scores = if development_build { + None + } else { + scoring::cpu_scores(&out.workloads) + }; + if scores.is_none() && !development_build { + warnings.push("Some measurements were unavailable, so no score was calculated.".into()); + } + result.stability = scoring::stability(&out.workloads, warnings); + result.workloads = out.workloads; + result.sustained = out.sustained; + result.scores = scores; + result.affinity = out.affinity; + Ok(()) + } + + fn execute_gpu( + &self, + config: Option<&GpuRunConfig>, + adapter: Option<&str>, + reporter: &Reporter, + warnings: &mut Vec, + result: &mut BenchmarkResult, + ) -> Result<(), BenchmarkError> { + let development_build = result.environment.development_build; + let profile = self.profile; + let (info, mut exec, cfg) = self.gpu.open(adapter, &|a| { + config.cloned().unwrap_or_else(|| { + GpuRunConfig::for_profile(profile, a.device_type == GpuDeviceType::Discrete) + }) + })?; + let out = run_gpu(&mut *exec, &cfg, &self.cancel, reporter)?; + // Release the device before the (possibly slow) bookkeeping. + drop(exec); + reporter.set_state(BenchmarkState::Finalizing, "Calculating results"); + let software = info.device_type == GpuDeviceType::Software; + let score = if development_build || software { + None + } else { + scoring::gpu_score(&out.workloads) + }; + if score.is_none() && !development_build && !software { + warnings.push("Some measurements were unavailable, so no score was calculated.".into()); + } + result.method = scoring::gpu_method(); + result.stability = scoring::gpu_stability(&out.workloads, warnings); + result.gpu_score = score; + result.sustained = None; + result.gpu = Some(GpuResult { + notes: gpu::notes(&info, out.timing_method), + adapter: info, + timing_method: out.timing_method, + workloads: out.workloads, + sustained: out.sustained, + }); + Ok(()) + } + + fn execute_disk( + &self, + config: Option<&DiskConfig>, + target: &DiskTargetInfo, + reporter: &Reporter, + warnings: &mut Vec, + result: &mut BenchmarkResult, + ) -> Result<(), BenchmarkError> { + let cfg = config + .cloned() + .unwrap_or_else(|| DiskConfig::for_profile(self.profile)); + let parent = std::path::PathBuf::from(&target.directory); + disk::remove_stale(&parent); + let busy = self.other_disk_activity(); + let req = DiskRequest { + free_bytes: disk::free_space(&parent, target.free_bytes), + parent, + run_id: self.id.clone(), + config: cfg, + want_direct: true, + }; + let out = run_disk(&req, &self.cancel, reporter)?; + reporter.set_state(BenchmarkState::Finalizing, "Calculating results"); + let mut notes = out.io_mode_note.iter().cloned().collect::>(); + notes.extend(target.notes.iter().cloned()); + if out.io_mode == super::model::IoMode::Buffered { + warnings.push( + "Buffered I/O was used, so the operating system's cache is part of what was measured. No score is given." + .into(), + ); + } + if let Some(rate) = busy.filter(|r| *r > DISK_BUSY_WARN_BYTES_PER_SEC) { + warnings.push(format!( + "Other programs were using the drive ({:.0} MB/s) when the test started, which can lower the results.", + rate / 1e6 + )); + } + let score = if result.environment.development_build { + None + } else { + scoring::disk_score(&out.phases, out.io_mode) + }; + result.method = scoring::disk_method(out.io_mode); + result.stability = scoring::disk_stability(&out.phases, warnings); + result.disk_score = score; + result.disk = Some(DiskResult { + device: target.device.as_deref().and_then(disk::device::device_info), + target: target.clone(), + io_mode: out.io_mode, + io_mode_note: out.io_mode_note, + file_bytes: out.file_bytes, + bytes_written: out.bytes_written, + bytes_read: out.bytes_read, + phases: out.phases, + notes, + }); + Ok(()) + } + + fn execute_internet( + &self, + config: Option<&InternetConfig>, + reporter: &Reporter, + warnings: &mut Vec, + result: &mut BenchmarkResult, + ) -> Result<(), BenchmarkError> { + let cfg = config + .cloned() + .unwrap_or_else(|| InternetConfig::for_profile(self.profile)); + let out = run_internet( + &*self.provider, + &cfg, + self.network_path(), + &self.cancel, + &self.internet_used, + reporter, + )?; + reporter.set_state(BenchmarkState::Finalizing, "Calculating results"); + let dl = out.download.as_ref().map(|t| t.samples.clone()); + let ul = out.upload.as_ref().map(|t| t.samples.clone()); + result.method = scoring::internet_method(); + result.stability = scoring::internet_stability( + dl.as_deref().unwrap_or(&[]), + ul.as_deref().unwrap_or(&[]), + warnings, + ); + if out.path.vpn_suspected { + warnings.push("A VPN or tunnel looks active; results include its overhead.".into()); + } + result.internet = Some(out); + Ok(()) + } +} + +/// Frequency and temperature come from the monitoring engine's latest tick, +/// so the benchmark adds no sensor reads of its own. +struct EngineHooks<'a>(&'a Engine); + +impl CpuHooks for EngineHooks<'_> { + fn pin_current_thread(&self, slot: usize) -> bool { + platform::pin_current_thread(slot) + } + + fn frequency_mhz(&self) -> Option { + self.0.latest()?.cpu.frequency_mhz.map(|f| f as f64) + } + + fn temperature_c(&self) -> Option { + cpu_temperature(&self.0.latest()?.temperatures) + } +} + +/// The CPU package temperature, only when a sensor label identifies it. A +/// generic `temp1` is never assumed to be the CPU. +pub fn cpu_temperature(readings: &[TemperatureReading]) -> Option { + const PREFERRED: [&str; 6] = [ + "coretemp Package", + "k10temp Tctl", + "k10temp Tdie", + "zenpower Tdie", + "x86_pkg_temp", + "cpu_thermal", + ]; + PREFERRED.iter().find_map(|prefix| { + readings + .iter() + .find(|r| r.label.starts_with(prefix)) + .map(|r| r.celsius) + }) +} + +/// Turns the runner's callbacks into rate-limited status updates. +struct Reporter { + shared: Arc, + sink: Arc, + engine: Arc, + started: Instant, + last_emit: Mutex>, +} + +impl Reporter { + fn new(ctx: &RunContext) -> Self { + Self { + shared: Arc::clone(&ctx.shared), + sink: Arc::clone(&ctx.sink), + engine: Arc::clone(&ctx.engine), + started: Instant::now(), + last_emit: Mutex::new(None), + } + } + + fn set_state(&self, state: BenchmarkState, stage: &str) { + self.update(|s| { + s.state = state; + s.stage = stage.to_owned(); + }); + } + + /// Applies `change`, then emits unless an update went out less than + /// [`MIN_EMIT_INTERVAL`] ago and the state did not change. + fn update(&self, change: impl FnOnce(&mut BenchmarkStatus)) { + let tick = self.engine.latest(); + let (snapshot, state_changed) = { + let mut status = lock(&self.shared.status); + // A finished or cancelled run's late callbacks must not revive it. + if !status.state.is_active() { + return; + } + let before = status.state; + change(&mut status); + status.elapsed_ms = self.started.elapsed().as_millis() as u64; + if let Some(t) = tick { + status.cpu_percent = Some(t.cpu.usage_percent); + status.frequency_mhz = t.cpu.frequency_mhz.map(|f| f as f64); + status.temperature_c = cpu_temperature(&t.temperatures); + } + (status.clone(), status.state != before) + }; + let mut last = lock(&self.last_emit); + if state_changed || last.is_none_or(|t| t.elapsed() >= MIN_EMIT_INTERVAL) { + *last = Some(Instant::now()); + drop(last); + self.sink.status(&snapshot); + } + } +} + +impl Observer for Reporter { + fn stage(&self, state: BenchmarkState, label: &str, fraction: f32) { + self.update(|s| { + s.state = state; + s.stage = label.to_owned(); + s.fraction = fraction.clamp(0.0, 1.0); + }); + } + + fn live(&self, reading: Option) { + self.update(|s| s.live = reading); + } +} + +#[cfg(test)] +mod tests; diff --git a/src-tauri/core/src/benchmarks/coordinator/tests.rs b/src-tauri/core/src/benchmarks/coordinator/tests.rs new file mode 100644 index 0000000..2178aac --- /dev/null +++ b/src-tauri/core/src/benchmarks/coordinator/tests.rs @@ -0,0 +1,674 @@ +use super::*; +use crate::benchmarks::model::{CpuScores, PowerSource}; +use crate::models::MonitoringState; + +#[derive(Default)] +struct Recorder { + statuses: Mutex>, + results: Mutex>, +} +impl ProgressSink for Recorder { + fn status(&self, s: &BenchmarkStatus) { + lock(&self.statuses).push(s.clone()); + } + fn finished(&self, r: &BenchmarkResult) { + lock(&self.results).push(r.clone()); + } +} + +fn coordinator() -> Coordinator { + let engine = Arc::new(Engine::start(MonitoringState::default()).expect("engine")); + Coordinator::new(engine, None).score_development_builds(true) +} + +fn wait_until_idle(c: &Coordinator) -> BenchmarkStatus { + let deadline = Instant::now() + Duration::from_secs(60); + loop { + let s = c.status(); + if !s.state.is_active() && lock(&c.shared.active).is_none() { + return s; + } + assert!(Instant::now() < deadline, "benchmark did not finish"); + std::thread::sleep(Duration::from_millis(10)); + } +} + +fn quick(c: &Coordinator, sink: &Arc, cfg: CpuRunConfig) -> AppResult { + let sink: Arc = sink.clone(); + c.start_with(BenchmarkKind::Cpu, BenchmarkProfile::Quick, cfg, sink) +} + +#[test] +fn a_finished_runs_guard_cannot_release_the_next_runs_lock() { + use std::sync::{Barrier, mpsc}; + struct Finishing { + entered: mpsc::Sender<()>, + release: Arc, + dropped: mpsc::Sender<()>, + } + impl ProgressSink for Finishing { + fn status(&self, _: &BenchmarkStatus) {} + fn finished(&self, _: &BenchmarkResult) { + self.entered.send(()).unwrap(); + self.release.wait(); + } + } + impl Drop for Finishing { + fn drop(&mut self) { + let _ = self.dropped.send(()); + } + } + let c = coordinator(); + let (entered, ready) = mpsc::channel(); + let (dropped, done) = mpsc::channel(); + let release = Arc::new(Barrier::new(2)); + c.start_with( + BenchmarkKind::Cpu, + BenchmarkProfile::Quick, + CpuRunConfig::test(), + Arc::new(Finishing { + entered, + release: Arc::clone(&release), + dropped, + }), + ) + .unwrap(); + ready.recv_timeout(Duration::from_secs(30)).unwrap(); + let mut long = CpuRunConfig::test(); + long.int_chunk_steps = u64::MAX / 4096; + let sink = Arc::new(Recorder::default()); + quick(&c, &sink, long).unwrap(); + release.wait(); + done.recv_timeout(Duration::from_secs(10)).unwrap(); + let third = quick(&c, &sink, CpuRunConfig::test()); + c.cancel(); + wait_until_idle(&c); + assert!(matches!(third, Err(AppError::BenchmarkAlreadyRunning))); +} + +#[test] +fn a_full_run_completes_scores_and_saves() { + let c = coordinator(); + let sink = Arc::new(Recorder::default()); + let started = quick(&c, &sink, CpuRunConfig::test()).expect("starts"); + assert_eq!(started.state, BenchmarkState::Preparing); + + let done = wait_until_idle(&c); + assert_eq!(done.state, BenchmarkState::Completed); + assert_eq!(done.fraction, 1.0); + + let results = lock(&sink.results); + assert_eq!(results.len(), 1); + let r = &results[0]; + assert_eq!(r.workloads.len(), 10); + assert!( + matches!(r.scores, Some(CpuScores { single_core, multi_core }) if single_core > 0 && multi_core > 0) + ); + assert_eq!(r.method.method_id, "cpu-method-v1"); + assert!(!r.environment.development_build); + assert_eq!(c.history().len(), 1); + assert_eq!(c.history()[0].id, r.id); + + // The state machine only moves forward through the expected states. + let states: Vec = lock(&sink.statuses).iter().map(|s| s.state).collect(); + let order = |s: &BenchmarkState| match s { + BenchmarkState::Preparing => 0, + BenchmarkState::Preflight => 1, + BenchmarkState::WarmingUp | BenchmarkState::Running => 2, + BenchmarkState::Finalizing => 3, + BenchmarkState::Completed => 4, + other => panic!("unexpected state {other:?}"), + }; + assert!( + states.windows(2).all(|p| order(&p[0]) <= order(&p[1])), + "{states:?}" + ); + assert_eq!(states.last(), Some(&BenchmarkState::Completed)); +} + +#[test] +fn only_one_benchmark_runs_at_a_time() { + let c = coordinator(); + let sink = Arc::new(Recorder::default()); + let mut long = CpuRunConfig::test(); + long.int_chunk_steps = u64::MAX / 4096; + quick(&c, &sink, long).expect("first starts"); + let second = quick(&c, &sink, CpuRunConfig::test()); + assert!(matches!(second, Err(AppError::BenchmarkAlreadyRunning))); + c.cancel(); + wait_until_idle(&c); + // The lock is free again. + quick(&c, &sink, CpuRunConfig::test()).expect("can start again"); + assert_eq!(wait_until_idle(&c).state, BenchmarkState::Completed); +} + +#[test] +fn cancelling_stops_the_run_saves_nothing_and_frees_the_lock() { + let c = coordinator(); + let sink = Arc::new(Recorder::default()); + let mut long = CpuRunConfig::test(); + long.int_chunk_steps = u64::MAX / 4096; + quick(&c, &sink, long).expect("starts"); + std::thread::sleep(Duration::from_millis(150)); + let t = Instant::now(); + c.cancel(); + let done = wait_until_idle(&c); + assert!(t.elapsed() < Duration::from_secs(3), "{:?}", t.elapsed()); + assert_eq!(done.state, BenchmarkState::Cancelled); + assert!( + done.message + .is_some_and(|m| m.contains("Nothing was saved")) + ); + assert!(c.history().is_empty()); + assert!(lock(&sink.results).is_empty()); +} + +#[test] +fn cancelling_while_idle_does_nothing() { + let c = coordinator(); + c.cancel(); + assert_eq!(c.status().state, BenchmarkState::Idle); +} + +#[test] +fn development_builds_are_flagged_unscored_and_not_saved() { + let engine = Arc::new(Engine::start(MonitoringState::default()).expect("engine")); + let c = Coordinator::new(engine, None); // default: treat debug as development + let sink = Arc::new(Recorder::default()); + quick(&c, &sink, CpuRunConfig::test()).expect("starts"); + let done = wait_until_idle(&c); + assert_eq!(done.state, BenchmarkState::Completed); + if cfg!(debug_assertions) { + let results = lock(&sink.results); + let r = &results[0]; + assert!(r.environment.development_build); + assert!(r.scores.is_none()); + assert!(r.warnings.iter().any(|w| w.contains("Development build"))); + assert!(c.history().is_empty()); + } +} + +#[test] +fn progress_updates_are_rate_limited() { + let c = coordinator(); + let sink = Arc::new(Recorder::default()); + let dyn_sink: Arc = sink.clone(); + { + let ctx_status = BenchmarkStatus { + state: BenchmarkState::Running, + ..BenchmarkStatus::idle() + }; + *lock(&c.shared.status) = ctx_status; + } + let reporter = Reporter { + shared: Arc::clone(&c.shared), + sink: dyn_sink, + engine: Arc::clone(&c.engine), + started: Instant::now(), + last_emit: Mutex::new(None), + }; + let t = Instant::now(); + for i in 0..5000 { + reporter.live(Some(LiveReading { + label: "x".into(), + value: f64::from(i), + unit: "u".into(), + })); + } + let emitted = lock(&sink.statuses).len(); + let max = 1 + (t.elapsed().as_millis() / MIN_EMIT_INTERVAL.as_millis()) as usize; + assert!( + emitted >= 1 && emitted <= max, + "{emitted} updates in {:?}", + t.elapsed() + ); + // The latest value is always the one stored, even when not emitted. + assert_eq!( + lock(&c.shared.status).live.as_ref().map(|l| l.value), + Some(4999.0) + ); + // A state change always goes out immediately. + reporter.stage(BenchmarkState::Finalizing, "Done soon", 0.9); + assert_eq!( + lock(&sink.statuses).last().map(|s| s.state), + Some(BenchmarkState::Finalizing) + ); +} + +#[test] +fn late_callbacks_do_not_revive_a_finished_run() { + let c = coordinator(); + let sink = Arc::new(Recorder::default()); + let dyn_sink: Arc = sink.clone(); + *lock(&c.shared.status) = BenchmarkStatus { + state: BenchmarkState::Cancelled, + ..BenchmarkStatus::idle() + }; + let reporter = Reporter { + shared: Arc::clone(&c.shared), + sink: dyn_sink, + engine: Arc::clone(&c.engine), + started: Instant::now(), + last_emit: Mutex::new(None), + }; + reporter.stage(BenchmarkState::Running, "late", 0.5); + assert_eq!(c.status().state, BenchmarkState::Cancelled); + assert!(lock(&sink.statuses).is_empty()); +} + +fn reading(label: &str, c: f32) -> TemperatureReading { + TemperatureReading { + label: label.into(), + celsius: c, + max_celsius: None, + critical_celsius: None, + } +} + +#[test] +fn cpu_temperature_needs_an_identifying_label() { + // `temp1` is never assumed to be the CPU. + assert_eq!( + cpu_temperature(&[ + reading("acpitz temp1", 27.0), + reading("nvme Composite", 40.0) + ]), + None + ); + assert_eq!( + cpu_temperature(&[ + reading("coretemp Core 0", 60.0), + reading("acpitz temp1", 27.0), + reading("coretemp Package id 0", 62.0), + ]), + Some(62.0) + ); + assert_eq!( + cpu_temperature(&[reading("k10temp Tctl", 55.0)]), + Some(55.0) + ); + assert_eq!(cpu_temperature(&[]), None); + // Environment type is available to tests that need it. + let _ = PowerSource::Unknown; +} + +// ------------------------------------------------------------- GPU +use crate::benchmarks::gpu::adapters; +use crate::benchmarks::gpu::runner::GpuExecutor; +use crate::benchmarks::gpu::runner::tests::Fake; + +struct FakeBackend { + adapters: Vec, + fail: Option, +} + +impl GpuBackend for FakeBackend { + fn adapters(&self) -> Result, BenchmarkError> { + Ok(self.adapters.clone()) + } + + fn open( + &self, + id: Option<&str>, + config_for: &dyn Fn(&GpuAdapterInfo) -> GpuRunConfig, + ) -> Result<(GpuAdapterInfo, Box, GpuRunConfig), BenchmarkError> { + if let Some(m) = &self.fail { + return Err(BenchmarkError::Failed(m.clone())); + } + let info = adapters::select(&self.adapters, id)?.clone(); + let cfg = config_for(&info); + Ok((info, Box::new(Fake::default()), cfg)) + } +} + +fn adapter(id: &str, t: GpuDeviceType) -> GpuAdapterInfo { + GpuAdapterInfo { + id: id.into(), + name: format!("Test {id}"), + vendor_id: 1, + device_id: 2, + device_type: t, + backend: "vulkan".into(), + driver: "test".into(), + driver_info: "1.0".into(), + supports_timestamps: true, + max_buffer_bytes: 1 << 30, + max_storage_binding_bytes: 1 << 27, + max_workgroups_per_dimension: 65_535, + recommended: false, + } +} + +fn gpu_coordinator(list: Vec, fail: Option<&str>) -> Coordinator { + coordinator().with_gpu_backend(Arc::new(FakeBackend { + adapters: list, + fail: fail.map(str::to_owned), + })) +} + +fn start_gpu( + c: &Coordinator, + sink: &Arc, + adapter: Option<&str>, +) -> AppResult { + let sink: Arc = sink.clone(); + c.start_with( + BenchmarkKind::Gpu, + BenchmarkProfile::Quick, + RunPlan::Gpu { + config: Some(GpuRunConfig::test()), + adapter: adapter.map(str::to_owned), + }, + sink, + ) +} + +#[test] +fn a_gpu_run_completes_scores_saves_and_records_the_adapter() { + let c = gpu_coordinator( + vec![ + adapter("cpu-like", GpuDeviceType::Software), + adapter("igpu", GpuDeviceType::Integrated), + ], + None, + ); + let sink = Arc::new(Recorder::default()); + start_gpu(&c, &sink, None).expect("starts"); + let done = wait_until_idle(&c); + assert_eq!(done.state, BenchmarkState::Completed, "{:?}", done.message); + + let r = lock(&sink.results)[0].clone(); + assert_eq!(r.kind, BenchmarkKind::Gpu); + assert_eq!(r.method.method_id, "gpu-compute-v1"); + let g = r.gpu.as_ref().expect("gpu result"); + // The software renderer is never the default. + assert_eq!(g.adapter.id, "igpu"); + assert_eq!(g.workloads.len(), 3); + assert!(g.workloads.iter().all(|w| w.validated)); + assert!(r.gpu_score.is_some_and(|s| s > 0)); + assert!(r.scores.is_none() && r.workloads.is_empty()); + assert!(g.notes.iter().any(|n| n.contains("shared system memory"))); + assert!(r.id.starts_with("gpu-")); + // Saved, and survives a JSON round trip unchanged. + assert_eq!(c.history().len(), 1); + let json = serde_json::to_string(&r).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), r); +} + +#[test] +fn a_software_renderer_is_run_on_request_but_never_scored() { + let c = gpu_coordinator(vec![adapter("llvmpipe", GpuDeviceType::Software)], None); + let sink = Arc::new(Recorder::default()); + start_gpu(&c, &sink, Some("llvmpipe")).expect("starts"); + assert_eq!(wait_until_idle(&c).state, BenchmarkState::Completed); + let r = lock(&sink.results)[0].clone(); + assert_eq!(r.gpu_score, None); + assert!( + r.gpu + .unwrap() + .notes + .iter() + .any(|n| n.contains("software renderer")) + ); + // Not scored, but also not a failure: no misleading "unavailable" warning. + assert!(!r.warnings.iter().any(|w| w.contains("unavailable"))); +} + +#[test] +fn no_adapter_fails_cleanly_and_frees_the_lock() { + let c = gpu_coordinator(vec![], None); + let sink = Arc::new(Recorder::default()); + start_gpu(&c, &sink, None).expect("starts"); + let done = wait_until_idle(&c); + assert_eq!(done.state, BenchmarkState::Failed); + assert!( + done.message + .is_some_and(|m| m.contains("No graphics adapter")) + ); + assert!(c.history().is_empty()); + // The app keeps working: a CPU benchmark can run right after. + quick(&c, &sink, CpuRunConfig::test()).expect("can start another"); + assert_eq!(wait_until_idle(&c).state, BenchmarkState::Completed); +} + +#[test] +fn a_driver_failure_is_reported_in_plain_words() { + let c = gpu_coordinator( + vec![adapter("igpu", GpuDeviceType::Integrated)], + Some("The graphics adapter could not be opened: driver said no"), + ); + let sink = Arc::new(Recorder::default()); + start_gpu(&c, &sink, None).expect("starts"); + let done = wait_until_idle(&c); + assert_eq!(done.state, BenchmarkState::Failed); + assert!( + done.message + .is_some_and(|m| m.contains("could not be opened")) + ); +} + +#[test] +fn a_choice_of_a_vanished_adapter_is_an_error_not_a_silent_fallback() { + let c = gpu_coordinator(vec![adapter("igpu", GpuDeviceType::Integrated)], None); + let sink = Arc::new(Recorder::default()); + start_gpu(&c, &sink, Some("gone")).expect("starts"); + let done = wait_until_idle(&c); + assert_eq!(done.state, BenchmarkState::Failed); + assert!( + done.message + .is_some_and(|m| m.contains("no longer available")) + ); +} + +#[test] +fn cpu_and_gpu_benchmarks_share_one_lock() { + let c = gpu_coordinator(vec![adapter("igpu", GpuDeviceType::Integrated)], None); + let sink = Arc::new(Recorder::default()); + let mut long = CpuRunConfig::test(); + long.int_chunk_steps = u64::MAX / 4096; + quick(&c, &sink, long).expect("cpu starts"); + assert!(matches!( + start_gpu(&c, &sink, None), + Err(AppError::BenchmarkAlreadyRunning) + )); + c.cancel(); + wait_until_idle(&c); + start_gpu(&c, &sink, None).expect("gpu can start after"); + assert_eq!(wait_until_idle(&c).state, BenchmarkState::Completed); +} + +#[test] +fn development_builds_do_not_score_gpu_runs_either() { + let engine = Arc::new(Engine::start(MonitoringState::default()).expect("engine")); + let c = Coordinator::new(engine, None).with_gpu_backend(Arc::new(FakeBackend { + adapters: vec![adapter("igpu", GpuDeviceType::Integrated)], + fail: None, + })); + let sink = Arc::new(Recorder::default()); + start_gpu(&c, &sink, None).expect("starts"); + assert_eq!(wait_until_idle(&c).state, BenchmarkState::Completed); + if cfg!(debug_assertions) { + let r = lock(&sink.results)[0].clone(); + assert!(r.environment.development_build && r.gpu_score.is_none()); + assert!(c.history().is_empty()); + } +} + +#[test] +fn the_adapter_list_comes_from_the_backend() { + let c = gpu_coordinator( + vec![ + adapter("a", GpuDeviceType::Discrete), + adapter("b", GpuDeviceType::Integrated), + ], + None, + ); + assert_eq!(c.gpu_adapters().unwrap().len(), 2); +} + +fn target(dir: &std::path::Path) -> DiskTargetInfo { + DiskTargetInfo { + id: "test".into(), + label: "Test folder".into(), + directory: dir.to_string_lossy().into_owned(), + mount_point: "/".into(), + file_system: "ext4".into(), + device: None, + free_bytes: 100 << 30, + total_bytes: 200 << 30, + recommended: true, + notes: vec![], + } +} + +// Unix only: the disk engine does not exist on other platforms yet. +#[cfg(unix)] +#[test] +fn a_disk_run_is_saved_with_its_target_mode_and_wear() { + let dir = tempfile::tempdir().unwrap(); + let t = target(dir.path()); + let c = coordinator().with_disk_targets(vec![t.clone()]); + let sink = Arc::new(Recorder::default()); + let sink2: Arc = sink.clone(); + c.start_with( + BenchmarkKind::Disk, + BenchmarkProfile::Quick, + RunPlan::Disk { + config: Some(DiskConfig::test()), + target: Box::new(t), + }, + sink2, + ) + .expect("starts"); + let done = wait_until_idle(&c); + assert_eq!(done.state, BenchmarkState::Completed, "{:?}", done.message); + let results = lock(&sink.results); + let r = &results[0]; + let d = r.disk.as_ref().expect("disk result"); + assert_eq!(d.phases.len(), 6); + assert_eq!(d.target.id, "test"); + assert!(d.bytes_written > 0 && d.bytes_read > 0); + assert!(r.method.method_id.starts_with("disk-")); + assert!(r.method.method_id.ends_with(match d.io_mode { + crate::benchmarks::model::IoMode::Direct => "direct-v1", + crate::benchmarks::model::IoMode::Buffered => "buffered-v1", + })); + assert!(r.id.starts_with("disk-")); + assert_eq!(c.history().len(), 1); + assert!( + !dir.path() + .join(crate::benchmarks::disk::file::DIR_NAME) + .exists(), + "no test file left behind" + ); +} + +#[test] +fn disk_and_internet_runs_refuse_unknown_targets_and_missing_consent() { + let c = coordinator().with_disk_targets(vec![]); + let sink: Arc = Arc::new(Recorder::default()); + let go = + |options: StartOptions, kind| c.start(kind, BenchmarkProfile::Quick, options, sink.clone()); + assert!(matches!( + go(StartOptions::default(), BenchmarkKind::Disk), + Err(AppError::InvalidArgument(_)) + )); + assert!(matches!( + go( + StartOptions { + disk_target: Some("/etc".into()), + ..Default::default() + }, + BenchmarkKind::Disk + ), + Err(AppError::InvalidArgument(_)) + )); + assert!(matches!( + go(StartOptions::default(), BenchmarkKind::Internet), + Err(AppError::InvalidArgument(_)) + )); + // Nothing started, so the lock is free. + assert!(lock(&c.shared.active).is_none()); + assert_eq!(c.status().state, BenchmarkState::Idle); +} + +#[test] +fn disk_plan_reports_the_write_volume_or_the_reason_it_is_blocked() { + let dir = tempfile::tempdir().unwrap(); + let t = target(dir.path()); + let c = coordinator().with_disk_targets(vec![t]); + let p = c.disk_plan("test", BenchmarkProfile::Standard).unwrap(); + assert!(p.blocked.is_none() || p.file_bytes == 0); + if p.blocked.is_none() { + assert!(p.estimated_bytes_written > p.file_bytes); + } + assert!(c.disk_plan("nope", BenchmarkProfile::Quick).is_err()); +} + +#[test] +fn an_internet_run_with_consent_is_saved_without_a_score() { + use crate::benchmarks::internet::provider::{Cloudflare, test_server}; + let server = test_server::start(test_server::Mode::Normal, 2_500_000); + let c = coordinator().with_internet_provider(Arc::new(Cloudflare::with_base( + &server.base, + Duration::from_secs(5), + ))); + let sink = Arc::new(Recorder::default()); + let sink2: Arc = sink.clone(); + c.start_with( + BenchmarkKind::Internet, + BenchmarkProfile::Quick, + RunPlan::Internet { + config: Some(InternetConfig::test()), + }, + sink2, + ) + .expect("starts"); + let done = wait_until_idle(&c); + assert_eq!(done.state, BenchmarkState::Completed, "{:?}", done.message); + let results = lock(&sink.results); + let r = &results[0]; + let n = r.internet.as_ref().expect("internet result"); + assert!(n.download.as_ref().unwrap().median_mbps > 10.0); + assert!(r.scores.is_none() && r.disk_score.is_none() && r.gpu_score.is_none()); + assert_eq!(r.method.method_id, "internet-cloudflare-v1"); + assert!(r.id.starts_with("net-")); + assert_eq!(c.history().len(), 1); +} + +#[test] +fn internet_tests_have_a_cooldown_and_a_session_data_budget() { + use crate::benchmarks::internet::provider::{Cloudflare, test_server}; + let server = test_server::start(test_server::Mode::Normal, 0); + let sink: Arc = Arc::new(Recorder::default()); + let provider = Arc::new(Cloudflare::with_base(&server.base, Duration::from_secs(5))); + let go = |c: &Coordinator| { + c.start( + BenchmarkKind::Internet, + BenchmarkProfile::Quick, + StartOptions { + internet_consent: true, + ..Default::default() + }, + sink.clone(), + ) + }; + + // Cooldown: a second start right after the first is refused. + let c = coordinator() + .with_internet_provider(provider.clone()) + .with_internet_limits(Duration::from_secs(300), u64::MAX); + assert!(go(&c).is_ok()); + c.cancel(); + wait_until_idle(&c); + let e = go(&c).expect_err("cooling down"); + assert!(e.to_string().contains("more seconds"), "{e}"); + + // Budget: a plan that does not fit what is left is refused up front. + let c = coordinator() + .with_internet_provider(provider) + .with_internet_limits(Duration::ZERO, 100_000_000); + let e = go(&c).expect_err("over budget"); + assert!(e.to_string().contains("allowance"), "{e}"); + assert_eq!(c.status().state, BenchmarkState::Idle); +} diff --git a/src-tauri/core/src/benchmarks/disk/file.rs b/src-tauri/core/src/benchmarks/disk/file.rs index ff983a6..6c4516b 100644 --- a/src-tauri/core/src/benchmarks/disk/file.rs +++ b/src-tauri/core/src/benchmarks/disk/file.rs @@ -173,21 +173,39 @@ fn direct_io_works(file: &File) -> bool { AlignedBuf::new(ALIGN).is_some_and(|b| file.write_at(b.as_slice(), 0).is_ok()) } +/// In a shared location such as `/tmp` another user could create the folder +/// first, so an existing one is used only if it is ours and private. fn ensure_private_dir(dir: &Path) -> Result<(), BenchmarkError> { - match std::fs::symlink_metadata(dir) { - Ok(meta) if meta.file_type().is_symlink() || !meta.is_dir() => { - return Err(BenchmarkError::Failed( - "The benchmark folder is not a plain folder; refusing to use it.".into(), - )); - } - Ok(_) => {} + use std::os::unix::fs::MetadataExt; + let meta = match std::fs::symlink_metadata(dir) { + Ok(meta) => meta, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { DirBuilder::new() .mode(0o700) .create(dir) .map_err(|e| io_error("create the benchmark folder", &e))?; + std::fs::symlink_metadata(dir) + .map_err(|e| io_error("inspect the benchmark folder", &e))? } Err(e) => return Err(io_error("inspect the benchmark folder", &e)), + }; + if meta.file_type().is_symlink() || !meta.is_dir() { + return Err(BenchmarkError::Failed( + "The benchmark folder is not a plain folder; refusing to use it.".into(), + )); + } + // SAFETY: getuid has no preconditions and cannot fail. + if meta.uid() != unsafe { libc::getuid() } { + return Err(BenchmarkError::Failed(format!( + "The benchmark folder {} belongs to another user; refusing to use it.", + dir.display() + ))); + } + if meta.mode() & 0o777 != 0o700 { + return Err(BenchmarkError::Failed(format!( + "The benchmark folder {} can be opened by other users; refusing to use it. Remove it or make it private (chmod 700).", + dir.display() + ))); } Ok(()) } @@ -312,13 +330,30 @@ mod tests { fn an_existing_file_is_never_opened_or_overwritten() { let d = tmp(); let dir = d.path().join(DIR_NAME); - std::fs::create_dir(&dir).unwrap(); + DirBuilder::new().mode(0o700).create(&dir).unwrap(); let existing = dir.join(".tm-bench-disk-3-0.tmp"); std::fs::write(&existing, b"precious").unwrap(); assert!(BenchFile::create(d.path(), "disk-3-0", false).is_err()); assert_eq!(std::fs::read(&existing).unwrap(), b"precious"); } + #[test] + fn an_existing_folder_others_can_open_is_refused() { + use std::os::unix::fs::PermissionsExt; + let d = tmp(); + let dir = d.path().join(DIR_NAME); + std::fs::create_dir(&dir).unwrap(); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap(); + let e = BenchFile::create(d.path(), "disk-3-1", false) + .err() + .expect("refused"); + assert!(e.to_string().contains("other users"), "{e}"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0); + + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap(); + assert!(BenchFile::create(d.path(), "disk-3-1", false).is_ok()); + } + #[test] fn bad_run_ids_cannot_escape_the_directory() { let d = tmp(); diff --git a/src-tauri/core/src/benchmarks/history.rs b/src-tauri/core/src/benchmarks/history.rs index 7296741..c19cc14 100644 --- a/src-tauri/core/src/benchmarks/history.rs +++ b/src-tauri/core/src/benchmarks/history.rs @@ -9,7 +9,7 @@ //! few KB; switch to SQLite if history ever needs querying or much more depth. use std::path::{Path, PathBuf}; -use std::sync::{Mutex, PoisonError}; +use std::sync::Mutex; use serde::{Deserialize, Serialize}; @@ -56,6 +56,7 @@ impl HistoryStore { self.lock().clone() } + #[cfg(test)] pub fn get(&self, id: &str) -> Option { self.lock().iter().find(|r| r.id == id).cloned() } @@ -82,7 +83,7 @@ impl HistoryStore { } fn lock(&self) -> std::sync::MutexGuard<'_, Vec> { - self.results.lock().unwrap_or_else(PoisonError::into_inner) + crate::sync_util::lock(&self.results) } fn persist(&self, results: &[BenchmarkResult]) -> AppResult<()> { diff --git a/src-tauri/core/src/benchmarks/mod.rs b/src-tauri/core/src/benchmarks/mod.rs index 343593c..950f763 100644 --- a/src-tauri/core/src/benchmarks/mod.rs +++ b/src-tauri/core/src/benchmarks/mod.rs @@ -1,7 +1,7 @@ //! The benchmark suite: shared, cross-platform algorithms and orchestration. //! Only `platform` contains OS-specific code. //! -//! See `docs/LINUX_BENCHMARK_IMPLEMENTATION.md` for the method definitions. +//! See `docs/archive/plans/LINUX_BENCHMARK_IMPLEMENTATION.md` for the method definitions. pub mod cancel; pub mod coordinator; diff --git a/src-tauri/core/src/inspect.rs b/src-tauri/core/src/inspect.rs index 522857e..cf00382 100644 --- a/src-tauri/core/src/inspect.rs +++ b/src-tauri/core/src/inspect.rs @@ -10,7 +10,6 @@ use crate::models::{ MemoryKindTotal, MemoryMap, MemoryRegion, MemoryRegionKind, ModuleInfo, ProcessState, ThreadInfo, ThreadList, }; -use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System}; /// Numeric-PID reads must not return a replacement process's data. pub fn for_process( @@ -23,22 +22,9 @@ pub fn for_process( "The process start time is unavailable, so its identity cannot be verified.".into(), )); } - let mut system = System::new().map_err(|e| AppError::SystemQueryFailed(e.to_string()))?; - let mut check = || { - system.refresh_processes_specifics( - ProcessesToUpdate::Some(&[Pid::from_u32(pid)]), - true, - ProcessRefreshKind::nothing().without_tasks(), - ); - system - .process(Pid::from_u32(pid)) - .filter(|p| p.start_time() == start_time) - .map(|_| ()) - .ok_or(AppError::ProcessNotFound(pid)) - }; - check()?; + crate::platform::verify_identity(pid, start_time)?; let result = read(); - check()?; + crate::platform::verify_identity(pid, start_time)?; result } @@ -346,6 +332,7 @@ mod tests { #[cfg(unix)] #[test] fn a_process_that_exits_during_inspection_cannot_return_data() { + use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System}; let mut child = std::process::Command::new("sleep") .arg("60") .spawn() diff --git a/src-tauri/core/src/lib.rs b/src-tauri/core/src/lib.rs index b1dbc1f..86c2cb8 100644 --- a/src-tauri/core/src/lib.rs +++ b/src-tauri/core/src/lib.rs @@ -11,3 +11,4 @@ pub mod models; pub mod monitoring; pub mod platform; pub mod process_control; +pub mod sync_util; diff --git a/src-tauri/core/src/models/inspect.rs b/src-tauri/core/src/models/inspect.rs index 48b9197..b4e1745 100644 --- a/src-tauri/core/src/models/inspect.rs +++ b/src-tauri/core/src/models/inspect.rs @@ -15,6 +15,9 @@ pub struct SocketEntry { /// without privileges, or a closing socket the kernel no longer attributes). pub pid: Option, pub process_name: Option, + /// Start time of the owner, as in `ProcessRow::start_time`, so the UI can + /// tell it from a later process that reused the PID. `None` without an owner. + pub start_time: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] diff --git a/src-tauri/core/src/monitoring/details.rs b/src-tauri/core/src/monitoring/details.rs index 2440b98..2defe3c 100644 --- a/src-tauri/core/src/monitoring/details.rs +++ b/src-tauri/core/src/monitoring/details.rs @@ -1,21 +1,19 @@ //! On-demand details for a single process. These are read only when the user //! opens a process, so they are not part of the per-tick payload. -use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind}; +use sysinfo::{ProcessRefreshKind, UpdateKind}; use crate::errors::{AppError, AppResult}; use crate::models::ProcessDetails; use crate::monitoring::Engine; use crate::platform; -pub fn process_details( - engine: &Engine, - pid: u32, - start_time: Option, -) -> AppResult { +/// `start_time` pins the process instance the user opened; a reused PID is +/// reported as not found. +pub fn process_details(engine: &Engine, pid: u32, start_time: u64) -> AppResult { let row = engine .process(pid) - .filter(|row| start_time.is_none_or(|start| row.start_time == start)) + .filter(|row| row.start_time == start_time) .ok_or(AppError::ProcessNotFound(pid))?; crate::inspect::for_process(pid, row.start_time, || { let parent_name = row @@ -24,34 +22,24 @@ pub fn process_details( .map(|p| p.name); // A tiny, single-process query; it does not touch the engine's System. - let (cwd, total_read, total_written) = match System::new() { - Ok(mut sys) => { - let target = [Pid::from_u32(pid)]; - sys.refresh_processes_specifics( - ProcessesToUpdate::Some(&target), - true, - ProcessRefreshKind::nothing() - .without_tasks() - .with_cwd(UpdateKind::Always) - .with_disk_usage(), - ); - match sys.process(Pid::from_u32(pid)) { - Some(p) if p.start_time() == row.start_time => { - let usage = p.disk_usage(); - ( - p.cwd() - .map(|c| c.to_string_lossy().into_owned()) - .filter(|c| !c.is_empty()), - Some(usage.total_read_bytes), - Some(usage.total_written_bytes), - ) - } - // Exited between the engine tick and this query. - _ => return Err(AppError::ProcessNotFound(pid)), - } - } - Err(_) => (None, None, None), - }; + let (cwd, total_read, total_written) = platform::with_process( + pid, + row.start_time, + ProcessRefreshKind::nothing() + .without_tasks() + .with_cwd(UpdateKind::Always) + .with_disk_usage(), + |p| { + let usage = p.disk_usage(); + ( + p.cwd() + .map(|c| c.to_string_lossy().into_owned()) + .filter(|c| !c.is_empty()), + usage.total_read_bytes, + usage.total_written_bytes, + ) + }, + )?; Ok(ProcessDetails { history: engine.process_history(pid, row.start_time), @@ -59,8 +47,8 @@ pub fn process_details( cwd, architecture: platform::process_architecture(pid), // Zero lifetime I/O usually means "not readable", not "no I/O". - total_read_bytes: total_read.filter(|&b| b > 0), - total_written_bytes: total_written.filter(|&b| b > 0), + total_read_bytes: Some(total_read).filter(|&b| b > 0), + total_written_bytes: Some(total_written).filter(|&b| b > 0), connections: platform::connections(pid), row, }) diff --git a/src-tauri/core/src/monitoring/disks.rs b/src-tauri/core/src/monitoring/disks.rs index 669a8ac..d86c1c2 100644 --- a/src-tauri/core/src/monitoring/disks.rs +++ b/src-tauri/core/src/monitoring/disks.rs @@ -10,7 +10,7 @@ use crate::platform::{self, DiskIoCounters}; /// Fallback cadence for re-enumerating the mount list. On Linux a change to /// the mount table triggers it at once (see [`mount_table`]), so this only /// covers other platforms and the slow-moving disk type. Capacities are -/// re-read on every tick (a `statvfs`/`GetDiskFreeSpaceEx` per mount) so +/// re-read on every tick (a `statvfs`/`GetDiskFreeSpaceEx` per local mount) so /// deleting files is reflected immediately in the UI. const MOUNT_LIST_REFRESH: Duration = Duration::from_secs(30); @@ -66,6 +66,32 @@ const IGNORED_FILE_SYSTEMS: &[&str] = &[ "fuse.gvfsd-fuse", ]; +/// Network file systems. They are never shown and never `statvfs`ed: if the +/// server goes away the call can block for minutes, stalling every metric. +/// (sysinfo already skips NFS/CIFS on Linux; this covers the rest.) +const REMOTE_FILE_SYSTEMS: &[&str] = &[ + "nfs", + "nfs4", + "cifs", + "smb3", + "smbfs", + "9p", + "afs", + "ceph", + "glusterfs", + "lustre", + "davfs", + "fuse.sshfs", + "fuse.rclone", + "fuse.s3fs", + "fuse.glusterfs", + "fuse.davfs2", +]; + +fn is_remote(file_system: &str) -> bool { + REMOTE_FILE_SYSTEMS.contains(&file_system.to_ascii_lowercase().as_str()) +} + pub struct DiskTracker { /// `None` when the platform cannot enumerate disks at all. disks: Option, @@ -100,13 +126,13 @@ impl DiskTracker { let table = mount_table(); let since = now.duration_since(self.last_mount_refresh); if list_refresh_due(table.as_deref(), self.mount_table.as_deref(), since) { - disks.refresh_specifics(true, DiskRefreshKind::everything()); + refresh_disks(disks, true, DiskRefreshKind::everything()); self.last_mount_refresh = now; self.mount_table = table; } else { // Free space is cheap to query and changes when the user writes or // deletes files, so refresh it on every tick. - disks.refresh_specifics(false, tick_refresh_kind()); + refresh_disks(disks, false, tick_refresh_kind()); } let relevant: Vec<&sysinfo::Disk> = disks @@ -202,13 +228,29 @@ fn tick_refresh_kind() -> DiskRefreshKind { DiskRefreshKind::nothing().with_storage().with_io_usage() } -/// Filters out pseudo file systems, snap images and zero-sized mounts. +/// Applies `kind` to every disk, except that free space (`statvfs`) is only +/// read for local file systems. +fn refresh_disks(disks: &mut Disks, list: bool, kind: DiskRefreshKind) { + disks.refresh_specifics(list, kind.without_storage()); + if kind.storage() { + let storage = DiskRefreshKind::nothing().with_storage(); + for disk in disks + .iter_mut() + .filter(|d| !is_remote(&d.file_system().to_string_lossy())) + { + disk.refresh_specifics(storage); + } + } +} + +/// Filters out pseudo and network file systems, snap images and zero-sized +/// mounts. pub fn is_relevant(file_system: &str, mount_point: &str, total_bytes: u64) -> bool { if total_bytes == 0 { return false; } let fs = file_system.to_ascii_lowercase(); - if IGNORED_FILE_SYSTEMS.contains(&fs.as_str()) { + if IGNORED_FILE_SYSTEMS.contains(&fs.as_str()) || is_remote(&fs) { return false; } !(mount_point.starts_with("/snap/") || mount_point.starts_with("/var/lib/docker/")) @@ -259,6 +301,15 @@ mod tests { assert!(!is_relevant("ext4", "/mnt/empty", 0)); } + #[test] + fn network_mounts_are_never_shown() { + assert!(!is_relevant("fuse.sshfs", "/mnt/server", 500)); + assert!(!is_relevant("smb3", "/mnt/share", 500)); + assert!(!is_relevant("9p", "/mnt/host", 500)); + assert!(is_remote("NFS4")); + assert!(!is_remote("ext4")); + } + /// Integration: the system drive reports capacity and, on Windows/Linux, /// operation counters. #[test] diff --git a/src-tauri/core/src/monitoring/engine.rs b/src-tauri/core/src/monitoring/engine.rs index 94a1dd8..0fd21ae 100644 --- a/src-tauri/core/src/monitoring/engine.rs +++ b/src-tauri/core/src/monitoring/engine.rs @@ -12,7 +12,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{self, RecvTimeoutError}; -use std::sync::{Arc, Mutex, PoisonError, RwLock}; +use std::sync::{Arc, Mutex, RwLock}; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; @@ -26,6 +26,7 @@ use crate::monitoring::alerts::AlertEvaluator; use crate::monitoring::collector::{Collector, unix_millis}; use crate::monitoring::history::RingBuffer; use crate::monitoring::process_history::ProcessHistory; +use crate::sync_util::{lock, read, write}; /// One hour of history at the fastest (500 ms) interval. pub const MAX_HISTORY_POINTS: usize = 7_200; @@ -41,8 +42,9 @@ pub trait TickSink: Send + Sync { true } - /// `json` is present whenever [`TickSink::wants_json`] returned true. - fn deliver(&self, tick: &MetricsTick, json: Option<&str>) -> bool; + /// `json` is present whenever [`TickSink::wants_json`] returned true. It is + /// owned so a single interested sink can forward it without copying. + fn deliver(&self, tick: &MetricsTick, json: Option) -> bool; } enum Control { @@ -61,7 +63,7 @@ struct Shared { /// Windows has no documented per-process "suspended" status, so the /// engine reports the state it caused. suspended: Mutex>, - alert_rules: RwLock>, + alert_rules: RwLock>, /// Linux kernel threads are dropped from ticks unless enabled: they are /// often over half of all rows and cannot be acted on. include_kernel_threads: AtomicBool, @@ -73,16 +75,6 @@ struct Shared { sinks: Mutex>>, } -fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { - m.lock().unwrap_or_else(PoisonError::into_inner) -} -fn read(l: &RwLock) -> std::sync::RwLockReadGuard<'_, T> { - l.read().unwrap_or_else(PoisonError::into_inner) -} -fn write(l: &RwLock) -> std::sync::RwLockWriteGuard<'_, T> { - l.write().unwrap_or_else(PoisonError::into_inner) -} - pub struct Engine { control: mpsc::SyncSender, refresh_requested: Mutex>, @@ -133,7 +125,7 @@ impl Engine { history: RwLock::new(RingBuffer::new(MAX_HISTORY_POINTS)), process_history: RwLock::new(ProcessHistory::default()), suspended: Mutex::new(HashSet::new()), - alert_rules: RwLock::new(Vec::new()), + alert_rules: RwLock::new(Arc::from([])), include_kernel_threads: AtomicBool::new(false), memory_detail: AtomicBool::new(false), alert_log: Mutex::new(RingBuffer::new(ALERT_LOG_LEN)), @@ -242,7 +234,7 @@ impl Engine { /// Replaces the alert rules; takes effect on the next tick. pub fn set_alert_rules(&self, rules: Vec) { - *write(&self.shared.alert_rules) = rules; + *write(&self.shared.alert_rules) = rules.into(); } /// Recently fired alerts, newest last. @@ -353,7 +345,7 @@ impl Worker { } apply_suspended(shared, &mut sample.processes); - let rules = read(&shared.alert_rules).clone(); + let rules = Arc::clone(&read(&shared.alert_rules)); sample.alerts = self.alerts.evaluate(&rules, &sample); if !sample.alerts.is_empty() { let mut log = lock(&shared.alert_log); @@ -399,7 +391,9 @@ impl Worker { .iter() .map(|(k, s)| (k.clone(), Arc::clone(s))) .collect(); - let json = if sinks.iter().any(|(_, s)| s.wants_json()) { + let wants: Vec = sinks.iter().map(|(_, s)| s.wants_json()).collect(); + let mut json_sinks_left = wants.iter().filter(|w| **w).count(); + let mut json = if json_sinks_left > 0 { match serde_json::to_string(&*sample) { Ok(json) => { self.last_payload = u32::try_from(json.len()).unwrap_or(u32::MAX); @@ -413,18 +407,23 @@ impl Worker { } else { None }; - let dead: Vec = sinks - .into_iter() - .filter(|(_, sink)| { - let payload = if sink.wants_json() { - json.as_deref() + let mut dead: Vec = Vec::new(); + for ((key, sink), wants) in sinks.into_iter().zip(wants) { + let payload = if wants { + json_sinks_left -= 1; + // The last interested sink takes the string itself. + if json_sinks_left == 0 { + json.take() } else { - None - }; - !sink.deliver(&sample, payload) - }) - .map(|(k, _)| k) - .collect(); + json.clone() + } + } else { + None + }; + if !sink.deliver(&sample, payload) { + dead.push(key); + } + } if !dead.is_empty() { let mut map = lock(&shared.sinks); for key in dead { @@ -484,9 +483,9 @@ mod tests { fn wants_json(&self) -> bool { self.wants_json.load(Ordering::SeqCst) } - fn deliver(&self, tick: &MetricsTick, json: Option<&str>) -> bool { + fn deliver(&self, tick: &MetricsTick, json: Option) -> bool { self.count.fetch_add(1, Ordering::SeqCst); - *self.last_json.lock().unwrap() = json.map(str::to_owned); + *self.last_json.lock().unwrap() = json; self.alerts .lock() .unwrap() @@ -584,6 +583,30 @@ mod tests { engine.shutdown(); } + #[test] + fn every_sink_that_wants_json_gets_it() { + let engine = engine(true); + let (a, b, hidden) = ( + CountingSink::new(true), + CountingSink::new(true), + CountingSink::new(false), + ); + engine.subscribe("a".into(), a.clone()); + engine.subscribe("b".into(), b.clone()); + engine.subscribe("hidden".into(), hidden.clone()); + engine.refresh_now().unwrap(); + assert!(wait_for(|| { + [&a, &b, &hidden] + .iter() + .all(|s| s.count.load(Ordering::SeqCst) == 1) + })); + let json = a.last_json.lock().unwrap().clone(); + assert!(json.is_some()); + assert_eq!(*b.last_json.lock().unwrap(), json); + assert!(hidden.last_json.lock().unwrap().is_none()); + engine.shutdown(); + } + #[test] fn evaluates_alert_rules_and_logs_events() { let engine = engine(true); diff --git a/src-tauri/core/src/monitoring/process_history.rs b/src-tauri/core/src/monitoring/process_history.rs index 7cb97b9..ca313f5 100644 --- a/src-tauri/core/src/monitoring/process_history.rs +++ b/src-tauri/core/src/monitoring/process_history.rs @@ -15,37 +15,47 @@ const MAX_TRACKED_PROCESSES: usize = 4_096; /// process's history. type Key = (u32, u64); +struct Tracked { + points: RingBuffer, + /// The `record` call that last saw this process. + seen: u64, +} + #[derive(Default)] pub struct ProcessHistory { - map: HashMap>, + map: HashMap, + generation: u64, } impl ProcessHistory { pub fn record(&mut self, t: u64, rows: &[ProcessRow]) { - let mut next = HashMap::with_capacity(rows.len().min(MAX_TRACKED_PROCESSES)); + self.generation += 1; + let generation = self.generation; for row in rows.iter().take(MAX_TRACKED_PROCESSES) { - let key = (row.pid, row.start_time); - let mut buf = self + let tracked = self .map - .remove(&key) - .unwrap_or_else(|| RingBuffer::new(PROCESS_HISTORY_POINTS)); - buf.push(ProcessPoint { + .entry((row.pid, row.start_time)) + .or_insert_with(|| Tracked { + points: RingBuffer::new(PROCESS_HISTORY_POINTS), + seen: generation, + }); + tracked.seen = generation; + tracked.points.push(ProcessPoint { t, cpu: row.cpu_percent, memory: row.memory_bytes, read: row.disk_read_per_sec, write: row.disk_write_per_sec, }); - next.insert(key, buf); } // Anything not seen this tick has exited. - self.map = next; + self.map.retain(|_, tracked| tracked.seen == generation); } pub fn get(&self, pid: u32, start_time: u64) -> Vec { self.map .get(&(pid, start_time)) - .map(|b| b.iter().copied().collect()) + .map(|tracked| tracked.points.iter().copied().collect()) .unwrap_or_default() } } diff --git a/src-tauri/core/src/platform/linux/control.rs b/src-tauri/core/src/platform/linux/control.rs index 3251dac..42a111b 100644 --- a/src-tauri/core/src/platform/linux/control.rs +++ b/src-tauri/core/src/platform/linux/control.rs @@ -12,7 +12,7 @@ pub fn perform_action( ) -> AppResult<()> { use crate::models::ProcessAction; use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; - use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System}; + use sysinfo::{ProcessRefreshKind, ProcessStatus}; let target = libc::pid_t::try_from(pid) .ok() .filter(|p| *p > 0) @@ -24,43 +24,44 @@ pub fn perform_action( } // SAFETY: a successful pidfd_open returns a new descriptor, owned once. let fd = unsafe { OwnedFd::from_raw_fd(raw as i32) }; - let mut system = System::new().map_err(|_| AppError::EngineUnavailable)?; - let id = Pid::from_u32(pid); - system.refresh_processes_specifics( - ProcessesToUpdate::Some(&[id]), - true, - ProcessRefreshKind::nothing(), - ); - if system - .process(id) - .is_none_or(|p| p.start_time() != start_time) - { - return Err(AppError::ProcessNotFound(pid)); - } + let stopped = crate::platform::with_process( + pid, + start_time, + ProcessRefreshKind::nothing().without_tasks(), + |p| matches!(p.status(), ProcessStatus::Stop | ProcessStatus::Tracing), + )?; let sig = match action { ProcessAction::Terminate => libc::SIGTERM, ProcessAction::ForceKill => libc::SIGKILL, ProcessAction::Suspend => libc::SIGSTOP, ProcessAction::Resume => libc::SIGCONT, // shortcut: Linux has no pidfd priority API; the live check narrows, - // but cannot eliminate, the setpriority PID race. See SECURITY_AUDIT. + // but cannot eliminate, the setpriority PID race. See docs/archive/security-2026-11-03/SECURITY_AUDIT.md. ProcessAction::SetPriority { priority } => return set_priority(pid, priority), }; - // SAFETY: fd is live; null siginfo and zero flags request an ordinary signal. - let rc = unsafe { - libc::syscall( - libc::SYS_pidfd_send_signal, - fd.as_raw_fd(), - sig, - std::ptr::null::(), - 0, - ) + let send = |sig: libc::c_int| { + // SAFETY: fd is live; null siginfo and zero flags request an ordinary signal. + let rc = unsafe { + libc::syscall( + libc::SYS_pidfd_send_signal, + fd.as_raw_fd(), + sig, + std::ptr::null::(), + 0, + ) + }; + if rc == 0 { + Ok(()) + } else { + Err(errno_error(pid, "signal")) + } }; - if rc == 0 { - Ok(()) - } else { - Err(errno_error(pid, "signal")) + send(sig)?; + // A stopped process only acts on SIGTERM once it is continued. + if sig == libc::SIGTERM && stopped { + send(libc::SIGCONT)?; } + Ok(()) } /// Raising priority (negative nice) requires CAP_SYS_NICE; the kernel's diff --git a/src-tauri/core/src/platform/linux/inspect.rs b/src-tauri/core/src/platform/linux/inspect.rs index db698ab..2f0e879 100644 --- a/src-tauri/core/src/platform/linux/inspect.rs +++ b/src-tauri/core/src/platform/linux/inspect.rs @@ -120,6 +120,7 @@ pub fn sockets() -> AppResult { connection: to_connection(&entry, protocol), pid, process_name, + start_time: None, }); } } diff --git a/src-tauri/core/src/platform/linux/mod.rs b/src-tauri/core/src/platform/linux/mod.rs index 4cf63a3..be251c6 100644 --- a/src-tauri/core/src/platform/linux/mod.rs +++ b/src-tauri/core/src/platform/linux/mod.rs @@ -8,6 +8,7 @@ mod inspect; mod net; mod services; mod system; +mod tool; mod volumes; use std::collections::HashMap; @@ -18,7 +19,7 @@ pub use control::{is_critical, perform_action, system_root}; pub use hardware::hardware_info; pub use inspect::{find_file_users, memory_map, modules, open_files, sockets, threads}; pub use net::interface_kind; -pub use services::{control_service, services}; +pub use services::{control_service, service_caution, services}; pub use system::os_details; pub use volumes::{mount_volume, power_off_volume, unmount_volume, volumes}; diff --git a/src-tauri/core/src/platform/linux/services.rs b/src-tauri/core/src/platform/linux/services.rs index 1505692..b6ddfd0 100644 --- a/src-tauri/core/src/platform/linux/services.rs +++ b/src-tauri/core/src/platform/linux/services.rs @@ -13,6 +13,7 @@ use std::collections::HashMap; use std::process::Command; +use super::tool::run_tool; use crate::errors::{AppError, AppResult}; use crate::models::{ServiceAction, ServiceInfo, ServiceScope}; @@ -37,16 +38,73 @@ const PROTECTED_PREFIXES: &[&str] = &[ "plymouth", "udev", "init", + // User-scope desktop session: stopping these ends the graphical login. + "org.gnome.shell@", + "gnome-session", + "plasma-", ]; -fn protected(unit: &str) -> bool { - let name = unit - .strip_suffix(".service") +/// Services that may be stopped or restarted (restarting is often the fix), +/// but whose loss the user should understand first. +const CAUTIONS: &[(&str, bool, &str)] = &[ + ( + "ssh", + true, + "Remote logins depend on it. Anyone connected over SSH, including you, may be locked out.", + ), + ( + "sshd", + true, + "Remote logins depend on it. Anyone connected over SSH, including you, may be locked out.", + ), + ( + "accounts-daemon", + true, + "The login screen and user settings depend on it.", + ), + ( + "pipewire", + false, + "Sound and screen sharing stop until it runs again.", + ), + ( + "wireplumber", + false, + "Sound and screen sharing stop until it runs again.", + ), + ( + "xdg-desktop-portal", + false, + "File pickers, screen sharing and sandboxed apps depend on it.", + ), +]; + +fn base_name(unit: &str) -> String { + unit.strip_suffix(".service") .unwrap_or(unit) - .to_ascii_lowercase(); + .to_ascii_lowercase() +} + +fn protected(unit: &str) -> bool { + let name = base_name(unit); PROTECTED_PREFIXES.iter().any(|p| name.starts_with(p)) } +/// Why stopping or restarting `unit` needs extra thought, if it does. +pub fn service_caution(unit: &str) -> Option<&'static str> { + let name = base_name(unit); + CAUTIONS + .iter() + .find(|(n, exact, _)| { + if *exact { + name == *n + } else { + name.starts_with(n) + } + }) + .map(|(_, _, why)| *why) +} + fn valid_unit(unit: &str) -> bool { unit.len() <= 256 && unit.strip_suffix(".service").is_some_and(|name| { @@ -63,34 +121,22 @@ fn systemctl(scope: ServiceScope, args: &[&str]) -> AppResult { if scope == ServiceScope::User { command.arg("--user"); } - let output = command - .args(args) - .env("LC_ALL", "C") - .output() - .map_err(|e| { - if e.kind() == std::io::ErrorKind::NotFound { - AppError::UnsupportedOperation("Services (systemd is not available)".into()) + run_tool( + command.args(args), + || AppError::UnsupportedOperation("Services (systemd is not available)".into()), + |first| { + if first.contains("Access denied") + || first.contains("authentication") + || first.contains("Authentication") + { + AppError::PermissionDenied( + "Not authorized. Approve the system prompt, or ask an administrator.".into(), + ) + } else if first.contains("not found") || first.contains("not loaded") { + AppError::ActionUnavailable("That service no longer exists.".into()) } else { - AppError::SystemQueryFailed(format!("Could not run systemctl: {e}")) + AppError::SystemQueryFailed(first.to_string()) } - })?; - if output.status.success() { - return Ok(String::from_utf8_lossy(&output.stdout).into_owned()); - } - let stderr = String::from_utf8_lossy(&output.stderr); - let first = stderr.lines().next().unwrap_or("").trim(); - Err( - if first.contains("Access denied") - || first.contains("authentication") - || first.contains("Authentication") - { - AppError::PermissionDenied( - "Not authorized. Approve the system prompt, or ask an administrator.".into(), - ) - } else if first.contains("not found") || first.contains("not loaded") { - AppError::ActionUnavailable("That service no longer exists.".into()) - } else { - AppError::SystemQueryFailed(first.to_string()) }, ) } @@ -301,6 +347,10 @@ ssh.service disabled enabled "getty@tty1.service", "user@1000.service", "plymouth-start.service", + "org.gnome.Shell@wayland.service", + "gnome-session-manager@gnome.service", + "plasma-plasmashell.service", + "plasma-kwin_wayland.service", ] { assert!(protected(unit), "{unit}"); } @@ -309,11 +359,31 @@ ssh.service disabled enabled "bluetooth.service", "ssh.service", "docker.service", + "pipewire.service", ] { assert!(!protected(unit), "{unit}"); } } + #[test] + fn session_and_remote_access_services_carry_a_caution() { + for unit in [ + "ssh.service", + "sshd.service", + "accounts-daemon.service", + "pipewire.service", + "pipewire-pulse.service", + "wireplumber.service", + "xdg-desktop-portal-gnome.service", + ] { + assert!(service_caution(unit).is_some(), "{unit}"); + } + assert!(service_caution("ssh.service").unwrap().contains("SSH")); + for unit in ["ssh-agent.service", "cups.service", "sshguard.service"] { + assert!(service_caution(unit).is_none(), "{unit}"); + } + } + #[test] fn only_plain_service_names_are_accepted() { for ok in [ diff --git a/src-tauri/core/src/platform/linux/tool.rs b/src-tauri/core/src/platform/linux/tool.rs new file mode 100644 index 0000000..186667c --- /dev/null +++ b/src-tauri/core/src/platform/linux/tool.rs @@ -0,0 +1,60 @@ +//! Running system command-line tools (no shell) and turning their failures +//! into plain-language errors. + +use std::process::Command; + +use crate::errors::{AppError, AppResult}; + +/// Runs `command` and returns its standard output. `missing` is the error +/// when the tool is not installed; `failed` classifies the first line of +/// standard error when it exits unsuccessfully. +pub fn run_tool( + command: &mut Command, + missing: impl FnOnce() -> AppError, + failed: impl FnOnce(&str) -> AppError, +) -> AppResult { + let output = command.env("LC_ALL", "C").output().map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + missing() + } else { + AppError::SystemQueryFailed(format!( + "Could not run {}: {e}", + command.get_program().to_string_lossy() + )) + } + })?; + if output.status.success() { + return Ok(String::from_utf8_lossy(&output.stdout).into_owned()); + } + let stderr = String::from_utf8_lossy(&output.stderr); + Err(failed(stderr.lines().next().unwrap_or("").trim())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_missing_tools_and_failures() { + let missing = run_tool( + &mut Command::new("tasky-no-such-tool"), + || AppError::ActionUnavailable("missing".into()), + |_| unreachable!(), + ); + assert!(matches!(missing, Err(AppError::ActionUnavailable(m)) if m == "missing")); + + let failed = run_tool( + Command::new("sh").args(["-c", "echo 'first line' >&2; echo second >&2; exit 3"]), + || unreachable!(), + |first| AppError::SystemQueryFailed(first.to_owned()), + ); + assert!(matches!(failed, Err(AppError::SystemQueryFailed(m)) if m == "first line")); + + let ok = run_tool( + Command::new("echo").arg("hi"), + || unreachable!(), + |_| unreachable!(), + ); + assert_eq!(ok.unwrap(), "hi\n"); + } +} diff --git a/src-tauri/core/src/platform/linux/volumes.rs b/src-tauri/core/src/platform/linux/volumes.rs index 85ae156..553efb3 100644 --- a/src-tauri/core/src/platform/linux/volumes.rs +++ b/src-tauri/core/src/platform/linux/volumes.rs @@ -107,34 +107,32 @@ fn valid_device(device: &str) -> bool { fn udisksctl(args: &[&str]) -> AppResult { let mut command = Command::new("udisksctl"); - command.args(args).env("LC_ALL", "C"); + command.args(args); // Never wait on a password prompt; `info` does not take the option. if args.first() != Some(&"info") { command.arg("--no-user-interaction"); } - let output = command.output().map_err(|e| { - if e.kind() == std::io::ErrorKind::NotFound { + super::tool::run_tool( + &mut command, + || { AppError::ActionUnavailable( "Mounting drives needs UDisks. Install the \"udisks2\" package.".into(), ) - } else { - AppError::SystemQueryFailed(format!("Could not run udisksctl: {e}")) - } - })?; - if output.status.success() { - return Ok(String::from_utf8_lossy(&output.stdout).into_owned()); - } - let stderr = String::from_utf8_lossy(&output.stderr); - let first = stderr.lines().next().unwrap_or("").trim(); - Err(if first.contains("busy") { - AppError::ActionUnavailable( - "The drive is in use. Close any program using it and try again.".into(), - ) - } else if first.contains("Not authorized") || first.contains("not allowed") { - AppError::PermissionDenied("Your account is not allowed to do this to that drive.".into()) - } else { - AppError::SystemQueryFailed(first.to_string()) - }) + }, + |first| { + if first.contains("busy") { + AppError::ActionUnavailable( + "The drive is in use. Close any program using it and try again.".into(), + ) + } else if first.contains("Not authorized") || first.contains("not allowed") { + AppError::PermissionDenied( + "Your account is not allowed to do this to that drive.".into(), + ) + } else { + AppError::SystemQueryFailed(first.to_string()) + } + }, + ) } fn candidates() -> AppResult> { @@ -228,11 +226,14 @@ pub fn mount_volume(device: &str) -> AppResult> { #[derive(Debug, PartialEq, Eq)] struct BlockRow { path: String, + /// `/dev/`; `parent` refers to this, not to `path` + /// (a LUKS volume at `/dev/mapper/luks-…` is `dm-0` to its children). + kernel: String, parent: Option, mounted: bool, } -/// Reads `lsblk -J -o PATH,PKNAME,MOUNTPOINTS` (a flat list of devices). +/// Reads `lsblk -J --list -o PATH,KNAME,PKNAME,MOUNTPOINTS`. fn parse_block_rows(json: &str) -> Vec { let Ok(value) = serde_json::from_str::(json) else { return Vec::new(); @@ -242,8 +243,12 @@ fn parse_block_rows(json: &str) -> Vec { .into_iter() .flatten() .filter_map(|d| { + let path = d["path"].as_str()?.to_string(); Some(BlockRow { - path: d["path"].as_str()?.to_string(), + kernel: d["kname"] + .as_str() + .map_or_else(|| path.clone(), |k| format!("/dev/{k}")), + path, parent: d["pkname"].as_str().map(|p| format!("/dev/{p}")), mounted: d["mountpoints"] .as_array() @@ -254,17 +259,31 @@ fn parse_block_rows(json: &str) -> Vec { } /// The whole-disk device for `device`, and whether anything on that disk is -/// still mounted (the disk itself or any partition, system ones included). -fn disk_of(rows: &[BlockRow], device: &str) -> (String, bool) { - let disk = rows - .iter() - .find(|r| r.path == device) - .and_then(|r| r.parent.clone()) - .unwrap_or_else(|| device.to_string()); +/// still mounted: the disk itself or anything stacked on it (partitions, +/// LUKS, LVM), system volumes included. `None` when `device` is not listed, +/// so an incomplete listing never reads as "nothing mounted". +fn disk_of(rows: &[BlockRow], device: &str) -> Option<(String, bool)> { + let row = rows.iter().find(|r| r.path == device)?; + let disk = row.parent.clone().unwrap_or_else(|| device.to_string()); + // Stacked devices are listed once per parent, so grow the set of kernel + // names on this disk until it stops changing. + let mut on_disk = vec![disk.as_str()]; + loop { + let before = on_disk.len(); + for r in rows { + let child = r.parent.as_deref().is_some_and(|p| on_disk.contains(&p)); + if child && !on_disk.contains(&r.kernel.as_str()) { + on_disk.push(&r.kernel); + } + } + if on_disk.len() == before { + break; + } + } let busy = rows .iter() - .any(|r| (r.path == disk || r.parent.as_deref() == Some(disk.as_str())) && r.mounted); - (disk, busy) + .any(|r| r.mounted && on_disk.contains(&r.kernel.as_str())); + Some((disk, busy)) } /// Powers off the drive holding `device`, after all of its volumes are @@ -282,14 +301,21 @@ pub fn power_off_volume(device: &str) -> AppResult<()> { )); } let output = Command::new("lsblk") - .args(["-J", "-o", "PATH,PKNAME,MOUNTPOINTS"]) + .args(["-J", "--list", "-o", "PATH,KNAME,PKNAME,MOUNTPOINTS"]) .env("LC_ALL", "C") .output() .map_err(|e| AppError::SystemQueryFailed(format!("Could not list drives: {e}")))?; - let (disk, busy) = disk_of( - &parse_block_rows(&String::from_utf8_lossy(&output.stdout)), - &volume.device, - ); + // MOUNTPOINTS needs util-linux 2.37+; without it the drive can't be shown + // to be idle, so refuse rather than power off a disk that may be in use. + let rows = output + .status + .success() + .then(|| parse_block_rows(&String::from_utf8_lossy(&output.stdout))); + let Some((disk, busy)) = rows.and_then(|rows| disk_of(&rows, &volume.device)) else { + return Err(AppError::ActionUnavailable( + "Could not confirm that nothing on this drive is in use, so it was left on.".into(), + )); + }; if busy { return Err(AppError::ActionUnavailable( "Another volume on this drive is still mounted. Unmount it first.".into(), @@ -425,11 +451,47 @@ mod tests { ]}"#, ); // A sibling partition is still mounted, so sdb1 cannot be powered off yet. - assert_eq!(disk_of(&rows, "/dev/sdb1"), ("/dev/sdb".to_string(), true)); + assert_eq!( + disk_of(&rows, "/dev/sdb1"), + Some(("/dev/sdb".to_string(), true)) + ); // sdc has nothing mounted. - assert_eq!(disk_of(&rows, "/dev/sdc1"), ("/dev/sdc".to_string(), false)); + assert_eq!( + disk_of(&rows, "/dev/sdc1"), + Some(("/dev/sdc".to_string(), false)) + ); // A volume that is the whole disk is its own drive. - assert_eq!(disk_of(&rows, "/dev/sdb"), ("/dev/sdb".to_string(), true)); + assert_eq!( + disk_of(&rows, "/dev/sdb"), + Some(("/dev/sdb".to_string(), true)) + ); + } + + #[test] + fn power_off_sees_volumes_stacked_on_a_partition() { + let rows = parse_block_rows( + r#"{"blockdevices":[ + {"path":"/dev/sdb","pkname":null,"mountpoints":[null]}, + {"path":"/dev/sdb1","pkname":"sdb","mountpoints":[null]}, + {"path":"/dev/sdb2","kname":"sdb2","pkname":"sdb","mountpoints":[null]}, + {"path":"/dev/mapper/luks-1","kname":"dm-0","pkname":"sdb2","mountpoints":[null]}, + {"path":"/dev/mapper/vg-data","kname":"dm-1","pkname":"dm-0","mountpoints":["/data"]} + ]}"#, + ); + assert_eq!( + disk_of(&rows, "/dev/sdb1"), + Some(("/dev/sdb".to_string(), true)) + ); + } + + #[test] + fn power_off_refuses_when_the_device_is_not_listed() { + // An lsblk without MOUNTPOINTS support fails and yields no rows. + assert_eq!(disk_of(&parse_block_rows(""), "/dev/sdb1"), None); + let rows = parse_block_rows( + r#"{"blockdevices":[{"path":"/dev/sdc","pkname":null,"mountpoints":[null]}]}"#, + ); + assert_eq!(disk_of(&rows, "/dev/sdb1"), None); } #[test] diff --git a/src-tauri/core/src/platform/mod.rs b/src-tauri/core/src/platform/mod.rs index 8cd09f9..cc49bd1 100644 --- a/src-tauri/core/src/platform/mod.rs +++ b/src-tauri/core/src/platform/mod.rs @@ -113,6 +113,12 @@ pub fn control_service( linux_only::control_service(scope, unit, action, confirm) } +/// Why stopping or restarting a (not protected) service needs extra thought, +/// for the confirmation text. +pub fn service_caution(unit: &str) -> Option<&'static str> { + linux_only::service_caution(unit) +} + /// External drives the user may mount or unmount. System and internal /// partitions are never included. Linux only for now. pub fn volumes() -> crate::errors::AppResult> { @@ -185,7 +191,71 @@ pub fn connections(pid: u32) -> Option> { /// Every TCP/UDP socket with its owning process. pub fn sockets() -> crate::errors::AppResult { - imp::sockets() + let mut list = imp::sockets()?; + let mut owners: Vec = list.sockets.iter().filter_map(|s| s.pid).collect(); + owners.sort_unstable(); + owners.dedup(); + let starts = start_times(&owners); + for socket in &mut list.sockets { + socket.start_time = socket.pid.and_then(|p| starts.get(&p).copied()); + } + Ok(list) +} + +/// Start times (as in `ProcessRow::start_time`) of the given processes, read +/// in one query. Processes that exited or report no start time are absent. +fn start_times(pids: &[u32]) -> std::collections::HashMap { + use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System}; + if pids.is_empty() { + return std::collections::HashMap::new(); + } + let Ok(mut system) = System::new() else { + return std::collections::HashMap::new(); + }; + let ids: Vec = pids.iter().map(|&p| Pid::from_u32(p)).collect(); + system.refresh_processes_specifics( + ProcessesToUpdate::Some(&ids), + true, + ProcessRefreshKind::nothing().without_tasks(), + ); + system + .processes() + .iter() + .filter(|(_, p)| p.start_time() > 0) + .map(|(pid, p)| (pid.as_u32(), p.start_time())) + .collect() +} + +/// Runs `read` on `pid` only while it is still the instance that started at +/// `start_time` (seconds since the epoch, as in `ProcessRow`), so a reused +/// PID never answers for the process the user picked. Reads only that PID. +pub fn with_process( + pid: u32, + start_time: u64, + kind: sysinfo::ProcessRefreshKind, + read: impl FnOnce(&sysinfo::Process) -> T, +) -> crate::errors::AppResult { + use sysinfo::{Pid, ProcessesToUpdate, System}; + let mut system = + System::new().map_err(|e| crate::errors::AppError::SystemQueryFailed(e.to_string()))?; + let id = Pid::from_u32(pid); + system.refresh_processes_specifics(ProcessesToUpdate::Some(&[id]), true, kind); + system + .process(id) + .filter(|p| p.start_time() == start_time) + .map(read) + .ok_or(crate::errors::AppError::ProcessNotFound(pid)) +} + +/// Fails with `ProcessNotFound` unless `pid` is still the instance that +/// started at `start_time`. +pub fn verify_identity(pid: u32, start_time: u64) -> crate::errors::AppResult<()> { + with_process( + pid, + start_time, + sysinfo::ProcessRefreshKind::nothing().without_tasks(), + |_| (), + ) } /// Files, directories and other objects `pid` holds open. @@ -323,6 +393,10 @@ mod fallback { )) } + pub fn service_caution(_unit: &str) -> Option<&'static str> { + None + } + pub fn volumes() -> crate::errors::AppResult> { Ok(Vec::new()) } @@ -349,3 +423,25 @@ mod fallback { crate::errors::AppError::UnsupportedOperation("Startup items".into()) } } + +#[cfg(all(test, target_os = "linux"))] +mod tests { + #[test] + fn sockets_carry_the_owner_start_time_and_it_verifies() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let me = std::process::id(); + let mine = super::sockets() + .unwrap() + .sockets + .into_iter() + .find(|s| s.connection.local_port == port && s.pid == Some(me)) + .expect("own listener is listed with its owner"); + let start = mine.start_time.expect("owner start time"); + super::verify_identity(me, start).unwrap(); + assert!(matches!( + super::verify_identity(me, start + 1), + Err(crate::errors::AppError::ProcessNotFound(_)) + )); + } +} diff --git a/src-tauri/core/src/platform/windows/inspect.rs b/src-tauri/core/src/platform/windows/inspect.rs index 453680a..a9a5313 100644 --- a/src-tauri/core/src/platform/windows/inspect.rs +++ b/src-tauri/core/src/platform/windows/inspect.rs @@ -101,6 +101,7 @@ pub fn sockets() -> AppResult { connection, pid, process_name: pid.and_then(|p| names.get(&p).cloned()), + start_time: None, } }) .collect(); diff --git a/src-tauri/core/src/process_control/mod.rs b/src-tauri/core/src/process_control/mod.rs index 8ed7dc0..fa777e6 100644 --- a/src-tauri/core/src/process_control/mod.rs +++ b/src-tauri/core/src/process_control/mod.rs @@ -83,6 +83,9 @@ pub fn perform(engine: &Engine, req: ActionRequest) -> AppResult if cfg!(windows) { format!("Asked {name} (PID {pid}) to close. It may prompt to save first.") } else { + // The platform continues a stopped process so it can handle + // the request; if it survives, it is no longer suspended. + engine.set_suspended(pid, row.start_time, false); format!("Sent a termination request to {name} (PID {pid}).") } } diff --git a/src-tauri/core/src/process_control/protection.rs b/src-tauri/core/src/process_control/protection.rs index b5b83f9..aa8f450 100644 --- a/src-tauri/core/src/process_control/protection.rs +++ b/src-tauri/core/src/process_control/protection.rs @@ -54,12 +54,27 @@ const LINUX_SYSTEM: &[&str] = &[ "gdm3", "sddm", "lightdm", +]; + +/// Display servers, compositors and session managers. They run as the +/// logged-in user, so the root-only daemon rule never sees them, yet ending +/// one ends the session and suspending one freezes the screen, leaving no +/// way to click Resume. (`gnome-session-b` is the 15-character kernel name.) +const LINUX_SESSION: &[&str] = &[ + "systemd", "Xorg", "Xwayland", "gnome-shell", + "gnome-session-b", + "gnome-session-binary", + "mutter", "plasmashell", + "ksmserver", "kwin_wayland", "kwin_x11", + "sway", + "Hyprland", + "weston", ]; pub fn assess( @@ -75,6 +90,15 @@ pub fn assess( ProtectionLevel::Blocked, "This is Tasky itself. Close the window or use Quit instead.", ); + } else if facts.parent_pid == Some(facts.self_pid) + && facts.name.starts_with("WebKit") + && disruptive + { + // Other children (e.g. a file manager opened from Tasky) stay manageable. + a.add( + ProtectionLevel::Blocked, + "This is part of Tasky: it draws the Tasky window or fetches for it. Quit Tasky instead.", + ); } match platform { @@ -180,6 +204,17 @@ fn assess_unix(facts: &ProcessFacts<'_>, action: ProcessAction, a: &mut Protecti ); return; } + if LINUX_SESSION.contains(&facts.name) { + a.add( + ProtectionLevel::Warning, + if matches!(action, ProcessAction::Suspend) { + "This runs your desktop session. Suspending it freezes the screen and input, so you may not be able to resume it." + } else { + "This runs your desktop session. Ending it may log you out and close every open app." + }, + ); + return; + } let is_root = facts.user == Some("root"); let known_daemon = LINUX_SYSTEM.contains(&facts.name) || facts.name.starts_with("systemd-"); if is_root && known_daemon { @@ -380,6 +415,64 @@ mod tests { ); } + #[test] + fn user_owned_desktop_session_is_warned_about() { + let shell = ProcessFacts { + pid: 2100, + parent_pid: Some(1900), + name: "gnome-shell", + user: Some("alice"), + self_pid: 4242, + ..Default::default() + }; + let suspend = assess(Platform::Linux, &shell, ProcessAction::Suspend); + assert_eq!(suspend.level, ProtectionLevel::Warning); + assert!( + suspend + .reasons + .iter() + .any(|r| r.contains("freezes the screen")) + ); + assert_eq!( + level(Platform::Linux, &shell, ProcessAction::Terminate), + ProtectionLevel::Warning + ); + assert_eq!( + level(Platform::Linux, &shell, ProcessAction::Resume), + ProtectionLevel::Allowed + ); + } + + #[test] + fn taskys_own_helpers_are_blocked() { + let webview = ProcessFacts { + pid: 4300, + parent_pid: Some(4242), + name: "WebKitWebProcess", + user: Some("alice"), + self_pid: 4242, + ..Default::default() + }; + for action in [ + ProcessAction::Suspend, + ProcessAction::Terminate, + ProcessAction::ForceKill, + ] { + assert_eq!( + level(Platform::Linux, &webview, action), + ProtectionLevel::Blocked + ); + } + let opened_app = ProcessFacts { + name: "nautilus", + ..webview + }; + assert_eq!( + level(Platform::Linux, &opened_app, ProcessAction::Terminate), + ProtectionLevel::Allowed + ); + } + #[test] fn high_priority_warns_everywhere() { let f = win("game.exe", None); diff --git a/src-tauri/core/src/sync_util.rs b/src-tauri/core/src/sync_util.rs new file mode 100644 index 0000000..7e4d9fa --- /dev/null +++ b/src-tauri/core/src/sync_util.rs @@ -0,0 +1,39 @@ +//! Poison-tolerant lock access. Shared state here stays structurally valid +//! even if a holder panicked, so one failed operation must not take every +//! later caller down with it. + +use std::sync::{Mutex, MutexGuard, PoisonError, RwLock, RwLockReadGuard, RwLockWriteGuard}; + +pub fn lock(m: &Mutex) -> MutexGuard<'_, T> { + m.lock().unwrap_or_else(PoisonError::into_inner) +} + +pub fn read(l: &RwLock) -> RwLockReadGuard<'_, T> { + l.read().unwrap_or_else(PoisonError::into_inner) +} + +pub fn write(l: &RwLock) -> RwLockWriteGuard<'_, T> { + l.write().unwrap_or_else(PoisonError::into_inner) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_poisoned_lock_still_yields_its_data() { + let m = std::sync::Arc::new(Mutex::new(1)); + let l = std::sync::Arc::new(RwLock::new(2)); + let (m2, l2) = (m.clone(), l.clone()); + let _ = std::thread::spawn(move || { + let _g = m2.lock().unwrap(); + let _w = l2.write().unwrap(); + panic!("poison both"); + }) + .join(); + assert!(m.is_poisoned() && l.is_poisoned()); + *lock(&m) += 1; + *write(&l) += 1; + assert_eq!((*lock(&m), *read(&l)), (2, 3)); + } +} diff --git a/src-tauri/linux/tasky.1 b/src-tauri/linux/tasky.1 index 2ef8c88..3f0c954 100644 --- a/src-tauri/linux/tasky.1 +++ b/src-tauri/linux/tasky.1 @@ -1,4 +1,4 @@ -.TH TASKY 1 "November 2026" "Tasky 0.1.4" "User Commands" +.TH TASKY 1 "November 2026" "Tasky 0.1.5" "User Commands" .SH NAME tasky \- task manager and system monitor .SH SYNOPSIS diff --git a/src-tauri/src/commands/benchmarks.rs b/src-tauri/src/commands/benchmarks.rs index ff77b0b..dc8475e 100644 --- a/src-tauri/src/commands/benchmarks.rs +++ b/src-tauri/src/commands/benchmarks.rs @@ -109,15 +109,17 @@ pub async fn start_benchmark( .await } -/// Locations the disk benchmark may use. +/// Locations the disk benchmark may use. Checking each one touches the +/// filesystem (a slow or hung mount must not stall the UI thread). #[tauri::command] -pub fn list_disk_targets(state: State<'_, AppState>) -> Vec { - state.benchmarks.disk_targets() +pub async fn list_disk_targets(state: State<'_, AppState>) -> AppResult> { + let benchmarks = Arc::clone(&state.benchmarks); + super::blocking::run(move || Ok(benchmarks.disk_targets())).await } /// What a disk run would write, or why it cannot happen. #[tauri::command] -pub fn disk_plan( +pub async fn disk_plan( state: State<'_, AppState>, target: String, profile: BenchmarkProfile, @@ -125,7 +127,8 @@ pub fn disk_plan( if !valid_id(&target) { return Err(AppError::InvalidArgument("Not a valid id.".into())); } - state.benchmarks.disk_plan(&target, profile) + let benchmarks = Arc::clone(&state.benchmarks); + super::blocking::run(move || benchmarks.disk_plan(&target, profile)).await } /// What an internet run would send and receive, and to whom. @@ -152,9 +155,11 @@ pub fn get_benchmark_status(state: State<'_, AppState>) -> BenchmarkStatus { state.benchmarks.status() } +/// Up to 200 full results; cloning them is kept off the UI thread. #[tauri::command] -pub fn get_benchmark_history(state: State<'_, AppState>) -> Vec { - state.benchmarks.history() +pub async fn get_benchmark_history(state: State<'_, AppState>) -> AppResult> { + let benchmarks = Arc::clone(&state.benchmarks); + super::blocking::run(move || Ok(benchmarks.history())).await } #[tauri::command] diff --git a/src-tauri/src/commands/blocking.rs b/src-tauri/src/commands/blocking.rs index 0449f34..3fda2fa 100644 --- a/src-tauri/src/commands/blocking.rs +++ b/src-tauri/src/commands/blocking.rs @@ -1,11 +1,16 @@ //! Bound queued and active on-demand work before entering Tauri's worker pool. use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Mutex, MutexGuard, TryLockError}; use tm_core::errors::{AppError, AppResult}; static ACTIVE: AtomicUsize = AtomicUsize::new(0); const MAX_ACTIVE: usize = 8; +/// Native dialogs wait on the user, possibly for minutes, so they do not +/// count toward [`MAX_ACTIVE`]; instead only one is open at a time. +static DIALOG: Mutex<()> = Mutex::new(()); + struct Permit; impl Permit { fn acquire() -> AppResult { @@ -29,6 +34,15 @@ impl Drop for Permit { } } +/// Held while a native dialog is shown. Busy if another one is open. +pub fn dialog_slot() -> AppResult> { + match DIALOG.try_lock() { + Ok(guard) => Ok(guard), + Err(TryLockError::Poisoned(e)) => Ok(e.into_inner()), + Err(TryLockError::WouldBlock) => Err(AppError::OperationBusy), + } +} + pub async fn run( f: impl FnOnce() -> AppResult + Send + 'static, ) -> AppResult { @@ -41,6 +55,19 @@ pub async fn run( .map_err(|_| AppError::SystemQueryFailed("Background operation failed.".into()))? } +/// Runs `f`, which shows a native file dialog, holding the dialog slot +/// rather than a worker permit. +pub async fn dialog( + f: impl FnOnce() -> AppResult + Send + 'static, +) -> AppResult { + tauri::async_runtime::spawn_blocking(move || { + let _slot = dialog_slot()?; + f() + }) + .await + .map_err(|_| AppError::SystemQueryFailed("Background operation failed.".into()))? +} + #[cfg(test)] mod tests { use super::*; @@ -53,4 +80,16 @@ mod tests { drop(permits); assert!(Permit::acquire().is_ok()); } + + #[test] + fn one_dialog_at_a_time_independent_of_the_worker_cap() { + let permits: Vec<_> = (0..MAX_ACTIVE) + .map(|_| Permit::acquire().unwrap()) + .collect(); + let slot = dialog_slot().expect("dialogs are not limited by busy workers"); + assert!(matches!(dialog_slot(), Err(AppError::OperationBusy))); + drop(slot); + assert!(dialog_slot().is_ok()); + drop(permits); + } } diff --git a/src-tauri/src/commands/confirm.rs b/src-tauri/src/commands/confirm.rs index ccd3c2a..4465fc6 100644 --- a/src-tauri/src/commands/confirm.rs +++ b/src-tauri/src/commands/confirm.rs @@ -5,10 +5,8 @@ use tauri::{AppHandle, Runtime}; use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind}; use tm_core::errors::{AppError, AppResult}; -static DIALOG: std::sync::Mutex<()> = std::sync::Mutex::new(()); - pub fn confirm(app: &AppHandle, message: &str) -> AppResult<()> { - let _guard = DIALOG.try_lock().map_err(|_| AppError::OperationBusy)?; + let _slot = super::blocking::dialog_slot()?; if app .dialog() .message(message) diff --git a/src-tauri/src/commands/export.rs b/src-tauri/src/commands/export.rs index 3412a0b..4468b8a 100644 --- a/src-tauri/src/commands/export.rs +++ b/src-tauri/src/commands/export.rs @@ -26,8 +26,16 @@ fn failed(e: impl std::fmt::Display) -> AppError { AppError::SystemQueryFailed(format!("export failed: {e}")) } -/// Asks for a destination and writes `contents`. Returns the saved path, or -/// `None` if the user cancelled. +/// Content ready to save: the default file name and a (label, extension) filter. +type Export = (String, String, (&'static str, &'static str)); + +/// Asks for a destination and writes the export. Returns the saved path, or +/// `None` if the user cancelled. The dialog does not hold a worker. +async fn save(app: AppHandle, prepared: Export) -> AppResult> { + let (contents, default_name, filter) = prepared; + super::blocking::dialog(move || save_with_dialog(&app, &default_name, filter, contents)).await +} + fn save_with_dialog( app: &AppHandle, default_name: &str, @@ -68,9 +76,10 @@ pub async fn export_processes( include_command_lines: bool, ) -> AppResult> { let engine = Arc::clone(&state.engine); - blocking(move || { + let confirm_app = app.clone(); + let prepared = blocking(move || { if include_command_lines { - super::confirm::confirm(&app, "This export includes process command lines, which may contain passwords or tokens. Continue with this sensitive export?")?; + super::confirm::confirm(&confirm_app, "This export includes process command lines, which may contain passwords or tokens. Continue with this sensitive export?")?; } let tick = engine.latest().ok_or(AppError::EngineUnavailable)?; let (contents, ext, label) = match format { @@ -85,14 +94,14 @@ pub async fn export_processes( "JSON", ), }; - save_with_dialog( - &app, - &format!("processes-{}.{ext}", timestamp()), - (label, ext), + Ok(( contents, - ) + format!("processes-{}.{ext}", timestamp()), + (label, ext), + )) }) - .await + .await?; + save(app, prepared).await } #[tauri::command] @@ -101,17 +110,17 @@ pub async fn export_system_summary( state: State<'_, AppState>, ) -> AppResult> { let engine = Arc::clone(&state.engine); - blocking(move || { + let prepared = blocking(move || { let tick = engine.latest().ok_or(AppError::EngineUnavailable)?; let json = export::system_summary_json(engine.static_info(), &tick).map_err(failed)?; - save_with_dialog( - &app, - &format!("system-summary-{}.json", timestamp()), - ("JSON", "json"), + Ok(( json, - ) + format!("system-summary-{}.json", timestamp()), + ("JSON", "json"), + )) }) - .await + .await?; + save(app, prepared).await } #[derive(Serialize)] @@ -143,26 +152,27 @@ pub async fn export_diagnostics( let engine = Arc::clone(&state.engine); let settings = state.settings.get(); let recent_log = state.logging.recent(); - blocking(move || { + let confirm_app = app.clone(); + let prepared = blocking(move || { if include_processes { let message = if include_command_lines { "Diagnostics will include process information and command lines, which may contain passwords or tokens, plus hostname, alert rules and recent errors. Continue?" } else { "Diagnostics will include process information, hostname, alert rules and recent errors, which may be private. Continue?" }; - super::confirm::confirm(&app, message)?; + super::confirm::confirm(&confirm_app, message)?; } let mut diagnostics = collect_diagnostics(&engine, settings, recent_log); diagnostics.apply_privacy(include_processes, include_command_lines); let json = serde_json::to_string_pretty(&diagnostics).map_err(failed)?; - save_with_dialog( - &app, - &format!("taskmanager-diagnostics-{}.json", timestamp()), - ("JSON", "json"), + Ok(( json, - ) + format!("taskmanager-diagnostics-{}.json", timestamp()), + ("JSON", "json"), + )) }) - .await + .await?; + save(app, prepared).await } fn collect_diagnostics( diff --git a/src-tauri/src/commands/inspect.rs b/src-tauri/src/commands/inspect.rs index 5be8be5..7a8440c 100644 --- a/src-tauri/src/commands/inspect.rs +++ b/src-tauri/src/commands/inspect.rs @@ -6,7 +6,7 @@ use super::blocking::run as blocking; use std::sync::Arc; use std::time::Instant; -use tauri::{AppHandle, Manager, Runtime, State}; +use tauri::{AppHandle, Runtime, State}; use tauri_plugin_dialog::DialogExt; use tm_core::errors::{AppError, AppResult}; use tm_core::models::{ @@ -34,19 +34,9 @@ pub async fn set_startup_enabled(app: AppHandle, id: String, enabled: bool) -> A )); } blocking(move || { - super::confirm::confirm( - &app, - &format!( - "{} startup entry {}? This changes what runs at your next login.", - if enabled { "Enable" } else { "Disable" }, - id.escape_debug() - ), - )?; + super::confirm::confirm(&app, &startup_confirmation(&id, enabled))?; if id == crate::autostart::entry_id(&app) { - let state = app.state::(); - let mut settings = state.settings.get(); - settings.start_with_os = enabled; - super::settings::save_settings(&app, &state, settings).map(|_| ()) + super::settings::save_settings(&app, |s| s.start_with_os = enabled).map(|_| ()) } else { platform::set_startup_enabled(&id, enabled) } @@ -54,6 +44,14 @@ pub async fn set_startup_enabled(app: AppHandle, id: String, enabled: bool) -> A .await } +pub fn startup_confirmation(id: &str, enabled: bool) -> String { + format!( + "{} startup entry {}? This changes what runs at your next login.", + if enabled { "Enable" } else { "Disable" }, + id.escape_debug() + ) +} + /// Every TCP/UDP socket with its owning process (Ports page). #[tauri::command] pub async fn list_sockets() -> AppResult { @@ -122,7 +120,7 @@ pub async fn pick_path( app: AppHandle, directory: bool, ) -> AppResult> { - blocking(move || { + super::blocking::dialog(move || { let dialog = app.dialog().file(); let picked = if directory { dialog.blocking_pick_folder() diff --git a/src-tauri/src/commands/monitoring.rs b/src-tauri/src/commands/monitoring.rs index 26c48b6..e395d3d 100644 --- a/src-tauri/src/commands/monitoring.rs +++ b/src-tauri/src/commands/monitoring.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use tauri::ipc::{Channel, InvokeResponseBody}; -use tauri::{AppHandle, Runtime, State, Webview}; +use tauri::{AppHandle, Manager, Runtime, State, Webview}; use crate::desktop::{WindowVisibility, update_pause_label}; use crate::state::AppState; @@ -28,12 +28,9 @@ impl TickSink for ChannelSink { self.visibility.get() } - fn deliver(&self, _tick: &MetricsTick, json: Option<&str>) -> bool { + fn deliver(&self, _tick: &MetricsTick, json: Option) -> bool { match json { - Some(json) => self - .channel - .send(InvokeResponseBody::Json(json.to_owned())) - .is_ok(), + Some(json) => self.channel.send(InvokeResponseBody::Json(json)).is_ok(), None => true, } } @@ -85,29 +82,31 @@ pub fn get_monitoring_state(state: State<'_, AppState>) -> MonitoringState { state.engine.state() } +/// Persisting the interval writes the settings file, so this runs off the UI thread. #[tauri::command] -pub fn set_monitoring_state( +pub async fn set_monitoring_state( app: AppHandle, - state: State<'_, AppState>, paused: bool, interval_ms: u32, ) -> AppResult { let interval_ms = validate_interval(interval_ms)?; - let result = state.engine.set_state(MonitoringState { - paused, - interval_ms, - })?; - // The interval is a persisted setting; pausing is per session. - let mut settings = state.settings.get(); - if settings.refresh_interval_ms != interval_ms { - settings.refresh_interval_ms = interval_ms; - let saved = state.settings.save(settings)?; - state - .engine - .set_history_capacity(crate::commands::settings::history_points(&saved)); - } - update_pause_label(&app, paused); - Ok(result) + super::blocking::run(move || { + let state = app.state::(); + let result = state.engine.set_state(MonitoringState { + paused, + interval_ms, + })?; + // The interval is a persisted setting; pausing is per session. + if state.settings.get().refresh_interval_ms != interval_ms { + state.settings.update( + |s| s.refresh_interval_ms = interval_ms, + |saved| super::settings::apply_runtime(&state, saved), + )?; + } + update_pause_label(&app, paused); + Ok(result) + }) + .await } #[tauri::command] diff --git a/src-tauri/src/commands/processes.rs b/src-tauri/src/commands/processes.rs index 5e756f2..08c5b31 100644 --- a/src-tauri/src/commands/processes.rs +++ b/src-tauri/src/commands/processes.rs @@ -10,13 +10,14 @@ use tm_core::errors::AppResult; use tm_core::models::ProcessDetails; use tm_core::monitoring::details; -/// Details for one process. Runs on a blocking worker so OS queries never -/// stall the UI thread. +/// Details for one process instance, identified by PID and start time so a +/// reused PID is never shown as the process the user opened. Runs on a +/// blocking worker so OS queries never stall the UI thread. #[tauri::command] pub async fn get_process_details( state: State<'_, AppState>, pid: i64, - start_time: Option, + start_time: u64, ) -> AppResult { let pid = validate::pid(pid)?; let engine = Arc::clone(&state.engine); diff --git a/src-tauri/src/commands/services.rs b/src-tauri/src/commands/services.rs index a6e233e..b30a1dc 100644 --- a/src-tauri/src/commands/services.rs +++ b/src-tauri/src/commands/services.rs @@ -30,10 +30,13 @@ pub async fn control_service( } else { "Restart" }; + let caution = platform::service_caution(&service.unit) + .map(|why| format!("\n{why}")) + .unwrap_or_default(); super::confirm::confirm( &app, &format!( - "{verb} {}?\n{}\nPrograms that depend on it may stop working.", + "{verb} {}?\n{}\nPrograms that depend on it may stop working.{caution}", service.unit, service.description ), ) diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 86172b1..71b8aa4 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -2,53 +2,73 @@ use std::sync::atomic::Ordering; -use tauri::{AppHandle, Runtime, State}; +use tauri::{AppHandle, Manager, Runtime, State}; use tm_core::errors::AppResult; use tm_core::models::{AlertEvent, MonitoringState, Settings}; use tm_core::monitoring::engine::MAX_HISTORY_POINTS; +use super::blocking::run as blocking; use crate::state::AppState; +/// Reading the login entry touches the disk, so this runs off the UI thread. #[tauri::command] -pub fn get_settings( - app: AppHandle, - state: State<'_, AppState>, -) -> AppResult { - let mut settings = state.settings.get(); - settings.start_with_os = crate::autostart::is_enabled(&app)?; - Ok(settings) +pub async fn get_settings(app: AppHandle) -> AppResult { + blocking(move || { + let mut settings = app.state::().settings.get(); + settings.start_with_os = crate::autostart::is_enabled(&app)?; + Ok(settings) + }) + .await } /// Rolls autostart back if persistence fails; one store lock covers both changes. #[tauri::command] -pub fn update_settings( +pub async fn update_settings( app: AppHandle, - state: State<'_, AppState>, settings: Settings, ) -> AppResult { - save_settings(&app, &state, settings) + blocking(move || { + confirm_autostart_change(&app, settings.start_with_os)?; + save_settings(&app, |s| *s = settings) + }) + .await } #[tauri::command] -pub fn reset_settings( - app: AppHandle, - state: State<'_, AppState>, -) -> AppResult { - save_settings(&app, &state, Settings::default()) +pub async fn reset_settings(app: AppHandle) -> AppResult { + blocking(move || { + let defaults = Settings::default(); + confirm_autostart_change(&app, defaults.start_with_os)?; + save_settings(&app, |s| *s = defaults) + }) + .await } +/// Starting Tasky at login is a startup entry like any other, so changing it +/// asks first, exactly as on the Startup page. +fn confirm_autostart_change(app: &AppHandle, enabled: bool) -> AppResult<()> { + if crate::autostart::is_enabled(app)? == enabled { + return Ok(()); + } + super::confirm::confirm( + app, + &super::inspect::startup_confirmation(&crate::autostart::entry_id(app), enabled), + ) +} + +/// Read-modify-persist under the settings lock, then push the result into +/// running components before the lock is released. pub fn save_settings( app: &AppHandle, - state: &AppState, - settings: Settings, + change: impl FnOnce(&mut Settings), ) -> AppResult { - let saved = state.settings.save_with_autostart( - settings, + let state = app.state::(); + state.settings.update_with_autostart( + change, || crate::autostart::is_enabled(app), |enabled| crate::autostart::set(app, enabled), - )?; - apply_runtime(state, &saved); - Ok(saved) + |saved| apply_runtime(&state, saved), + ) } #[tauri::command] diff --git a/src-tauri/src/commands/volumes.rs b/src-tauri/src/commands/volumes.rs index b8206e6..e67e8ea 100644 --- a/src-tauri/src/commands/volumes.rs +++ b/src-tauri/src/commands/volumes.rs @@ -1,8 +1,10 @@ //! Mount and unmount external drives. The platform layer decides which -//! volumes are eligible; these commands only run it on a blocking worker. +//! volumes are eligible; these commands add the OS confirmation for the +//! disruptive actions and run everything on a blocking worker. use super::blocking::run as blocking; -use tm_core::errors::AppResult; +use tauri::AppHandle; +use tm_core::errors::{AppError, AppResult}; use tm_core::models::Volume; use tm_core::platform; @@ -18,14 +20,73 @@ pub async fn mount_volume(device: String) -> AppResult> { blocking(move || platform::mount_volume(&device)).await } -/// Powers off the drive holding an unmounted, listed volume. +/// Powers off the drive holding an unmounted, listed volume. Asks first. #[tauri::command] -pub async fn power_off_volume(device: String) -> AppResult<()> { - blocking(move || platform::power_off_volume(&device)).await +pub async fn power_off_volume(app: AppHandle, device: String) -> AppResult<()> { + blocking(move || { + let name = listed_name(&device)?; + super::confirm::confirm( + &app, + &format!( + "Power off the drive holding {name}?\nIt can't be used again until it is unplugged and reconnected." + ), + )?; + platform::power_off_volume(&device) + }) + .await } -/// Unmounts a listed drive. Refused for system and internal partitions. +/// Unmounts a listed drive. Refused for system and internal partitions. Asks first. #[tauri::command] -pub async fn unmount_volume(device: String) -> AppResult<()> { - blocking(move || platform::unmount_volume(&device)).await +pub async fn unmount_volume(app: AppHandle, device: String) -> AppResult<()> { + blocking(move || { + let name = listed_name(&device)?; + super::confirm::confirm( + &app, + &format!("Unmount {name}?\nPrograms using files on it may lose unsaved work."), + )?; + platform::unmount_volume(&device) + }) + .await +} + +/// How the prompt names a listed volume: its label and device, from the +/// platform's own list rather than from the request. +fn listed_name(device: &str) -> AppResult { + let volume = platform::volumes()? + .into_iter() + .find(|v| v.device == device) + .ok_or_else(|| AppError::ActionUnavailable("That drive is no longer available.".into()))?; + Ok(display_name(&volume)) +} + +fn display_name(volume: &Volume) -> String { + match &volume.label { + Some(label) => format!("\"{}\" ({})", label.escape_debug(), volume.device), + None => volume.device.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn volume(label: Option<&str>) -> Volume { + Volume { + device: "/dev/sdb1".into(), + label: label.map(str::to_owned), + file_system: "vfat".into(), + size_bytes: 1, + mount_point: None, + can_power_off: true, + } + } + + #[test] + fn prompts_name_the_label_and_device() { + assert_eq!(display_name(&volume(Some("USB"))), "\"USB\" (/dev/sdb1)"); + assert_eq!(display_name(&volume(None)), "/dev/sdb1"); + // A label cannot inject extra lines into the prompt. + assert_eq!(display_name(&volume(Some("A\nB"))), "\"A\\nB\" (/dev/sdb1)"); + } } diff --git a/src-tauri/src/desktop.rs b/src-tauri/src/desktop.rs index b25599c..412361d 100644 --- a/src-tauri/src/desktop.rs +++ b/src-tauri/src/desktop.rs @@ -133,13 +133,43 @@ pub fn prefer_x11_on_gnome_wayland() {} /// after login the shell may not have started its tray yet. const TRAY_WAIT_SECS: u64 = 15; +/// Last answer of [`tray_host_available`] and when it was given (Unix ms). +static TRAY_HOST: AtomicBool = AtomicBool::new(false); +static TRAY_HOST_CHECKED_MS: AtomicU64 = AtomicU64::new(0); +const TRAY_RECHECK_MS: u64 = 30_000; + /// Whether something on this desktop shows tray icons: an owner of the /// StatusNotifier watcher name, which KDE, Xfce and GNOME's AppIndicator /// extension all provide. Stock GNOME does not, so a window hidden "to the /// tray" there could never be brought back. If the bus cannot be asked, the /// answer is no: the safe failure is a window that stays visible. -#[cfg(target_os = "linux")] +/// +/// Asks the session bus (up to 500 ms); use [`tray_host_cached`] on the main thread. pub fn tray_host_available() -> bool { + let available = query_tray_host(); + TRAY_HOST.store(available, Ordering::Relaxed); + TRAY_HOST_CHECKED_MS.store(tm_core::monitoring::unix_millis().max(1), Ordering::Relaxed); + available +} + +/// [`tray_host_available`] as of the last check, without waiting on the bus. +/// A stale answer is refreshed in the background for the next caller; before +/// the first check completes the answer is no. +pub fn tray_host_cached() -> bool { + let now = tm_core::monitoring::unix_millis(); + let last = TRAY_HOST_CHECKED_MS.load(Ordering::Relaxed); + if now.saturating_sub(last) >= TRAY_RECHECK_MS + && TRAY_HOST_CHECKED_MS + .compare_exchange(last, now, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + { + std::thread::spawn(tray_host_available); + } + TRAY_HOST.load(Ordering::Relaxed) +} + +#[cfg(target_os = "linux")] +fn query_tray_host() -> bool { use gio::glib::{ToVariant, VariantTy}; let ask = || -> Result { @@ -162,7 +192,7 @@ pub fn tray_host_available() -> bool { /// Other platforms always have a tray or taskbar to come back from. #[cfg(not(target_os = "linux"))] -pub fn tray_host_available() -> bool { +fn query_tray_host() -> bool { true } @@ -289,7 +319,7 @@ impl TickSink for DesktopSink { false } - fn deliver(&self, tick: &MetricsTick, _json: Option<&str>) -> bool { + fn deliver(&self, tick: &MetricsTick, _json: Option) -> bool { let last = self.last_tray_update.load(Ordering::Relaxed); if tick.timestamp_ms.saturating_sub(last) >= TRAY_UPDATE_MS { self.last_tray_update diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0de7924..5787a57 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -12,6 +12,7 @@ mod state; use std::sync::Arc; use std::sync::atomic::AtomicBool; +use tauri::webview::PageLoadEvent; use tauri::{Manager, RunEvent, WindowEvent}; use tauri_plugin_window_state::{StateFlags, WindowExt}; use tm_core::benchmarks::coordinator::Coordinator; @@ -25,7 +26,7 @@ use crate::state::AppState; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { desktop::prefer_x11_on_gnome_wayland(); - let app = tauri::Builder::default() + let builder = tauri::Builder::default() // Must be registered first: a second launch focuses the running // instance instead of starting another engine and window. .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { @@ -37,11 +38,14 @@ pub fn run() { .on_navigation(|_, url| security::navigation_allowed(url)) .build(), ) - .plugin(tauri_plugin_dialog::init()) - .plugin(tauri_plugin_autostart::init( - tauri_plugin_autostart::MacosLauncher::LaunchAgent, - None, - )) + .plugin(tauri_plugin_dialog::init()); + // Linux writes its own XDG login entry (see `autostart`). + #[cfg(not(target_os = "linux"))] + let builder = builder.plugin(tauri_plugin_autostart::init( + tauri_plugin_autostart::MacosLauncher::LaunchAgent, + None, + )); + let app = builder // Restored manually below, only when "Remember window position" is on. .plugin( tauri_plugin_window_state::Builder::default() @@ -77,6 +81,8 @@ pub fn run() { let tray = desktop::build_tray(app.handle())?; app.manage(tray); + // The first check runs in the background; closing must never wait on it. + desktop::tray_host_cached(); engine.subscribe( "desktop".into(), Arc::new(DesktopSink::new(app.handle().clone(), notifications)), @@ -100,6 +106,15 @@ pub fn run() { } Ok(()) }) + .on_page_load(|webview, payload| { + // A reloaded page starts with no Memory view open; its new + // requests for memory detail arrive after this event. + if payload.event() == PageLoadEvent::Started + && let Some(state) = webview.try_state::() + { + state.engine.set_memory_detail(false); + } + }) .on_window_event(|window, event| { if window.label() != MAIN_WINDOW { return; @@ -112,12 +127,16 @@ pub fn run() { // Close hides to the tray only when there is a tray to restore // it from; otherwise closing quits, as it would without the setting. WindowEvent::CloseRequested { api, .. } - if state.settings.get().minimize_to_tray && desktop::tray_host_available() => + if state.settings.get().minimize_to_tray && desktop::tray_host_cached() => { api.prevent_close(); hide_main_window(app); } WindowEvent::Resized(_) | WindowEvent::Focused(_) => { + // Keeps the tray answer fresh for a later close request. + if matches!(event, WindowEvent::Focused(true)) { + desktop::tray_host_cached(); + } let visible = window.is_visible().unwrap_or(true) && !window.is_minimized().unwrap_or(false); let was = state.visibility.get(); diff --git a/src-tauri/src/logging.rs b/src-tauri/src/logging.rs index 6baf400..8751975 100644 --- a/src-tauri/src/logging.rs +++ b/src-tauri/src/logging.rs @@ -106,6 +106,8 @@ fn file_appender(dir: &Path) -> Result> .build(dir)?; Ok(BoundedLogs { inner, + used: log_bytes(dir)?, + scanned_day: utc_day(), dir: dir.to_owned(), max_bytes: MAX_LOG_BYTES, }) @@ -117,25 +119,45 @@ struct BoundedLogs { inner: RollingFileAppender, dir: std::path::PathBuf, max_bytes: u64, + /// Bytes in log files, counted from disk only at startup and once per + /// (UTC) day, when the appender rotates and prunes old files. + used: u64, + scanned_day: u64, +} + +fn utc_day() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_secs() / 86_400) +} + +fn log_bytes(dir: &Path) -> io::Result { + Ok(std::fs::read_dir(dir)? + .filter_map(Result::ok) + .filter(|e| { + e.file_name() + .to_str() + .is_some_and(|n| n.starts_with("taskmanager.") && n.ends_with(".log")) + }) + .filter_map(|e| e.path().symlink_metadata().ok()) + .filter(|m| m.is_file()) + .fold(0u64, |sum, m| sum.saturating_add(m.len()))) } impl Write for BoundedLogs { fn write(&mut self, bytes: &[u8]) -> io::Result { self.inner.write(&[])?; // Allow rotation even after reaching the cap. - let used = std::fs::read_dir(&self.dir)? - .filter_map(Result::ok) - .filter(|e| { - e.file_name() - .to_str() - .is_some_and(|n| n.starts_with("taskmanager.") && n.ends_with(".log")) - }) - .filter_map(|e| e.path().symlink_metadata().ok()) - .filter(|m| m.is_file()) - .fold(0u64, |sum, m| sum.saturating_add(m.len())); - if used.saturating_add(bytes.len() as u64) > self.max_bytes { + let day = utc_day(); + if day != self.scanned_day { + self.used = log_bytes(&self.dir)?; + self.scanned_day = day; + } + if self.used.saturating_add(bytes.len() as u64) > self.max_bytes { return Ok(bytes.len()); } - self.inner.write(bytes) + let written = self.inner.write(bytes)?; + self.used = self.used.saturating_add(written as u64); + Ok(written) } fn flush(&mut self) -> io::Result<()> { @@ -226,4 +248,29 @@ mod tests { drop(writer); std::fs::remove_dir_all(dir).unwrap(); } + + #[test] + fn disk_usage_is_rescanned_only_when_the_day_changes() { + let dir = std::env::temp_dir().join(format!( + "tm-log-rescan-{}-{}", + std::process::id(), + tm_core::monitoring::unix_millis() + )); + std::fs::create_dir(&dir).unwrap(); + let mut writer = file_appender(&dir).unwrap(); + writer.max_bytes = 100; + writer.write_all(b"12345").unwrap(); + assert_eq!(writer.used, 5); + // Space taken behind the writer's back is not seen within the day... + std::fs::write(dir.join("taskmanager.old.log"), vec![b'x'; 100]).unwrap(); + writer.write_all(b"678").unwrap(); + assert_eq!(writer.used, 8); + // ...but is after a day boundary, and further output is discarded. + writer.scanned_day = 0; + writer.write_all(b"discard").unwrap(); + writer.flush().unwrap(); + assert!(writer.used >= 108); + drop(writer); + std::fs::remove_dir_all(dir).unwrap(); + } } diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index b8743f0..9907fb9 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -5,10 +5,11 @@ //! is moved aside rather than silently overwritten. use std::path::{Path, PathBuf}; -use std::sync::{PoisonError, RwLock}; +use std::sync::RwLock; use tm_core::errors::{AppError, AppResult}; use tm_core::models::Settings; +use tm_core::sync_util::{read, write}; const FILE_NAME: &str = "settings.json"; @@ -28,26 +29,49 @@ impl SettingsStore { } pub fn get(&self) -> Settings { - self.current - .read() - .unwrap_or_else(PoisonError::into_inner) - .clone() + read(&self.current).clone() } - /// Validates, persists and stores `next`. Returns the stored settings. - pub fn save(&self, next: Settings) -> AppResult { + /// Applies `change` to the current settings, then validates, persists and + /// stores the result, all under one write lock so concurrent updates are + /// never lost. `then` sees the stored settings before the lock is + /// released, so runtime effects are applied in the order saves happen. + pub fn update( + &self, + change: impl FnOnce(&mut Settings), + then: impl FnOnce(&Settings), + ) -> AppResult { + let mut current = write(&self.current); + let mut next = current.clone(); + change(&mut next); let enabled = next.start_with_os; - self.save_with_autostart(next, || Ok(enabled), |_| Ok(())) + self.commit(&mut current, next, || Ok(enabled), |_| Ok(()), then) } - pub fn save_with_autostart( + /// [`update`](Self::update), also applying `start_with_os` to the real + /// login entry. Rolls autostart back if persistence fails. + pub fn update_with_autostart( &self, + change: impl FnOnce(&mut Settings), + read_autostart: impl FnOnce() -> AppResult, + set_autostart: impl FnMut(bool) -> AppResult<()>, + then: impl FnOnce(&Settings), + ) -> AppResult { + let mut current = write(&self.current); + let mut next = current.clone(); + change(&mut next); + self.commit(&mut current, next, read_autostart, set_autostart, then) + } + + fn commit( + &self, + current: &mut Settings, next: Settings, read_autostart: impl FnOnce() -> AppResult, mut set_autostart: impl FnMut(bool) -> AppResult<()>, + then: impl FnOnce(&Settings), ) -> AppResult { next.validate()?; - let mut current = self.current.write().unwrap_or_else(PoisonError::into_inner); let previous_autostart = read_autostart()?; let changed = previous_autostart != next.start_with_os; if changed { @@ -64,6 +88,7 @@ impl SettingsStore { return Err(error); } *current = next.clone(); + then(&next); Ok(next) } } @@ -109,7 +134,48 @@ mod tests { fn save(store: &SettingsStore, next: Settings) -> AppResult { let previous = store.get().start_with_os; - store.save_with_autostart(next, || Ok(previous), |_| Ok(())) + store.update_with_autostart(|s| *s = next, || Ok(previous), |_| Ok(()), |_| {}) + } + + #[test] + fn concurrent_updates_are_not_lost_and_effects_follow_save_order() { + let store = std::sync::Arc::new(SettingsStore::load(None)); + let applied = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let threads: Vec<_> = (0..8) + .map(|i| { + let (store, applied) = (store.clone(), applied.clone()); + std::thread::spawn(move || { + store + .update( + |s| { + let n = s.new_process_seconds; + std::thread::yield_now(); + s.new_process_seconds = n + 1; + s.show_kernel_threads = i % 2 == 0; + }, + |saved| applied.lock().unwrap().push(saved.new_process_seconds), + ) + .unwrap(); + }) + }) + .collect(); + for t in threads { + t.join().unwrap(); + } + let start = Settings::default().new_process_seconds; + assert_eq!(store.get().new_process_seconds, start + 8); + let applied = applied.lock().unwrap(); + assert_eq!(*applied, (start + 1..=start + 8).collect::>()); + } + + #[test] + fn a_rejected_update_changes_nothing_and_skips_effects() { + let store = SettingsStore::load(None); + let mut ran = false; + let result = store.update(|s| s.refresh_interval_ms = 7, |_| ran = true); + assert!(result.is_err()); + assert!(!ran); + assert_eq!(store.get(), Settings::default()); } fn temp_dir(name: &str) -> PathBuf { @@ -177,13 +243,14 @@ mod tests { start_with_os: true, ..Settings::default() }; - let result = store.save_with_autostart( - next, + let result = store.update_with_autostart( + |s| *s = next, || Ok(enabled.get()), |value| { enabled.set(value); Ok(()) }, + |_| {}, ); assert!(result.is_err()); assert!(!enabled.get()); @@ -196,13 +263,14 @@ mod tests { let store = SettingsStore::load(None); let enabled = std::cell::Cell::new(true); store - .save_with_autostart( - Settings::default(), + .update_with_autostart( + |s| *s = Settings::default(), || Ok(enabled.get()), |value| { enabled.set(value); Ok(()) }, + |_| {}, ) .unwrap(); assert!(!enabled.get()); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 68a919c..6717254 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Task Manager", - "version": "0.1.4", + "version": "0.1.5", "identifier": "com.taskmanager.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/app/App.tsx b/src/app/App.tsx index 02a4f41..076ee04 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,6 +1,7 @@ import { useEffect, type ComponentType } from "react"; import { useGlobalShortcuts } from "@/hooks/useGlobalShortcuts"; import { startMonitoring } from "@/services/monitoring"; +import { ErrorBoundary } from "@/components/ErrorBoundary"; import { Toaster } from "@/components/Toaster"; import { ConfirmActionDialog } from "@/features/processes/ConfirmActionDialog"; import { CommandPalette } from "./CommandPalette"; @@ -62,7 +63,9 @@ export function App() {
- + + +
diff --git a/src/app/CommandPalette.test.tsx b/src/app/CommandPalette.test.tsx new file mode 100644 index 0000000..ce52e3e --- /dev/null +++ b/src/app/CommandPalette.test.tsx @@ -0,0 +1,63 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { useProcessStore } from "@/stores/processStore"; +import { useUiStore } from "@/stores/uiStore"; +import { CommandPalette } from "./CommandPalette"; + +// jsdom does not implement scrolling. +beforeAll(() => { + Element.prototype.scrollIntoView = () => {}; +}); + +beforeEach(() => { + useUiStore.setState({ route: "overview" }); + useProcessStore.setState({ search: "" }); +}); + +const open = () => userEvent.keyboard("{Control>}k{/Control}"); + +describe("CommandPalette", () => { + it("opens with Ctrl+K and closes with Escape", async () => { + render(); + expect(screen.queryByRole("dialog")).toBeNull(); + await open(); + expect(screen.getByRole("dialog", { name: "Command palette" })).toBeInTheDocument(); + expect(screen.getByRole("combobox", { name: "Command" })).toHaveFocus(); + await userEvent.keyboard("{Escape}"); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("filters commands as the user types", async () => { + render(); + await open(); + await userEvent.keyboard("memory"); + const options = screen.getAllByRole("option").map((o) => o.textContent); + expect(options).toEqual(['Search processes for "memory"', "Go to Memory"]); + }); + + it("moves the active option with the arrow keys and runs it with Enter", async () => { + render(); + await open(); + await userEvent.keyboard("go to st"); + const input = screen.getByRole("combobox"); + expect(screen.getAllByRole("option")[0]).toHaveAttribute("aria-selected", "true"); + + await userEvent.keyboard("{ArrowDown}"); + const storage = screen.getByRole("option", { name: "Go to Storage" }); + expect(storage).toHaveAttribute("aria-selected", "true"); + expect(input).toHaveAttribute("aria-activedescendant", storage.id); + + await userEvent.keyboard("{Enter}"); + expect(useUiStore.getState().route).toBe("storage"); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("searches processes for free text", async () => { + render(); + await open(); + await userEvent.keyboard("firefox{Enter}"); + expect(useUiStore.getState().route).toBe("processes"); + expect(useProcessStore.getState().search).toBe("firefox"); + }); +}); diff --git a/src/components/ErrorBoundary.test.tsx b/src/components/ErrorBoundary.test.tsx new file mode 100644 index 0000000..9f36a90 --- /dev/null +++ b/src/components/ErrorBoundary.test.tsx @@ -0,0 +1,53 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ErrorBoundary } from "./ErrorBoundary"; + +let broken = true; +function Flaky() { + if (broken) throw new Error("boom"); + return

Page content

; +} + +beforeEach(() => { + broken = true; + // React and the boundary both log the caught error. + vi.spyOn(console, "error").mockImplementation(() => {}); +}); +afterEach(() => vi.restoreAllMocks()); + +describe("ErrorBoundary", () => { + it("shows the error instead of a blank page and reloads the page on request", async () => { + render( + <> + + + + + , + ); + expect(screen.getByRole("alert")).toHaveTextContent("boom"); + expect(screen.getByText("Sidebar")).toBeInTheDocument(); + + broken = false; + await userEvent.click(screen.getByRole("button", { name: "Reload page" })); + expect(screen.getByText("Page content")).toBeInTheDocument(); + expect(screen.queryByRole("alert")).toBeNull(); + }); + + it("starts fresh when its key changes (another page)", () => { + const { rerender } = render( + + + , + ); + expect(screen.getByRole("alert")).toBeInTheDocument(); + broken = false; + rerender( + + + , + ); + expect(screen.getByText("Page content")).toBeInTheDocument(); + }); +}); diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..9bfefae --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -0,0 +1,46 @@ +import { Component, type ErrorInfo, type ReactNode } from "react"; +import { outlineButton } from "@/components/ui/button"; + +interface Props { + children: ReactNode; +} + +interface State { + error: Error | null; +} + +/** Contains a crash in one page so the shell (sidebar, top bar) keeps working. */ +export class ErrorBoundary extends Component { + state: State = { error: null }; + + static getDerivedStateFromError(error: Error): State { + return { error }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error("Page crashed", error, info.componentStack); + } + + render() { + const { error } = this.state; + if (!error) return this.props.children; + return ( +
+

This page stopped working

+

+ Something went wrong while showing it. Other pages still work. +

+

+ {error.message} +

+ +
+ ); + } +} diff --git a/src/components/Toaster.test.tsx b/src/components/Toaster.test.tsx new file mode 100644 index 0000000..8c9f3e4 --- /dev/null +++ b/src/components/Toaster.test.tsx @@ -0,0 +1,38 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { toast, useToastStore } from "@/stores/toastStore"; +import { Toaster } from "./Toaster"; + +beforeEach(() => { + vi.useFakeTimers(); + useToastStore.setState({ toasts: [] }); +}); +afterEach(() => { + vi.useRealTimers(); +}); + +it("announces errors assertively and the rest politely, without nested alert roles", () => { + const { container } = render(); + act(() => { + toast.success("Saved"); + toast.error("Failed"); + }); + expect(screen.getByText("Saved").closest("[aria-live]")).toHaveAttribute("aria-live", "polite"); + expect(screen.getByText("Failed").closest("[aria-live]")).toHaveAttribute( + "aria-live", + "assertive", + ); + expect(container.querySelector("[role]")).toBeNull(); +}); + +it("stays while hovered", () => { + render(); + act(() => toast.info("Copied")); + const item = screen.getByText("Copied").closest("div.pointer-events-auto")!; + fireEvent.mouseEnter(item); + act(() => vi.advanceTimersByTime(10_000)); + expect(screen.getByText("Copied")).toBeInTheDocument(); + fireEvent.mouseLeave(item); + act(() => vi.advanceTimersByTime(4000)); + expect(screen.queryByText("Copied")).toBeNull(); +}); diff --git a/src/components/Toaster.tsx b/src/components/Toaster.tsx index a96af0b..f08d65b 100644 --- a/src/components/Toaster.tsx +++ b/src/components/Toaster.tsx @@ -1,62 +1,92 @@ import { CheckCircle2, Info, TriangleAlert, X } from "lucide-react"; +import { useEffect, useState } from "react"; import { cn } from "@/lib/cn"; -import { useToastStore } from "@/stores/toastStore"; +import { useToastStore, type Toast } from "@/stores/toastStore"; const ICON = { success: CheckCircle2, info: Info, error: TriangleAlert } as const; -/** Bottom-right notification stack. Announced politely to screen readers. */ +/** + * Bottom-right notification stack. Errors are announced assertively, the rest + * politely; the live regions stay mounted so additions are announced. + */ export function Toaster() { const toasts = useToastStore((s) => s.toasts); + return ( +
+
+ {toasts + .filter((t) => t.tone !== "error") + .map((t) => ( + + ))} +
+
+ {toasts + .filter((t) => t.tone === "error") + .map((t) => ( + + ))} +
+
+ ); +} + +function ToastItem({ toast: t }: { toast: Toast }) { const dismiss = useToastStore((s) => s.dismiss); + const pause = useToastStore((s) => s.pause); + const resume = useToastStore((s) => s.resume); + const [hovered, setHovered] = useState(false); + const [focused, setFocused] = useState(false); + const held = hovered || focused; + useEffect(() => { + if (held) pause(t.id); + else resume(t.id); + }, [held, t.id, pause, resume]); + + const Icon = ICON[t.tone]; return (
setHovered(true)} + onMouseLeave={() => setHovered(false)} + onFocus={() => setFocused(true)} + onBlur={(e) => { + if (!e.currentTarget.contains(e.relatedTarget)) setFocused(false); + }} + className="pointer-events-auto flex items-start gap-3 rounded-lg border border-border bg-surface p-3 shadow-lg" > - {toasts.map((t) => { - const Icon = ICON[t.tone]; - return ( -
+
+

{t.message}

+ {t.action && ( + - )} -
- -
- ); - })} + {t.action.label} + + )} +
+ ); } diff --git a/src/components/ui/SegmentedControl.test.tsx b/src/components/ui/SegmentedControl.test.tsx new file mode 100644 index 0000000..da6803b --- /dev/null +++ b/src/components/ui/SegmentedControl.test.tsx @@ -0,0 +1,78 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { describe, expect, it } from "vitest"; +import { SegmentedControl } from "./SegmentedControl"; + +type Size = "s" | "m" | "l"; + +function Picker({ disabled }: { disabled?: boolean }) { + const [value, setValue] = useState("m"); + return ( + <> + + + + + ); +} + +/** + * Radix moves focus in a timeout and selects on focus only while the arrow + * key is still down, so release it after focus has moved, like a real press. + */ +async function press(key: string) { + const user = userEvent.setup(); + await user.keyboard(`{${key}>}`); + await new Promise((resolve) => setTimeout(resolve, 0)); + await user.keyboard(`{/${key}}`); +} + +describe("SegmentedControl", () => { + it("is one tab stop on the selected segment", async () => { + render(); + screen.getByRole("button", { name: "Before" }).focus(); + await userEvent.tab(); + expect(screen.getByRole("radio", { name: "Medium" })).toHaveFocus(); + await userEvent.tab(); + expect(screen.getByRole("button", { name: "After" })).toHaveFocus(); + }); + + it("moves and selects with the arrow keys, wrapping at the ends", async () => { + render(); + expect(screen.getByRole("radiogroup", { name: "Size" })).toBeInTheDocument(); + screen.getByRole("radio", { name: "Medium" }).focus(); + + await press("ArrowRight"); + const large = screen.getByRole("radio", { name: "Large" }); + expect(large).toHaveFocus(); + expect(large).toBeChecked(); + + await press("ArrowRight"); + expect(screen.getByRole("radio", { name: "Small" })).toBeChecked(); + + await press("ArrowLeft"); + expect(screen.getByRole("radio", { name: "Large" })).toBeChecked(); + expect(screen.getByRole("radio", { name: "Small" })).not.toBeChecked(); + }); + + it("selects on click and disables every segment", async () => { + const { unmount } = render(); + await userEvent.click(screen.getByRole("radio", { name: "Small" })); + expect(screen.getByRole("radio", { name: "Small" })).toBeChecked(); + unmount(); + + render(); + for (const radio of screen.getAllByRole("radio")) expect(radio).toBeDisabled(); + }); +}); diff --git a/src/components/ui/SegmentedControl.tsx b/src/components/ui/SegmentedControl.tsx new file mode 100644 index 0000000..a3bf979 --- /dev/null +++ b/src/components/ui/SegmentedControl.tsx @@ -0,0 +1,60 @@ +import { RadioGroup } from "radix-ui"; +import type { ReactNode } from "react"; +import { cn } from "@/lib/cn"; + +export interface Segment { + value: T; + label: ReactNode; + /** Accessible name and tooltip, for icon-only segments. */ + title?: string; +} + +/** + * Single-choice button strip. One tab stop; arrow keys move between and + * select segments. + */ +export function SegmentedControl({ + value, + onChange, + segments, + label, + labelledBy, + disabled, + className, + itemClassName, +}: { + value: T; + onChange: (value: T) => void; + segments: readonly Segment[]; + label?: string; + labelledBy?: string; + disabled?: boolean; + className?: string; + itemClassName?: string; +}) { + return ( + onChange(v as T)} + disabled={disabled} + aria-label={label} + aria-labelledby={labelledBy} + className={cn("flex gap-0.5 rounded-md bg-surface-2 p-0.5", className)} + > + {segments.map((s) => ( + + {s.label} + + ))} + + ); +} diff --git a/src/components/ui/button.ts b/src/components/ui/button.ts new file mode 100644 index 0000000..5031308 --- /dev/null +++ b/src/components/ui/button.ts @@ -0,0 +1,3 @@ +/** Bordered toolbar/header button. */ +export const outlineButton = + "flex h-8 items-center gap-1.5 rounded-md border border-border px-2.5 text-fg-muted hover:text-fg disabled:opacity-50"; diff --git a/src/dev/previewInspect.ts b/src/dev/previewInspect.ts index 17e2e57..6b5ad09 100644 --- a/src/dev/previewInspect.ts +++ b/src/dev/previewInspect.ts @@ -34,6 +34,7 @@ const socket = ( state, }, pid, + startTime: null, processName, }); diff --git a/src/features/activity/ActivityPage.tsx b/src/features/activity/ActivityPage.tsx index 70c6e93..06269da 100644 --- a/src/features/activity/ActivityPage.tsx +++ b/src/features/activity/ActivityPage.tsx @@ -4,7 +4,7 @@ import { EmptyState } from "@/components/EmptyState"; import { PageHeader } from "@/components/PageHeader"; import { SearchBox } from "@/components/SearchBox"; import { Card } from "@/components/ui/Card"; -import { cn } from "@/lib/cn"; +import { SegmentedControl } from "@/components/ui/SegmentedControl"; import { useActivityStore } from "@/stores/activityStore"; import { useSettingsStore } from "@/stores/settingsStore"; import { useUiStore } from "@/stores/uiStore"; @@ -48,27 +48,14 @@ export function ActivityPage() { label="Search activity" placeholder="Process, PID, detail…" /> -
- {FILTERS.map((f) => ( - - ))} -
+ ({ value: f, label: FILTER_LABEL[f] }))} + className="gap-0 border border-border bg-transparent" + itemClassName="h-7 py-0 data-[state=checked]:bg-surface-2 data-[state=checked]:shadow-none" + /> {shown.length} shown diff --git a/src/features/benchmarks/DiskCard.tsx b/src/features/benchmarks/DiskCard.tsx index 46a8d57..429a50c 100644 --- a/src/features/benchmarks/DiskCard.tsx +++ b/src/features/benchmarks/DiskCard.tsx @@ -1,5 +1,5 @@ import { Check } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Card, CardHeader } from "@/components/ui/Card"; import { backend, toAppError } from "@/services/ipc"; import { loadDiskTargets, runBenchmark } from "@/services/benchmarks"; @@ -51,8 +51,12 @@ export function DiskCard({ // Reload when a drive is plugged in or removed (the monitor sees the mount // table change within a tick), so the target list is never stale. const mounts = useMetricsStore((s) => s.tick?.disks.map((d) => d.mountPoint).join("\n")); + const seenMounts = useRef(mounts); useEffect(() => { - void loadDiskTargets(); + // The first tick arriving is not a change. + const changed = seenMounts.current !== undefined && seenMounts.current !== mounts; + seenMounts.current = mounts; + void loadDiskTargets({ reload: changed }); }, [mounts]); const recommended = targets?.find((t) => t.recommended) ?? targets?.[0]; diff --git a/src/features/benchmarks/DiskInternetCards.test.tsx b/src/features/benchmarks/DiskInternetCards.test.tsx index 089140b..14ad22c 100644 --- a/src/features/benchmarks/DiskInternetCards.test.tsx +++ b/src/features/benchmarks/DiskInternetCards.test.tsx @@ -1,8 +1,9 @@ -import { render, screen, waitFor, within } from "@testing-library/react"; +import { act, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { runBenchmark } from "@/services/benchmarks"; +import { loadDiskTargets, runBenchmark } from "@/services/benchmarks"; import { useBenchmarkStore } from "@/stores/benchmarkStore"; +import { useMetricsStore } from "@/stores/metricsStore"; import type { BenchmarkResult, BenchmarkStatus, @@ -10,8 +11,10 @@ import type { DiskPlan, DiskTargetInfo, InternetPlan, + MetricsTick, } from "@/types"; import { BenchmarksPage } from "./BenchmarksPage"; +import { DiskCard } from "./DiskCard"; const api = vi.hoisted(() => ({ diskPlan: vi.fn(), internetPlan: vi.fn() })); @@ -225,6 +228,8 @@ const NET: BenchmarkResult = { beforeEach(() => { vi.mocked(runBenchmark).mockClear(); + vi.mocked(loadDiskTargets).mockClear(); + useMetricsStore.setState({ tick: null }); api.diskPlan.mockReset().mockResolvedValue(PLAN); api.internetPlan.mockReset().mockResolvedValue(NET_PLAN); useBenchmarkStore.setState({ @@ -298,6 +303,20 @@ describe("Disk card", () => { expect(panel.getByText(/O_DIRECT is not supported here/)).toBeInTheDocument(); }); + it("reloads the locations when the mount table changes, not when the first tick arrives", () => { + const tickWith = (...mounts: string[]) => + useMetricsStore.setState({ + tick: { disks: mounts.map((mountPoint) => ({ mountPoint })) } as unknown as MetricsTick, + }); + render(); + expect(loadDiskTargets).toHaveBeenLastCalledWith({ reload: false }); + act(() => tickWith("/")); + expect(loadDiskTargets).toHaveBeenLastCalledWith({ reload: false }); + act(() => tickWith("/", "/run/media/me/USB")); + expect(loadDiskTargets).toHaveBeenLastCalledWith({ reload: true }); + expect(loadDiskTargets).toHaveBeenCalledTimes(3); + }); + it("explains when no location is available", () => { useBenchmarkStore.setState({ diskTargets: [] }); render(); @@ -379,6 +398,16 @@ describe("Internet card", () => { expect(panel.queryByText(/not scored/i)).not.toBeInTheDocument(); }); + it("says why it cannot run when the plan cannot be made", async () => { + api.internetPlan.mockRejectedValue({ + kind: "systemQueryFailed", + message: "No network connection.", + }); + render(); + expect(await screen.findByText("No network connection.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /run internet test/i })).toBeDisabled(); + }); + it("shows a failed run's reason", () => { useBenchmarkStore.setState({ status: { diff --git a/src/features/benchmarks/InternetCard.tsx b/src/features/benchmarks/InternetCard.tsx index 2cb7feb..c126bd1 100644 --- a/src/features/benchmarks/InternetCard.tsx +++ b/src/features/benchmarks/InternetCard.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { Card, CardHeader } from "@/components/ui/Card"; -import { backend } from "@/services/ipc"; +import { backend, toAppError } from "@/services/ipc"; import { runBenchmark } from "@/services/benchmarks"; import { isActive, useBenchmarkStore } from "@/stores/benchmarkStore"; import type { @@ -49,6 +49,7 @@ export function InternetCard({ const status = useBenchmarkStore((s) => s.status); const [profile, setProfile] = useState("standard"); const [plan, setPlan] = useState(null); + const [planError, setPlanError] = useState(null); const active = isActive(status); const mine = active && status.kind === "internet"; @@ -56,8 +57,16 @@ export function InternetCard({ let stale = false; backend .internetPlan(profile) - .then((p) => !stale && setPlan(p)) - .catch(() => !stale && setPlan(null)); + .then((p) => { + if (stale) return; + setPlan(p); + setPlanError(null); + }) + .catch((e) => { + if (stale) return; + setPlan(null); + setPlanError(toAppError(e).message); + }); return () => { stale = true; }; @@ -83,6 +92,11 @@ export function InternetCard({ it sparingly. You are asked to confirm before it starts.

)} + {planError && ( +

+ {planError} +

+ )} - {profiles.map((p) => ( - - ))} - + ({ value: p.id, label: p.label }))} + className="inline-flex gap-1 rounded-lg p-1" + itemClassName="rounded-md px-3 py-1.5 disabled:opacity-60" + /> ); } diff --git a/src/features/file-finder/FileFinderPage.tsx b/src/features/file-finder/FileFinderPage.tsx index 5e64d45..76a6bd9 100644 --- a/src/features/file-finder/FileFinderPage.tsx +++ b/src/features/file-finder/FileFinderPage.tsx @@ -2,15 +2,13 @@ import { useMemo, useState, type FormEvent } from "react"; import { File, Folder, Search } from "lucide-react"; import { EmptyState } from "@/components/EmptyState"; import { PageHeader } from "@/components/PageHeader"; +import { outlineButton } from "@/components/ui/button"; import { Card, CardHeader } from "@/components/ui/Card"; import { backend, isBackendAvailable, toAppError } from "@/services/ipc"; import { useUiStore } from "@/stores/uiStore"; import type { AppError, FileUserList } from "@/types"; import { USAGE_LABEL, groupByProcess, summarize } from "./logic"; -const btn = - "flex h-8 items-center gap-1.5 rounded-md border border-border px-2.5 text-fg-muted hover:text-fg disabled:opacity-50"; - export function FileFinderPage() { const openProcess = useUiStore((s) => s.openProcess); const [path, setPath] = useState(""); @@ -67,12 +65,16 @@ export function FileFinderPage() { spellCheck={false} className="h-8 min-w-72 flex-1 rounded-md border border-border bg-surface px-2.5 font-mono text-[0.8125rem] outline-none placeholder:font-sans placeholder:text-fg-subtle focus:border-accent" /> - } diff --git a/src/features/performance/PerformancePage.tsx b/src/features/performance/PerformancePage.tsx index 6f9e8b3..87b038b 100644 --- a/src/features/performance/PerformancePage.tsx +++ b/src/features/performance/PerformancePage.tsx @@ -14,6 +14,7 @@ import { } from "@/components/charts/series"; import { Card } from "@/components/ui/Card"; import { KeyValueList, Unavailable } from "@/components/ui/KeyValueList"; +import { SegmentedControl } from "@/components/ui/SegmentedControl"; import { cn } from "@/lib/cn"; import { useMetricsStore } from "@/stores/metricsStore"; import type { MetricsTick, StaticSystemInfo } from "@/types"; @@ -171,27 +172,12 @@ export function PerformancePage() { function RangePicker({ value, onChange }: { value: number; onChange: (s: number) => void }) { return ( -
- {RANGES.map((r) => ( - - ))} -
+ onChange(Number(v))} + segments={RANGES.map((r) => ({ value: String(r.seconds), label: r.label }))} + /> ); } diff --git a/src/features/ports/PortsPage.tsx b/src/features/ports/PortsPage.tsx index bfbd26c..b8040d0 100644 --- a/src/features/ports/PortsPage.tsx +++ b/src/features/ports/PortsPage.tsx @@ -1,10 +1,12 @@ import { usePersistedState } from "@/hooks/usePersistedState"; -import { useMemo, useState } from "react"; +import { memo, useMemo, useState } from "react"; import { Copy, FileSearch, RefreshCw, Square } from "lucide-react"; import { EmptyState } from "@/components/EmptyState"; import { PageHeader } from "@/components/PageHeader"; import { SearchBox } from "@/components/SearchBox"; +import { outlineButton } from "@/components/ui/button"; import { Card } from "@/components/ui/Card"; +import { type Segment, SegmentedControl } from "@/components/ui/SegmentedControl"; import { cn } from "@/lib/cn"; import { copyText, requestAction } from "@/features/processes/actions"; import { useRemoteData } from "@/hooks/useRemoteData"; @@ -26,8 +28,10 @@ import { /** Resolving owners scans every process's descriptors, so poll gently. */ const POLL_MS = 3_000; -const btn = - "flex h-8 items-center gap-1.5 rounded-md border border-border px-2.5 text-fg-muted hover:text-fg disabled:opacity-50"; +const VIEWS: readonly Segment[] = [ + { value: "listening", label: "Listening" }, + { value: "all", label: "All connections" }, +]; export function PortsPage() { const [query, setQuery] = useState(""); @@ -51,7 +55,7 @@ export function PortsPage() { title="Ports" description="Which program is listening on, or connected through, each port" actions={ - } @@ -63,27 +67,14 @@ export function PortsPage() { label="Search ports" placeholder="Port, process, PID, address…" /> -
- {(["listening", "all"] as const).map((v) => ( - - ))} -
+ {shown.length} shown @@ -142,7 +133,12 @@ export function PortsPage() { ); } -function PortRowView({ row }: { row: PortRow }) { +/** Every poll delivers new row objects; compare their values instead. */ +function sameRow(prev: { row: PortRow }, next: { row: PortRow }): boolean { + return (Object.keys(next.row) as (keyof PortRow)[]).every((k) => prev.row[k] === next.row[k]); +} + +const PortRowView = memo(function PortRowView({ row }: { row: PortRow }) { const openProcess = useUiStore((s) => s.openProcess); return ( @@ -156,7 +152,7 @@ function PortRowView({ row }: { row: PortRow }) { @@ -185,7 +181,7 @@ function PortRowView({ row }: { row: PortRow }) { ); -} +}, sameRow); /** * Shown when the search is exactly a port number. Describes who holds the @@ -217,7 +213,12 @@ function ConflictBanner({ >

Port {port} is already in use.

{owners.map((o) => { - const row = o.pid != null ? processes?.find((p) => p.pid === o.pid) : undefined; + // The socket list can be seconds old; only the same PID *and* start + // time is the process that holds the port. + const row = + o.pid != null && o.startTime != null + ? processes?.find((p) => p.pid === o.pid && p.startTime === o.startTime) + : undefined; return (

@@ -235,19 +236,23 @@ function ConflictBanner({ )} {o.pid != null && (

- + + + + + ); +} diff --git a/src/features/process-details/ConnectionsCard.tsx b/src/features/process-details/ConnectionsCard.tsx new file mode 100644 index 0000000..2aef3ff --- /dev/null +++ b/src/features/process-details/ConnectionsCard.tsx @@ -0,0 +1,55 @@ +import { Card, CardHeader } from "@/components/ui/Card"; +import { STATE_LABEL, endpoint } from "@/features/ports/logic"; +import type { Connection } from "@/types"; +import { NOT_AVAILABLE } from "@/utils/format"; + +export function ConnectionsCard({ + connections, + loading, +}: { + connections: Connection[] | null | undefined; + loading: boolean; +}) { + return ( + + +
+ {connections === undefined ? ( +

{loading ? "Loading…" : NOT_AVAILABLE}

+ ) : connections === null ? ( +

+ Unavailable on this platform or for this process. Listing another account's sockets may + require administrator/root rights. +

+ ) : connections.length === 0 ? ( +

No open TCP or UDP sockets.

+ ) : ( + + + + + + + + + + + {connections.map((c, i) => ( + + + + + + + ))} + +
ProtocolLocalRemoteState
{c.protocol}{endpoint(c.localAddress, c.localPort)} + {c.remoteAddress != null ? endpoint(c.remoteAddress, c.remotePort) : "—"} + {c.state ? STATE_LABEL[c.state] : "—"}
+ )} +
+
+ ); +} diff --git a/src/features/process-details/MetricCard.tsx b/src/features/process-details/MetricCard.tsx new file mode 100644 index 0000000..c005d3d --- /dev/null +++ b/src/features/process-details/MetricCard.tsx @@ -0,0 +1,60 @@ +import { useMemo } from "react"; +import type uPlot from "uplot"; +import { LineChart, type LineSeries } from "@/components/charts/LineChart"; +import { Card, CardHeader } from "@/components/ui/Card"; +import type { ProcessPoint } from "@/types"; + +const HISTORY_SECONDS = 300; + +export function MetricCard({ + title, + series, + history, + pick, + yMax, + minYMax, + format, + label, + footer, + nowMs, +}: { + title: string; + series: readonly LineSeries[]; + history: ProcessPoint[]; + pick: (p: ProcessPoint) => number[]; + yMax?: number; + minYMax?: number; + format: (v: number) => string; + label: string; + footer?: string; + /** Latest tick time; anchors the x axis before history exists. */ + nowMs: number; +}) { + const { data, xRange } = useMemo(() => { + const xs = history.map((p) => p.t / 1000); + const columns = series.map((_, i) => history.map((p) => pick(p)[i] ?? 0)); + const end = history.length > 0 ? (xs[xs.length - 1] as number) : nowMs / 1000; + return { + data: [xs, ...columns] as uPlot.AlignedData, + xRange: [end - HISTORY_SECONDS, end] as const, + }; + }, [history, series, pick, nowMs]); + return ( + + +
+ + {footer &&

{footer}

} +
+
+ ); +} diff --git a/src/features/process-details/ProcessDetailsPage.tsx b/src/features/process-details/ProcessDetailsPage.tsx index 8581e8b..9d46b6c 100644 --- a/src/features/process-details/ProcessDetailsPage.tsx +++ b/src/features/process-details/ProcessDetailsPage.tsx @@ -1,19 +1,19 @@ -import { ArrowLeft, FolderOpen, OctagonX, Pause, Play, RefreshCw, Square } from "lucide-react"; -import { requestAction, revealExecutable } from "@/features/processes/actions"; +import { ArrowLeft, RefreshCw } from "lucide-react"; +import { Tabs } from "radix-ui"; import { useMemo, useState } from "react"; -import type uPlot from "uplot"; import { EmptyState } from "@/components/EmptyState"; import { PageHeader } from "@/components/PageHeader"; -import { LineChart, type LineSeries } from "@/components/charts/LineChart"; +import type { LineSeries } from "@/components/charts/LineChart"; import { percentAxis, rateAxis, RATE_MIN_Y } from "@/components/charts/series"; +import { outlineButton } from "@/components/ui/button"; import { Card, CardHeader } from "@/components/ui/Card"; import { KeyValueList, Unavailable } from "@/components/ui/KeyValueList"; -import { childrenOf } from "@/features/processes/tree"; +import { childrenOf, isPlausibleParent } from "@/features/processes/tree"; import { STATUS_LABEL } from "@/features/processes/cells"; import { useActivityStore } from "@/stores/activityStore"; import { useMetricsStore } from "@/stores/metricsStore"; import { useUiStore } from "@/stores/uiStore"; -import type { Connection, ProcessPoint, ProcessRow } from "@/types"; +import type { ProcessRow } from "@/types"; import { NOT_AVAILABLE, formatBytes, @@ -22,8 +22,10 @@ import { formatPercent, formatTimestamp, } from "@/utils/format"; -import { cn } from "@/lib/cn"; +import { ActionButtons } from "./ActionButtons"; +import { ConnectionsCard } from "./ConnectionsCard"; import { DiagnoseTab } from "./DiagnoseTab"; +import { MetricCard } from "./MetricCard"; import { MemoryMapTab } from "./inspect/MemoryMapTab"; import { ModulesTab } from "./inspect/ModulesTab"; import { OpenFilesTab } from "./inspect/OpenFilesTab"; @@ -43,7 +45,6 @@ const TABS: readonly { id: Tab; label: string }[] = [ { id: "diagnose", label: "Diagnose" }, ]; -const HISTORY_SECONDS = 300; const CPU_SERIES: readonly LineSeries[] = [{ label: "CPU", colorVar: "--res-cpu" }]; const MEM_SERIES: readonly LineSeries[] = [{ label: "Memory", colorVar: "--res-mem" }]; const IO_SERIES: readonly LineSeries[] = [ @@ -93,6 +94,13 @@ function ProcessDetailsView({ pid, startTime }: { pid: number; startTime: number const row: ProcessRow | null = live ?? details?.row ?? null; const exited = live == null && details != null; const children = useMemo(() => (rows && live ? childrenOf(rows, pid) : []), [rows, live, pid]); + // The parent PID may since have been reused by an unrelated process. + const parent = useMemo(() => { + const self = live ?? details?.row; + const candidate = + self?.parentPid == null ? undefined : rows?.find((p) => p.pid === self.parentPid); + return candidate && self && isPlausibleParent(candidate, self) ? candidate : undefined; + }, [rows, live, details]); const allEvents = useActivityStore((s) => s.events); // Growth hints recorded for this process (not for a later one with the same PID). const hints = useMemo( @@ -131,19 +139,10 @@ function ProcessDetailsView({ pid, startTime }: { pid: number; startTime: number description={`PID ${row.pid}${row.user ? ` · ${row.user}` : ""}`} actions={ <> - - {live && } @@ -174,189 +173,194 @@ function ProcessDetailsView({ pid, startTime }: { pid: number; startTime: number
)} -
- {TABS.map((t) => ( - - ))} -
-
- {tab === "network" && ( + setTab(v as Tab)} activationMode="manual"> + + {TABS.map((t) => ( + + {t.label} + + ))} + + - )} + {/* Inspection tabs mount only when opened, so nothing is read until then. */} - {tab === "files" && !exited && ( - - - - )} - {tab === "modules" && !exited && ( - - - - )} - {tab === "maps" && !exited && ( - - - - )} - {tab === "diagnose" && !exited && ( - - - - )} - {tab === "threads" && !exited && ( - - - - )} -
- - {tab === "overview" && hints.length > 0 && ( -
- {hints.map((h) => ( -

{h.detail}

- ))} -
- )} - {tab === "overview" && ( -
- - -
- row.parentPid != null && openProcess(row.parentPid)} - > - {details?.parentName ?? "PID"} ({row.parentPid}) - - ), - }, - { - label: "User", - value: row.user ?? , - hint: "Owner information for other accounts' processes may require administrator/root rights.", - }, - { label: "Status", value: STATUS_LABEL[row.status] }, - { label: "Architecture", value: details?.architecture ?? NOT_AVAILABLE }, - ]} - /> - 0 ? formatTimestamp(row.startTime * 1000) : NOT_AVAILABLE, - }, - { label: "Runtime", value: formatDuration(runtime) }, - { label: "Threads", value: row.threads ?? }, - { label: "Priority", value: row.priority ?? }, - { label: "Session", value: row.sessionId ?? NOT_AVAILABLE }, - { label: "Children", value: live ? children.length : NOT_AVAILABLE }, - ]} + + {!exited && ( + + + + )} + + + {!exited && ( + + + + )} + + + {!exited && ( + + + + )} + + + {!exited && ( + + + + )} + + + {!exited && ( + + + + )} + + + {hints.length > 0 && ( +
+ {hints.map((h) => ( +

{h.detail}

+ ))}
-
- - - -
- - - [p.cpu]} - yMax={100} - format={percentAxis} - label="CPU usage of this process" - /> - [p.memory]} - format={bytesAxis} - label="Memory usage of this process" - footer={`Virtual memory ${formatBytes(row.virtualMemoryBytes)}`} - /> - [p.read, p.write]} - format={rateAxis} - minYMax={RATE_MIN_Y} - label="Disk I/O of this process" - footer={ - details?.totalReadBytes != null || details?.totalWrittenBytes != null - ? `Total read ${formatBytes(details?.totalReadBytes)} · written ${formatBytes(details?.totalWrittenBytes)}` - : "Lifetime totals unavailable for this process" - } - /> - - {children.length > 0 && ( + )} +
- -
    - {children.map((c) => ( -
  • - -
  • - ))} -
+ +
+ openProcess(parent.pid, parent.startTime)} + > + {details?.parentName ?? "PID"} ({row.parentPid}) + + ) : ( + `${details?.parentName ?? "PID"} (${row.parentPid})` + ), + }, + { + label: "User", + value: row.user ?? , + hint: "Owner information for other accounts' processes may require administrator/root rights.", + }, + { label: "Status", value: STATUS_LABEL[row.status] }, + { label: "Architecture", value: details?.architecture ?? NOT_AVAILABLE }, + ]} + /> + 0 ? formatTimestamp(row.startTime * 1000) : NOT_AVAILABLE, + }, + { label: "Runtime", value: formatDuration(runtime) }, + { label: "Threads", value: row.threads ?? }, + { label: "Priority", value: row.priority ?? }, + { label: "Session", value: row.sessionId ?? NOT_AVAILABLE }, + { label: "Children", value: live ? children.length : NOT_AVAILABLE }, + ]} + /> +
+
+ + + +
- )} -
- )} + + [p.cpu]} + yMax={100} + format={percentAxis} + label="CPU usage of this process" + /> + [p.memory]} + format={bytesAxis} + label="Memory usage of this process" + footer={`Virtual memory ${formatBytes(row.virtualMemoryBytes)}`} + /> + [p.read, p.write]} + format={rateAxis} + minYMax={RATE_MIN_Y} + label="Disk I/O of this process" + footer={ + details?.totalReadBytes != null || details?.totalWrittenBytes != null + ? `Total read ${formatBytes(details?.totalReadBytes)} · written ${formatBytes(details?.totalWrittenBytes)}` + : "Lifetime totals unavailable for this process" + } + /> + + {children.length > 0 && ( + + +
    + {children.map((c) => ( +
  • + +
  • + ))} +
+
+ )} +
+ + ); } @@ -373,168 +377,3 @@ function PathField({ label, value }: { label: string; value: string | null }) {
); } - -function MetricCard({ - title, - series, - history, - pick, - yMax, - minYMax, - format, - label, - footer, - nowMs, -}: { - title: string; - series: readonly LineSeries[]; - history: ProcessPoint[]; - pick: (p: ProcessPoint) => number[]; - yMax?: number; - minYMax?: number; - format: (v: number) => string; - label: string; - footer?: string; - /** Latest tick time; anchors the x axis before history exists. */ - nowMs: number; -}) { - const { data, xRange } = useMemo(() => { - const xs = history.map((p) => p.t / 1000); - const columns = series.map((_, i) => history.map((p) => pick(p)[i] ?? 0)); - const end = history.length > 0 ? (xs[xs.length - 1] as number) : nowMs / 1000; - return { - data: [xs, ...columns] as uPlot.AlignedData, - xRange: [end - HISTORY_SECONDS, end] as const, - }; - }, [history, series, pick, nowMs]); - return ( - - -
- - {footer &&

{footer}

} -
-
- ); -} - -const STATE_LABEL: Record = { - listen: "Listening", - established: "Established", - timeWait: "Time wait", - closeWait: "Close wait", - synSent: "SYN sent", - synReceived: "SYN received", - finWait1: "FIN wait 1", - finWait2: "FIN wait 2", - closing: "Closing", - lastAck: "Last ACK", - closed: "Closed", - unknown: "Unknown", -}; - -function ConnectionsCard({ - connections, - loading, -}: { - connections: Connection[] | null | undefined; - loading: boolean; -}) { - return ( - - -
- {connections === undefined ? ( -

{loading ? "Loading…" : NOT_AVAILABLE}

- ) : connections === null ? ( -

- Unavailable on this platform or for this process. Listing another account's sockets may - require administrator/root rights. -

- ) : connections.length === 0 ? ( -

No open TCP or UDP sockets.

- ) : ( - - - - - - - - - - - {connections.map((c, i) => ( - - - - - - - ))} - -
ProtocolLocalRemoteState
{c.protocol}{endpoint(c.localAddress, c.localPort)} - {c.remoteAddress != null ? endpoint(c.remoteAddress, c.remotePort) : "—"} - {c.state ? STATE_LABEL[c.state] : "—"}
- )} -
-
- ); -} - -function endpoint(address: string, port: number | null): string { - const host = address.includes(":") ? `[${address}]` : address; - return port == null ? host : `${host}:${port}`; -} - -const actionBtn = - "flex h-8 items-center gap-1.5 rounded-md border border-border px-2.5 text-fg-muted hover:text-fg disabled:opacity-50"; - -function ActionButtons({ row }: { row: ProcessRow }) { - const suspended = row.status === "stopped"; - return ( - <> - - - - - - ); -} diff --git a/src/features/process-details/inspect/InspectBody.tsx b/src/features/process-details/inspect/InspectBody.tsx index 2287517..e70c804 100644 --- a/src/features/process-details/inspect/InspectBody.tsx +++ b/src/features/process-details/inspect/InspectBody.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; import { RefreshCw } from "lucide-react"; +import { outlineButton } from "@/components/ui/button"; import { isBackendAvailable } from "@/services/ipc"; import type { AppError } from "@/types"; @@ -45,11 +46,7 @@ export function InspectBody({
{toolbar} -
diff --git a/src/features/process-details/inspect/ThreadsTab.tsx b/src/features/process-details/inspect/ThreadsTab.tsx index 5543e25..91c6e2a 100644 --- a/src/features/process-details/inspect/ThreadsTab.tsx +++ b/src/features/process-details/inspect/ThreadsTab.tsx @@ -1,11 +1,18 @@ +import { useRef } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; import { useRemoteData } from "@/hooks/useRemoteData"; +import { scaled, useRootPx } from "@/hooks/useRootPx"; import { STATUS_LABEL } from "@/features/processes/cells"; import { backend } from "@/services/ipc"; +import type { ThreadInfo } from "@/types"; import { formatDuration, formatPercent, formatTimestamp } from "@/utils/format"; import { InspectBody } from "./InspectBody"; /** CPU per thread needs two readings, so this is the one tab that polls: only while visible. */ const POLL_MS = 1_500; +const ROW_HEIGHT = 24; +const COLUMNS = + "grid-cols-[4.5rem_minmax(8rem,1fr)_6rem_4.5rem_6rem_4.5rem_3.5rem_10rem_minmax(8rem,1fr)]"; export function ThreadsTab({ pid, @@ -41,42 +48,95 @@ export function ThreadsTab({ : "Windows does not report thread state or wait reasons through the interfaces this app uses." } > -
- - - - - - - - - - - - - - - - {threads.map((t) => ( - - - - - - - - - - - - ))} - -
TIDNameStateCPUCPU timePriorityCoreStartedWaiting in
{t.tid}{t.name ?? "—"}{t.state ? STATUS_LABEL[t.state] : "—"}{formatPercent(t.cpuPercent)} - {t.cpuTimeMs == null ? "—" : formatDuration(t.cpuTimeMs / 1000)} - {t.priority ?? "—"}{t.processor ?? "—"} - {t.startTime != null ? formatTimestamp(t.startTime * 1000) : "—"} - {t.waitChannel ?? "—"}
-
+ ); } + +/** Virtualized: some processes run thousands of threads, re-read every poll. */ +function ThreadTable({ threads }: { threads: readonly ThreadInfo[] }) { + const scroller = useRef(null); + const rowHeight = scaled(ROW_HEIGHT, useRootPx()); + const virtualizer = useVirtualizer({ + count: threads.length, + getScrollElement: () => scroller.current, + estimateSize: () => rowHeight, + overscan: 12, + getItemKey: (index) => threads[index]?.tid ?? index, + }); + return ( +
+
+ TID + Name + State + + CPU + + + CPU time + + + Priority + + + Core + + Started + Waiting in +
+
+ {virtualizer.getVirtualItems().map((v) => { + const t = threads[v.index]; + if (!t) return null; + return ( +
+ + {t.tid} + + + {t.name ?? "—"} + + {t.state ? STATUS_LABEL[t.state] : "—"} + + {formatPercent(t.cpuPercent)} + + + {t.cpuTimeMs == null ? "—" : formatDuration(t.cpuTimeMs / 1000)} + + + {t.priority ?? "—"} + + + {t.processor ?? "—"} + + + {t.startTime != null ? formatTimestamp(t.startTime * 1000) : "—"} + + + {t.waitChannel ?? "—"} + +
+ ); + })} +
+
+ ); +} diff --git a/src/features/process-details/useProcessDetails.test.ts b/src/features/process-details/useProcessDetails.test.ts index d19b836..770d8e5 100644 --- a/src/features/process-details/useProcessDetails.test.ts +++ b/src/features/process-details/useProcessDetails.test.ts @@ -75,15 +75,8 @@ describe("useProcessDetails", () => { expect(result.current.error?.kind).toBe("processNotFound"); }); - it("does not collect an unknown lifetime's ticks before the first details response", async () => { + it("reports an unknown start time as not found without fetching or following ticks", () => { useMetricsStore.setState({ tick: makeTick([]) }); - let resolve: (value: ProcessDetails) => void = () => {}; - getProcessDetails.mockImplementationOnce( - () => - new Promise((done) => { - resolve = done; - }), - ); const { result } = renderHook(() => useProcessDetails(42)); act(() => useMetricsStore.getState().ingestTick({ @@ -92,11 +85,18 @@ describe("useProcessDetails", () => { processes: [{ ...opened, startTime: 5_000 }], }), ); + expect(getProcessDetails).not.toHaveBeenCalled(); + expect(result.current.loading).toBe(false); + expect(result.current.error?.kind).toBe("processNotFound"); + expect(result.current.details).toBeNull(); expect(result.current.live).toBeNull(); - await act(async () => resolve(details)); - await waitFor(() => expect(result.current.details).toBe(details)); expect(result.current.history).toEqual([]); - expect(result.current.live).toBeNull(); + }); + + it("asks for the exact instance it was opened with", async () => { + const { result } = renderHook(() => useProcessDetails(42, 1_000)); + await waitFor(() => expect(result.current.details).toBe(details)); + expect(getProcessDetails).toHaveBeenCalledWith(42, 1_000); }); it("never follows replacement ticks with unknown zero creation times", async () => { diff --git a/src/features/process-details/useProcessDetails.ts b/src/features/process-details/useProcessDetails.ts index 7a687a7..47093d8 100644 --- a/src/features/process-details/useProcessDetails.ts +++ b/src/features/process-details/useProcessDetails.ts @@ -34,15 +34,15 @@ export function useProcessDetails( const [result, setResult] = useState(null); const [livePoints, setLivePoints] = useState([]); const requestKey = `${pid}:${nonce}`; - const [openedStartTime] = useState( + // The start time pins the process instance, so a reused PID is reported as + // an exit rather than shown as this process. Without one (the process was + // gone before the page opened) there is nothing that can be verified. + const [startTime] = useState( () => selectedStartTime ?? useMetricsStore.getState().tick?.processes.find((p) => p.pid === pid)?.startTime, ); - - // Once details load they pin the process instance, so a reused PID is - // reported as an exit rather than shown as this process. - const startTime = openedStartTime ?? result?.details?.row.startTime; + const missing = useMemo(() => (startTime == null ? notFound(pid) : null), [pid, startTime]); const live = useMetricsStore( (s) => s.tick?.processes.find( @@ -51,7 +51,7 @@ export function useProcessDetails( ); useEffect(() => { - if (!isBackendAvailable()) return; + if (!isBackendAvailable() || startTime == null) return; let cancelled = false; backend .getProcessDetails(pid, startTime) @@ -62,12 +62,7 @@ export function useProcessDetails( message: "The process start time is unavailable, so its identity cannot be verified.", } satisfies AppError; } - if (startTime != null && details.row.startTime !== startTime) { - throw { - kind: "processNotFound", - message: `Process ${pid} is no longer running.`, - } satisfies AppError; - } + if (details.row.startTime !== startTime) throw notFound(pid); if (!cancelled) setResult({ key: requestKey, details, error: null }); }) .catch( @@ -111,8 +106,12 @@ export function useProcessDetails( live, details, history, - loading: isBackendAvailable() && result?.key !== requestKey, - error: result?.error ?? null, + loading: !missing && isBackendAvailable() && result?.key !== requestKey, + error: missing ?? result?.error ?? null, refresh, }; } + +function notFound(pid: number): AppError { + return { kind: "processNotFound", message: `Process ${pid} is no longer running.` }; +} diff --git a/src/features/processes/ExportMenu.tsx b/src/features/processes/ExportMenu.tsx index 2f15cd8..38f1421 100644 --- a/src/features/processes/ExportMenu.tsx +++ b/src/features/processes/ExportMenu.tsx @@ -1,6 +1,7 @@ import { Download } from "lucide-react"; import { DropdownMenu } from "radix-ui"; import { useState } from "react"; +import { outlineButton } from "@/components/ui/button"; import { backend, isBackendAvailable, toAppError } from "@/services/ipc"; import { toast } from "@/stores/toastStore"; @@ -26,7 +27,7 @@ export function ExportMenu() { type="button" disabled={!isBackendAvailable()} title={isBackendAvailable() ? undefined : "Export is available in the desktop app"} - className="flex h-8 items-center gap-1.5 rounded-md border border-border px-2.5 text-fg-muted hover:text-fg disabled:opacity-50" + className={outlineButton} > Export diff --git a/src/features/processes/ProcessContextMenu.test.tsx b/src/features/processes/ProcessContextMenu.test.tsx new file mode 100644 index 0000000..1254f3a --- /dev/null +++ b/src/features/processes/ProcessContextMenu.test.tsx @@ -0,0 +1,86 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useMetricsStore } from "@/stores/metricsStore"; +import { useProcessStore } from "@/stores/processStore"; +import { useUiStore } from "@/stores/uiStore"; +import { NOW_MS, STATIC_INFO, makeTick } from "@/test/fixtures"; +import { mockProcesses } from "./mock"; +import { ProcessesPage } from "./ProcessesPage"; + +const processes = mockProcesses(10, NOW_MS / 1000); + +// jsdom has no layout; give the virtualized grid a viewport so rows render. +beforeAll(() => { + Object.defineProperty(HTMLElement.prototype, "clientHeight", { + configurable: true, + get: () => 800, + }); + Object.defineProperty(HTMLElement.prototype, "clientWidth", { + configurable: true, + get: () => 1200, + }); +}); +afterAll(() => { + Reflect.deleteProperty(HTMLElement.prototype, "clientHeight"); + Reflect.deleteProperty(HTMLElement.prototype, "clientWidth"); +}); + +beforeEach(() => { + useProcessStore.setState({ + search: "", + preset: "all", + sort: { column: "pid", direction: "asc" }, + selectedPid: null, + mode: "list", + collapsed: new Set(), + expandedApps: new Set(), + }); + useMetricsStore.setState({ staticInfo: STATIC_INFO, tick: makeTick(processes), status: "live" }); + useUiStore.setState({ route: "processes", selectedPid: null, selectedStartTime: null }); +}); + +const rowOf = (pid: number) => + screen.getAllByRole("row").find((r) => r.getAttribute("data-pid") === String(pid))!; + +describe("ProcessContextMenu", () => { + it("opens for the right-clicked row and acts on that process", async () => { + render(); + const target = processes[3]!; + await userEvent.pointer({ keys: "[MouseRight]", target: rowOf(target.pid) }); + const menu = await screen.findByRole("menu", { name: `Actions for ${target.name}` }); + expect(within(menu).getByText(`${target.name} · PID ${target.pid}`)).toBeInTheDocument(); + expect(useProcessStore.getState().selectedPid).toBe(target.pid); + + await userEvent.click(within(menu).getByRole("menuitem", { name: "Open details" })); + expect(useUiStore.getState()).toMatchObject({ + route: "process-details", + selectedPid: target.pid, + selectedStartTime: target.startTime, + }); + }); + + it.each([ + ["Shift+F10", "{Shift>}{F10}{/Shift}"], + ["the context menu key", "{ContextMenu}"], + ])("opens for the selected row with %s", async (_name, keys) => { + render(); + const grid = screen.getByRole("treegrid", { name: "Processes" }); + grid.focus(); + await userEvent.keyboard("{ArrowDown}{ArrowDown}"); + const selected = processes[1]!; + expect(grid).toHaveAttribute("aria-activedescendant", rowOf(selected.pid).id); + + await userEvent.keyboard(keys); + expect( + await screen.findByRole("menu", { name: `Actions for ${selected.name}` }), + ).toBeInTheDocument(); + }); + + it("does nothing from the keyboard when no row is selected", async () => { + render(); + screen.getByRole("treegrid", { name: "Processes" }).focus(); + await userEvent.keyboard("{Shift>}{F10}{/Shift}"); + expect(screen.queryByRole("menu")).toBeNull(); + }); +}); diff --git a/src/features/processes/ProcessContextMenu.tsx b/src/features/processes/ProcessContextMenu.tsx index 683c6c4..b8fcc37 100644 --- a/src/features/processes/ProcessContextMenu.tsx +++ b/src/features/processes/ProcessContextMenu.tsx @@ -65,8 +65,11 @@ export function ProcessContextMenu({ children: ReactElement; }) { const openProcess = useUiStore((s) => s.openProcess); - const [pid, setPid] = useState(null); - const row = pid == null ? undefined : find(pid); + const [target, setTarget] = useState<{ pid: number; startTime: number } | null>(null); + // Re-resolved each tick, but only while the PID still belongs to the process + // that was right-clicked; a reused PID must never receive the action. + const live = target == null ? undefined : find(target.pid); + const row = live?.startTime === target?.startTime ? live : undefined; const suspended = row?.status === "stopped"; return ( @@ -75,12 +78,14 @@ export function ProcessContextMenu({ onContextMenu={(e) => { const cell = (e.target as HTMLElement).closest("[data-pid]"); // Not on a process row (header, summary row): no menu. - if (!cell || cell.dataset.group !== undefined) { - setPid(null); + const opened = + cell && cell.dataset.group === undefined ? find(Number(cell.dataset.pid)) : undefined; + if (!opened) { + setTarget(null); e.preventDefault(); return; } - setPid(Number(cell.dataset.pid)); + setTarget({ pid: opened.pid, startTime: opened.startTime }); }} > {children} diff --git a/src/features/processes/ProcessRow.tsx b/src/features/processes/ProcessRow.tsx new file mode 100644 index 0000000..350335a --- /dev/null +++ b/src/features/processes/ProcessRow.tsx @@ -0,0 +1,320 @@ +import { ArrowDown, ArrowUp, ChevronDown, ChevronRight } from "lucide-react"; +import { type KeyboardEvent, type PointerEvent, memo } from "react"; +import { cn } from "@/lib/cn"; +import { useRootPx } from "@/hooks/useRootPx"; +import { columnWidth, useProcessStore } from "@/stores/processStore"; +import type { ProcessRow as ProcessRowData } from "@/types"; +import { type HeatLevel, heatLevel, memoryHeatLevel } from "@/utils/levels"; +import { GROUP_MEMORY_NOTE } from "./apps"; +import { cellContent, cellTitle } from "./cells"; +import { + COLUMN_BY_ID, + type ColumnId, + type SortState, + memoryOf, + rowDomId, + runState, + type RunState, +} from "./logic"; + +/** Execution state: a coloured row edge, plus a status-cell tint only for problem states. */ +const RUN_COLOR: Record = { + active: { bar: "var(--ok)" }, + idle: { bar: "transparent" }, + problem: { bar: "var(--crit)", bg: "var(--crit-soft)", fg: "var(--crit)" }, +}; + +const HEAT_BG: Record = { + 0: undefined, + 1: "var(--heat-1)", + 2: "var(--heat-2)", + 3: "var(--heat-3)", +}; + +/** Indentation per tree level, in pixels. */ +const INDENT = 16; + +/** Keyboard resize step, in 16px-root units. */ +const RESIZE_STEP = 8; + +export const HeaderRow = memo(function HeaderRow({ + columns, + template, + sort, +}: { + columns: ColumnId[]; + template: string; + sort: SortState; +}) { + const sortBy = useProcessStore((s) => s.sortBy); + return ( +
+ {columns.map((id) => { + const col = COLUMN_BY_ID[id]; + const active = sort.column === id; + return ( +
+ + +
+ ); + })} +
+ ); +}); + +function ResizeHandle({ column }: { column: ColumnId }) { + const setWidth = useProcessStore((s) => s.setColumnWidth); + const width = useProcessStore((s) => columnWidth(s.columnWidths, column)); + const rootPx = useRootPx(); + const onPointerDown = (e: PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + const el = e.currentTarget; + el.setPointerCapture(e.pointerId); + const startX = e.clientX; + const startWidth = columnWidth(useProcessStore.getState().columnWidths, column); + const move = (ev: globalThis.PointerEvent) => + // Stored widths are in 16px-root units; the pointer moves in screen px. + setWidth(column, startWidth + ((ev.clientX - startX) * 16) / rootPx); + // Fires after pointerup, pointercancel (touch interrupt, alt-tab) and any + // other way the capture ends, so the drag can never get stuck on. + const end = () => { + el.removeEventListener("pointermove", move); + el.removeEventListener("lostpointercapture", end); + }; + el.addEventListener("pointermove", move); + el.addEventListener("lostpointercapture", end); + }; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return; + // The grid also handles arrows (row expand/collapse). + e.preventDefault(); + e.stopPropagation(); + setWidth(column, width + (e.key === "ArrowRight" ? RESIZE_STEP : -RESIZE_STEP)); + }; + const label = COLUMN_BY_ID[column].label; + return ( +
+ ); +} + +interface RowProps { + row: ProcessRowData; + index: number; + depth: number; + hasChildren: boolean; + expanded: boolean; + group: boolean; + columns: ColumnId[]; + template: string; + /** Unix seconds; 0 unless a column shows it, so rows don't re-render on the clock. */ + nowSecs: number; + totalMemoryBytes: number; + heat: boolean; + isNew: boolean; + tree: boolean; + selected: boolean; + isSelf: boolean; + offset: number; + height: number; + onSelect: (pid: number) => void; + onOpen: (pid: number, startTime: number) => void; + onToggle: (pid: number) => void; +} + +function sameRow(a: ProcessRowData, b: ProcessRowData): boolean { + if (a === b) return true; + for (const key of Object.keys(b) as (keyof ProcessRowData)[]) { + if (a[key] !== b[key]) return false; + } + return true; +} + +/** Every tick delivers new row objects; compare their values instead. */ +function sameProps(prev: RowProps, next: RowProps): boolean { + for (const key of Object.keys(next) as (keyof RowProps)[]) { + if (key !== "row" && prev[key] !== next[key]) return false; + } + return sameRow(prev.row, next.row); +} + +export const Row = memo(function Row({ + row, + index, + depth, + hasChildren, + expanded, + group, + columns, + template, + nowSecs, + totalMemoryBytes, + heat, + isNew, + tree, + selected, + isSelf, + offset, + height, + onSelect, + onOpen, + onToggle, +}: RowProps) { + const ctx = { nowSecs, totalMemoryBytes }; + const memPct = totalMemoryBytes > 0 ? (memoryOf(row) / totalMemoryBytes) * 100 : 0; + const run = RUN_COLOR[runState(row)]; + return ( +
onSelect(row.pid)} + onDoubleClick={() => (group ? onToggle(row.pid) : onOpen(row.pid, row.startTime))} + onContextMenu={() => onSelect(row.pid)} + className={cn( + "group absolute left-0 top-0 grid w-full items-center border-b border-border/60 text-[0.875rem]", + selected ? "bg-accent-soft" : isNew ? "bg-accent-soft/40" : "hover:bg-surface-2", + group && "font-medium", + )} + style={{ transform: `translateY(${offset}px)`, height, gridTemplateColumns: template }} + > + {columns.map((id) => { + const col = COLUMN_BY_ID[id]; + const statusTint = heat && id === "status"; + const level: HeatLevel = !heat + ? 0 + : id === "cpu" + ? heatLevel(row.cpuPercent) + : id === "memory" || id === "memoryPercent" + ? memoryHeatLevel(memPct) + : 0; + return ( +
+ {id === "name" && tree && ( + + + {hasChildren && ( + + )} + + )} + + {group && id === "pid" ? "—" : cellContent(id, row, ctx)} + + {id === "name" && isNew && ( + + New + + )} + {id === "name" && isSelf && ( + + this app + + )} +
+ ); + })} +
+ ); +}, sameProps); diff --git a/src/features/processes/ProcessTable.tsx b/src/features/processes/ProcessTable.tsx index 917e129..173e921 100644 --- a/src/features/processes/ProcessTable.tsx +++ b/src/features/processes/ProcessTable.tsx @@ -1,52 +1,21 @@ import { useVirtualizer } from "@tanstack/react-virtual"; -import { ArrowDown, ArrowUp, ChevronDown, ChevronRight } from "lucide-react"; import { type KeyboardEvent, - type PointerEvent, - memo, + type MouseEvent, useCallback, useEffect, useMemo, useRef, } from "react"; -import { cn } from "@/lib/cn"; import { scaled, useRootPx } from "@/hooks/useRootPx"; import { columnWidth, useProcessStore } from "@/stores/processStore"; import { useSettingsStore } from "@/stores/settingsStore"; import { useUiStore } from "@/stores/uiStore"; import type { ProcessRow } from "@/types"; -import { type HeatLevel, heatLevel, memoryHeatLevel } from "@/utils/levels"; -import { GROUP_MEMORY_NOTE } from "./apps"; -import { cellContent, cellTitle } from "./cells"; -import { - COLUMN_BY_ID, - type ColumnId, - type ProcessContext, - type SortState, - isNewProcess, - memoryOf, - runState, - type RunState, -} from "./logic"; +import { type ProcessContext, isNewProcess, rowDomId } from "./logic"; +import { HeaderRow, Row } from "./ProcessRow"; import type { TableItem } from "./tree"; -/** Execution state: a coloured row edge, plus a status-cell tint only for problem states. */ -const RUN_COLOR: Record = { - active: { bar: "var(--ok)" }, - idle: { bar: "transparent" }, - problem: { bar: "var(--crit)", bg: "var(--crit-soft)", fg: "var(--crit)" }, -}; - -const HEAT_BG: Record = { - 0: undefined, - 1: "var(--heat-1)", - 2: "var(--heat-2)", - 3: "var(--heat-3)", -}; - -/** Indentation per tree level, in pixels. */ -const INDENT = 16; - interface ProcessTableProps { items: readonly TableItem[]; ctx: ProcessContext; @@ -93,6 +62,8 @@ export function ProcessTable({ // Rows scale with the type size, but stop at 18px root so a big window // keeps showing many processes instead of fewer, taller rows. const rowHeight = scaled(compact ? 26 : 32, Math.min(rootPx, 18)); + // Only a visible runtime column makes rows depend on the clock. + const rowNow = visibleColumns.includes("runtime") ? ctx.nowSecs : 0; const template = useMemo( () => visibleColumns.map((c) => `${scaled(columnWidth(widths, c), rootPx)}px`).join(" "), @@ -122,6 +93,42 @@ export function ProcessTable({ [items, selectedPid], ); + /** Opens the row context menu from the keyboard, positioned on the selected row. */ + const openRowMenu = useCallback(() => { + if (selectedPid == null || selectedIndex < 0) return; + const fire = () => { + const el = document.getElementById(rowDomId(selectedPid)); + if (!el) return; + const rect = el.getBoundingClientRect(); + el.dispatchEvent( + new globalThis.MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + clientX: rect.left + 24, + clientY: rect.top + rect.height / 2, + }), + ); + }; + if (document.getElementById(rowDomId(selectedPid))) { + fire(); + } else { + virtualizer.scrollToIndex(selectedIndex, { align: "auto" }); + requestAnimationFrame(fire); + } + }, [selectedPid, selectedIndex, virtualizer]); + + // A keyboard-generated contextmenu event targets the grid itself, which + // has no row to act on; redirect it to the selected row. + const onContextMenuCapture = useCallback( + (e: MouseEvent) => { + if (e.target !== e.currentTarget || selectedIndex < 0) return; + e.preventDefault(); + e.stopPropagation(); + openRowMenu(); + }, + [openRowMenu, selectedIndex], + ); + const onKeyDown = useCallback( (e: KeyboardEvent) => { if (items.length === 0) return; @@ -130,6 +137,11 @@ export function ProcessTable({ 1, Math.floor((scrollRef.current?.clientHeight ?? 300) / rowHeight) - 1, ); + if ((e.key === "F10" && e.shiftKey) || e.key === "ContextMenu") { + e.preventDefault(); + openRowMenu(); + return; + } let next: number; switch (e.key) { case "ArrowDown": @@ -186,6 +198,7 @@ export function ProcessTable({ select, setCollapsed, openProcess, + openRowMenu, toggleCollapsed, onRequestTerminate, virtualizer, @@ -197,6 +210,12 @@ export function ProcessTable({ [items], ); + const virtualItems = virtualizer.getVirtualItems(); + const activeDescendant = + selectedPid != null && virtualItems.some((v) => v.index === selectedIndex) + ? rowDomId(selectedPid) + : undefined; + const table = (
- {virtualizer.getVirtualItems().map((v) => { + {virtualItems.map((v) => { const item = items[v.index]; if (!item) return null; + const { row } = item; return ( s.sortBy); - return ( -
- {columns.map((id) => { - const col = COLUMN_BY_ID[id]; - const active = sort.column === id; - return ( -
- - -
- ); - })} -
- ); -}); - -function ResizeHandle({ column }: { column: ColumnId }) { - const setWidth = useProcessStore((s) => s.setColumnWidth); - const rootPx = useRootPx(); - const onPointerDown = (e: PointerEvent) => { - e.preventDefault(); - e.stopPropagation(); - const el = e.currentTarget; - el.setPointerCapture(e.pointerId); - const startX = e.clientX; - const startWidth = columnWidth(useProcessStore.getState().columnWidths, column); - const move = (ev: globalThis.PointerEvent) => - // Stored widths are in 16px-root units; the pointer moves in screen px. - setWidth(column, startWidth + ((ev.clientX - startX) * 16) / rootPx); - // Fires after pointerup, pointercancel (touch interrupt, alt-tab) and any - // other way the capture ends, so the drag can never get stuck on. - const end = () => { - el.removeEventListener("pointermove", move); - el.removeEventListener("lostpointercapture", end); - }; - el.addEventListener("pointermove", move); - el.addEventListener("lostpointercapture", end); - }; - return ( -
- ); -} - -interface RowProps { - item: TableItem; - index: number; - columns: ColumnId[]; - template: string; - ctx: ProcessContext; - heat: boolean; - /** Seconds a new process is highlighted; 0 is off. */ - highlightSecs: number; - tree: boolean; - selected: boolean; - isSelf: boolean; - offset: number; - height: number; - onSelect: (pid: number) => void; - onOpen: (pid: number, startTime: number) => void; - onToggle: (pid: number) => void; -} - -const Row = memo(function Row({ - item, - index, - columns, - template, - ctx, - heat, - highlightSecs, - tree, - selected, - isSelf, - offset, - height, - onSelect, - onOpen, - onToggle, -}: RowProps) { - const { row } = item; - const memPct = ctx.totalMemoryBytes > 0 ? (memoryOf(row) / ctx.totalMemoryBytes) * 100 : 0; - const isNew = isNewProcess(row.startTime, ctx.nowSecs, highlightSecs); - const run = RUN_COLOR[runState(row)]; - return ( -
onSelect(row.pid)} - onDoubleClick={() => (item.group ? onToggle(row.pid) : onOpen(row.pid, row.startTime))} - onContextMenu={() => onSelect(row.pid)} - className={cn( - "group absolute left-0 top-0 grid w-full items-center border-b border-border/60 text-[0.875rem]", - selected ? "bg-accent-soft" : isNew ? "bg-accent-soft/40" : "hover:bg-surface-2", - item.group && "font-medium", - )} - style={{ transform: `translateY(${offset}px)`, height, gridTemplateColumns: template }} - > - {columns.map((id) => { - const col = COLUMN_BY_ID[id]; - const statusTint = heat && id === "status"; - const level: HeatLevel = !heat - ? 0 - : id === "cpu" - ? heatLevel(row.cpuPercent) - : id === "memory" || id === "memoryPercent" - ? memoryHeatLevel(memPct) - : 0; - return ( -
- {id === "name" && tree && ( - - - {item.hasChildren && ( - - )} - - )} - - {item.group && id === "pid" ? "—" : cellContent(id, row, ctx)} - - {id === "name" && isNew && ( - - New - - )} - {id === "name" && isSelf && ( - - this app - - )} -
- ); - })} -
- ); -}); diff --git a/src/features/processes/ProcessToolbar.tsx b/src/features/processes/ProcessToolbar.tsx index c5af647..f612cb0 100644 --- a/src/features/processes/ProcessToolbar.tsx +++ b/src/features/processes/ProcessToolbar.tsx @@ -10,8 +10,9 @@ import { } from "lucide-react"; import { DropdownMenu } from "radix-ui"; import { useEffect, useRef } from "react"; -import { cn } from "@/lib/cn"; -import { useProcessStore } from "@/stores/processStore"; +import { outlineButton } from "@/components/ui/button"; +import { type Segment, SegmentedControl } from "@/components/ui/SegmentedControl"; +import { type ProcessViewMode, useProcessStore } from "@/stores/processStore"; import type { ProcessRow } from "@/types"; import { COLUMNS, FILTER_PRESETS } from "./logic"; import { ExportMenu } from "./ExportMenu"; @@ -32,7 +33,7 @@ export function ProcessToolbar({
- + {mode !== "list" || shown === total ? `${total} processes` : `${shown} of ${total} processes`} @@ -120,31 +121,27 @@ function PresetSelect() { function ModeToggle() { const mode = useProcessStore((s) => s.mode); const setMode = useProcessStore((s) => s.setMode); - const item = (id: typeof mode, label: string, Icon: typeof List) => ( - - ); return ( -
- {item("list", "List view", List)} - {item("tree", "Tree view", ListTree)} - {item("apps", "Applications view (group processes by program)", Layers)} -
+ ); } +const MODES: readonly Segment[] = [ + { value: "list", title: "List view", label: }, + { value: "tree", title: "Tree view", label: }, + { + value: "apps", + title: "Applications view (group processes by program)", + label: , + }, +]; + function ColumnMenu() { const visible = useProcessStore((s) => s.visibleColumns); const toggle = useProcessStore((s) => s.toggleColumn); @@ -152,10 +149,7 @@ function ColumnMenu() { return ( - diff --git a/src/features/processes/ProcessesPage.test.tsx b/src/features/processes/ProcessesPage.test.tsx index 77f643d..5d6d508 100644 --- a/src/features/processes/ProcessesPage.test.tsx +++ b/src/features/processes/ProcessesPage.test.tsx @@ -116,6 +116,20 @@ describe("ProcessesPage", () => { expect(useUiStore.getState().selectedPid).toBe(processes[1]?.pid); }); + it("resizes a column from the keyboard", async () => { + useProcessStore.setState({ columnWidths: {} }); + render(); + const handle = screen.getByRole("separator", { name: "Resize PID column" }); + const before = Number(handle.getAttribute("aria-valuenow")); + handle.focus(); + await userEvent.keyboard("{ArrowRight}{ArrowRight}"); + expect(useProcessStore.getState().columnWidths.pid).toBe(before + 16); + await userEvent.keyboard("{ArrowLeft}"); + expect(handle).toHaveAttribute("aria-valuenow", String(before + 8)); + // Arrows on the handle must not reach the grid's row navigation. + expect(useProcessStore.getState().selectedPid).toBeNull(); + }); + it("collapses and expands all in list view", async () => { useMetricsStore.setState({ tick: makeTick([ diff --git a/src/features/processes/ProcessesPage.tsx b/src/features/processes/ProcessesPage.tsx index 76ed8c6..39e10af 100644 --- a/src/features/processes/ProcessesPage.tsx +++ b/src/features/processes/ProcessesPage.tsx @@ -5,6 +5,8 @@ import { useMemoryDetail } from "@/features/memory/useMemoryDetail"; import { requestAction } from "./actions"; import { ProcessContextMenu } from "./ProcessContextMenu"; import { ProcessTable } from "./ProcessTable"; +import { ProcessToolbar } from "./ProcessToolbar"; +import { useProcessView } from "./useProcessView"; import type { ProcessRow } from "@/types"; const withContextMenu = ( @@ -12,8 +14,6 @@ const withContextMenu = ( find: (pid: number) => ProcessRow | undefined, ) => {table}; const requestTerminate = (row: ProcessRow) => void requestAction(row, { type: "terminate" }); -import { ProcessToolbar } from "./ProcessToolbar"; -import { useProcessView } from "./useProcessView"; export function ProcessesPage() { // Proportional memory, so every view shows the figure the Memory page does. diff --git a/src/features/processes/actions.test.tsx b/src/features/processes/actions.test.tsx index 32fba3a..1f43935 100644 --- a/src/features/processes/actions.test.tsx +++ b/src/features/processes/actions.test.tsx @@ -18,7 +18,7 @@ vi.mock("@/services/ipc", async (importOriginal) => { }); // Imported after the mock is registered. -const { requestAction, useActionStore } = await import("./actions"); +const { confirmPending, requestAction, useActionStore } = await import("./actions"); const row: ProcessRow = { ...(mockProcesses(1, 1_800_000_000)[0] as ProcessRow), name: "node" }; const allowed: ProtectionAssessment = { level: "allowed", reasons: [] }; @@ -98,6 +98,41 @@ describe("process action flow", () => { expect(useToastStore.getState().toasts[0]?.message).toBe("Done."); }); + it("ignores new requests while an action is running", async () => { + backend.assessProcessAction.mockResolvedValue(allowed); + await requestAction(row, { type: "terminate" }); + let finish: (v: { message: string }) => void = () => {}; + backend.performProcessAction.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const confirming = confirmPending(); + await requestAction(row, { type: "forceKill" }); + expect(backend.assessProcessAction).toHaveBeenCalledTimes(1); + finish({ message: "Done." }); + await confirming; + expect(useActionStore.getState()).toMatchObject({ pending: null, busy: false }); + }); + + it("opens only the newest request's dialog when assessments overlap", async () => { + let first: (a: ProtectionAssessment) => void = () => {}; + backend.assessProcessAction + .mockImplementationOnce( + () => + new Promise((resolve) => { + first = resolve; + }), + ) + .mockResolvedValueOnce(allowed); + const older = requestAction(row, { type: "terminate" }); + await requestAction(row, { type: "forceKill" }); + first(allowed); + await older; + expect(useActionStore.getState().pending?.action).toEqual({ type: "forceKill" }); + }); + it("offers force end when a graceful close is impossible", async () => { backend.assessProcessAction.mockResolvedValue(allowed); backend.performProcessAction.mockRejectedValueOnce({ diff --git a/src/features/processes/actions.ts b/src/features/processes/actions.ts index 5b37f22..1a23940 100644 --- a/src/features/processes/actions.ts +++ b/src/features/processes/actions.ts @@ -36,16 +36,21 @@ export const PRIORITY_LABELS: Record = { high: "High", }; +/** Bumped per request so only the newest assessment opens a dialog or runs. */ +let latestRequest = 0; + /** Starts an action. Shows the confirmation dialog when required. */ export async function requestAction(row: ProcessRow, action: ProcessAction): Promise { - if (!isBackendAvailable()) return; + if (!isBackendAvailable() || useActionStore.getState().busy) return; + const request = ++latestRequest; let assessment: ProtectionAssessment; try { assessment = await backend.assessProcessAction(row.pid, action); } catch (e) { - toast.error(toAppError(e).message); + if (request === latestRequest) toast.error(toAppError(e).message); return; } + if (request !== latestRequest || useActionStore.getState().busy) return; if (assessment.level !== "allowed" || needsConfirmation(action)) { useActionStore.setState({ pending: { row, action, assessment } }); return; @@ -59,11 +64,14 @@ export function cancelPending(): void { /** Confirms the pending dialog. Blocked actions cannot be confirmed. */ export async function confirmPending(): Promise { - const { pending } = useActionStore.getState(); - if (!pending || pending.assessment.level === "blocked") return; + const { pending, busy } = useActionStore.getState(); + if (!pending || busy || pending.assessment.level === "blocked") return; useActionStore.setState({ busy: true }); await perform(pending.row, pending.action, pending.assessment.level === "warning"); - useActionStore.setState({ busy: false, pending: null }); + useActionStore.setState((s) => ({ + busy: false, + pending: s.pending === pending ? null : s.pending, + })); } async function perform(row: ProcessRow, action: ProcessAction, acknowledge: boolean) { diff --git a/src/features/processes/cells.tsx b/src/features/processes/cells.tsx index e443270..00abcd1 100644 --- a/src/features/processes/cells.tsx +++ b/src/features/processes/cells.tsx @@ -32,7 +32,11 @@ const rate = (v: number) => (v > 0 ? formatBytesPerSecond(v) : "0 B/s"); const opt = (v: number | null) => (v == null ? NOT_AVAILABLE : String(v)); /** Display content for a cell. Kept separate from layout for reuse in exports/tree. */ -export function cellContent(column: ColumnId, row: ProcessRow, ctx: ProcessContext): ReactNode { +export function cellContent( + column: ColumnId, + row: ProcessRow, + ctx: Pick, +): ReactNode { if (row.restricted && RESTRICTED_COLUMNS.has(column)) return NOT_AVAILABLE; switch (column) { case "name": diff --git a/src/features/processes/logic.ts b/src/features/processes/logic.ts index bbb8a9d..a94b60a 100644 --- a/src/features/processes/logic.ts +++ b/src/features/processes/logic.ts @@ -227,7 +227,7 @@ export const DEFAULT_VISIBLE_COLUMNS: readonly ColumnId[] = COLUMNS.filter( (c) => c.defaultVisible, ).map((c) => c.id); -export function runtimeSecs(row: ProcessRow, ctx: ProcessContext): number { +export function runtimeSecs(row: ProcessRow, ctx: Pick): number { return row.startTime > 0 ? Math.max(0, ctx.nowSecs - row.startTime) : -1; } @@ -405,6 +405,11 @@ export function userOf(rows: readonly ProcessRow[], pid: number | undefined): st return rows.find((r) => r.pid === pid)?.user ?? null; } +/** DOM id of a process table row, for `aria-activedescendant`. */ +export function rowDomId(pid: number): string { + return `proc-row-${pid}`; +} + /** Whether a process started within the last `windowSecs` (0 turns the highlight off). */ export function isNewProcess(startTime: number, nowSecs: number, windowSecs: number): boolean { return ( diff --git a/src/features/services/ServicesPage.tsx b/src/features/services/ServicesPage.tsx index 87bcb95..09a358d 100644 --- a/src/features/services/ServicesPage.tsx +++ b/src/features/services/ServicesPage.tsx @@ -1,5 +1,5 @@ import { CircleAlert, CircleCheck, CircleMinus, Lock, RefreshCw } from "lucide-react"; -import { useMemo, useState } from "react"; +import { memo, useCallback, useMemo, useState } from "react"; import { EmptyState } from "@/components/EmptyState"; import { PageHeader } from "@/components/PageHeader"; import { SearchBox } from "@/components/SearchBox"; @@ -49,20 +49,23 @@ export function ServicesPage() { ); const failed = data?.filter((s) => s.activeState === "failed").length ?? 0; - async function act(s: ServiceInfo, action: ServiceAction) { - setPending(s.unit); - try { - await backend.controlService(scope, s.unit, action); - toast.success(`${DONE[action]} ${s.unit}…`); - } catch (e) { - const err = toAppError(e); - // Declining the confirmation is a choice, not an error. - if (err.kind !== "operationCancelled") toast.error(err.message); - } finally { - setPending(null); - refresh(); - } - } + const act = useCallback( + async (s: ServiceInfo, action: ServiceAction) => { + setPending(s.unit); + try { + await backend.controlService(scope, s.unit, action); + toast.success(`${DONE[action]} ${s.unit}…`); + } catch (e) { + const err = toAppError(e); + // Declining the confirmation is a choice, not an error. + if (err.kind !== "operationCancelled") toast.error(err.message); + } finally { + setPending(null); + refresh(); + } + }, + [scope, refresh], + ); return ( <> @@ -155,8 +158,8 @@ export function ServicesPage() { key={s.unit} service={s} busy={pending === s.unit} - onAct={(a) => void act(s, a)} - onOpen={() => s.mainPid != null && openProcess(s.mainPid)} + onAct={act} + onOpen={openProcess} /> ))} @@ -188,17 +191,25 @@ function StateBadge({ service }: { service: ServiceInfo }) { ); } -function Row({ - service: s, - busy, - onAct, - onOpen, -}: { +interface RowProps { service: ServiceInfo; busy: boolean; - onAct: (action: ServiceAction) => void; - onOpen: () => void; -}) { + onAct: (service: ServiceInfo, action: ServiceAction) => Promise; + onOpen: (pid: number) => void; +} + +/** Every poll delivers new service objects; compare their values instead. */ +function sameRowProps(prev: RowProps, next: RowProps): boolean { + if (prev.busy !== next.busy || prev.onAct !== next.onAct || prev.onOpen !== next.onOpen) { + return false; + } + const a = prev.service; + const b = next.service; + return (Object.keys(b) as (keyof ServiceInfo)[]).every((k) => a[k] === b[k]); +} + +const Row = memo(function Row({ service: s, busy, onAct: act, onOpen }: RowProps) { + const onAct = (action: ServiceAction) => void act(s, action); const running = s.activeState === "active" || s.activeState === "activating"; return ( @@ -216,7 +227,7 @@ function Row({ {s.mainPid != null ? ( - ))} -
+ s.update({ theme })} + segments={THEMES} + className="inline-flex gap-1 rounded-lg p-1" + itemClassName="flex items-center gap-1.5 rounded-md px-3 py-1.5" + /> - + void s.reset()} />
@@ -278,6 +267,51 @@ export function SettingsPage() { ); } +function ResetSettings({ onReset }: { onReset: () => void }) { + return ( + + + + + + + + + Reset all settings? + + + Every preference returns to its default, including your alert rules. + +
+ + + + + + +
+
+
+
+ ); +} + function DiagnosticsExport({ disabled }: { disabled: boolean }) { const [processes, setProcesses] = useState(false); const [commandLines, setCommandLines] = useState(false); diff --git a/src/features/startup/StartupPage.tsx b/src/features/startup/StartupPage.tsx index 2ec6422..3b5fc95 100644 --- a/src/features/startup/StartupPage.tsx +++ b/src/features/startup/StartupPage.tsx @@ -3,6 +3,7 @@ import { RefreshCw } from "lucide-react"; import { EmptyState } from "@/components/EmptyState"; import { PageHeader } from "@/components/PageHeader"; import { SearchBox } from "@/components/SearchBox"; +import { outlineButton } from "@/components/ui/button"; import { Card } from "@/components/ui/Card"; import { Switch } from "@/components/ui/Switch"; import { cn } from "@/lib/cn"; @@ -12,9 +13,6 @@ import { toast } from "@/stores/toastStore"; import { useSettingsStore } from "@/stores/settingsStore"; import { filterItems } from "./logic"; -const btn = - "flex h-8 items-center gap-1.5 rounded-md border border-border px-2.5 text-fg-muted hover:text-fg disabled:opacity-50"; - export function StartupPage() { const [query, setQuery] = useState(""); const [pending, setPending] = useState>(new Set()); @@ -52,7 +50,7 @@ export function StartupPage() { title="Startup" description="Programs that start when you log in. Turning one off hides it; nothing is deleted." actions={ - } diff --git a/src/features/storage/RemovableDrives.test.tsx b/src/features/storage/RemovableDrives.test.tsx index 29882be..160f862 100644 --- a/src/features/storage/RemovableDrives.test.tsx +++ b/src/features/storage/RemovableDrives.test.tsx @@ -1,7 +1,8 @@ -import { render, screen, waitFor } from "@testing-library/react"; +import { act, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, expect, it, vi } from "vitest"; -import type { Volume } from "@/types"; +import { useMetricsStore } from "@/stores/metricsStore"; +import type { MetricsTick, Volume } from "@/types"; import { RemovableDrives } from "./RemovableDrives"; const api = vi.hoisted(() => ({ @@ -28,6 +29,7 @@ const unmounted: Volume = { ...mounted, device: "/dev/sdc1", label: null, mountP beforeEach(() => { Object.values(api).forEach((m) => m.mockReset()); + useMetricsStore.setState({ tick: null }); }); it("shows nothing when there are no external drives", async () => { @@ -56,6 +58,19 @@ it("passes the device to the backend and reloads the list", async () => { await waitFor(() => expect(api.listVolumes).toHaveBeenCalledTimes(2)); }); +it("keeps the list on screen while reloading after the mount table changes", async () => { + api.listVolumes.mockResolvedValueOnce([unmounted]).mockReturnValueOnce(new Promise(() => {})); + render(); + await screen.findByRole("button", { name: "Mount /dev/sdc1" }); + act(() => + useMetricsStore.setState({ + tick: { disks: [{ mountPoint: "/run/media/a/NEW" }] } as unknown as MetricsTick, + }), + ); + await waitFor(() => expect(api.listVolumes).toHaveBeenCalledTimes(2)); + expect(screen.getByRole("button", { name: "Mount /dev/sdc1" })).toBeInTheDocument(); +}); + it("offers Power off only for an unmounted drive that supports it", async () => { api.listVolumes.mockResolvedValue([ mounted, diff --git a/src/features/storage/RemovableDrives.tsx b/src/features/storage/RemovableDrives.tsx index d408e9b..ae09815 100644 --- a/src/features/storage/RemovableDrives.tsx +++ b/src/features/storage/RemovableDrives.tsx @@ -26,7 +26,9 @@ export function RemovableDrives() { // A drive being mounted or unmounted changes the mount table within a tick. const mounts = useMetricsStore((s) => s.tick?.disks.map((d) => d.mountPoint).join("\n")); // Drive management is optional (e.g. UDisks missing): an error shows nothing. - const { data, refresh } = useRemoteData(() => backend.listVolumes(), mounts ?? "", POLL_MS); + const { data, refresh } = useRemoteData(() => backend.listVolumes(), mounts ?? "", POLL_MS, { + keepPreviousData: true, + }); const volumes = data ?? []; const act = async (v: Volume, what: "mount" | "unmount" | "powerOff") => { diff --git a/src/features/system-info/SystemInfoPage.tsx b/src/features/system-info/SystemInfoPage.tsx index 3bdfe27..22083cb 100644 --- a/src/features/system-info/SystemInfoPage.tsx +++ b/src/features/system-info/SystemInfoPage.tsx @@ -3,6 +3,7 @@ import { useRef, type KeyboardEvent, type ReactNode } from "react"; import { create } from "zustand"; import { ConnectionNotice } from "@/components/ConnectionNotice"; import { PageHeader } from "@/components/PageHeader"; +import { outlineButton } from "@/components/ui/button"; import { Card } from "@/components/ui/Card"; import { cn } from "@/lib/cn"; import { useRemoteData } from "@/hooks/useRemoteData"; @@ -94,7 +95,7 @@ export function SystemInfoPage() { type="button" disabled={!isBackendAvailable()} onClick={() => void exportSummary()} - className="flex h-8 items-center gap-1.5 rounded-md border border-border px-2.5 text-fg-muted hover:text-fg disabled:opacity-50" + className={outlineButton} > Export summary… diff --git a/src/hooks/useRemoteData.test.ts b/src/hooks/useRemoteData.test.ts new file mode 100644 index 0000000..4818192 --- /dev/null +++ b/src/hooks/useRemoteData.test.ts @@ -0,0 +1,61 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { expect, it, vi } from "vitest"; +import { useRemoteData } from "./useRemoteData"; + +vi.mock("@/services/ipc", async (orig) => ({ + ...(await orig()), + isBackendAvailable: () => true, +})); + +/** A fetcher per call whose results the test releases in any order. */ +function deferredCalls() { + const resolvers: ((value: string) => void)[] = []; + const fetcher = vi.fn( + () => + new Promise((resolve) => { + resolvers.push(resolve); + }), + ); + return { fetcher, resolve: (call: number, value: string) => resolvers[call]?.(value) }; +} + +it("drops the previous key's data by default", async () => { + const { fetcher, resolve } = deferredCalls(); + const { result, rerender } = renderHook(({ k }) => useRemoteData(fetcher, k), { + initialProps: { k: "a" }, + }); + act(() => resolve(0, "A")); + await waitFor(() => expect(result.current.data).toBe("A")); + rerender({ k: "b" }); + expect(result.current.data).toBeNull(); + expect(result.current.loading).toBe(true); +}); + +it("keeps the previous key's data until the new key's arrives with keepPreviousData", async () => { + const { fetcher, resolve } = deferredCalls(); + const { result, rerender } = renderHook( + ({ k }) => useRemoteData(fetcher, k, undefined, { keepPreviousData: true }), + { initialProps: { k: "a" } }, + ); + act(() => resolve(0, "A")); + await waitFor(() => expect(result.current.data).toBe("A")); + rerender({ k: "b" }); + expect(result.current.data).toBe("A"); + expect(result.current.loading).toBe(false); + act(() => resolve(1, "B")); + await waitFor(() => expect(result.current.data).toBe("B")); +}); + +it("ignores a slower earlier call that resolves after a newer one", async () => { + const { fetcher, resolve } = deferredCalls(); + const { result, rerender } = renderHook( + ({ k }) => useRemoteData(fetcher, k, undefined, { keepPreviousData: true }), + { initialProps: { k: "a" } }, + ); + rerender({ k: "b" }); + act(() => resolve(1, "B")); + await waitFor(() => expect(result.current.data).toBe("B")); + act(() => resolve(0, "A")); + await Promise.resolve(); + expect(result.current.data).toBe("B"); +}); diff --git a/src/hooks/useRemoteData.ts b/src/hooks/useRemoteData.ts index ee0457b..2af8235 100644 --- a/src/hooks/useRemoteData.ts +++ b/src/hooks/useRemoteData.ts @@ -11,6 +11,14 @@ export interface RemoteData { refresh: () => void; } +export interface RemoteDataOptions { + /** + * Keep showing the previous key's data until the new key's arrives, for + * keys that mean "reload" rather than "different data". + */ + keepPreviousData?: boolean; +} + /** * Loads data with `fetcher` when the component mounts (and whenever `key` * changes), optionally repeating every `pollMs`. Polling stops on unmount, so @@ -22,6 +30,7 @@ export function useRemoteData( fetcher: () => Promise, key: string, pollMs?: number, + { keepPreviousData = false }: RemoteDataOptions = {}, ): RemoteData { const [state, setState] = useState<{ key: string; data: T | null; error: AppError | null }>({ key, @@ -69,7 +78,8 @@ export function useRemoteData( const refresh = useCallback(() => setNonce((n) => n + 1), []); // Results of a previous key are stale for this one. - const current = state.key === key ? state : { data: null, error: null }; + const current = + state.key === key ? state : { data: keepPreviousData ? state.data : null, error: null }; return { data: current.data, error: current.error, diff --git a/src/services/benchmarks.test.ts b/src/services/benchmarks.test.ts index d9d8e5a..c296980 100644 --- a/src/services/benchmarks.test.ts +++ b/src/services/benchmarks.test.ts @@ -1,43 +1,172 @@ -import { beforeEach, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { isActive, useBenchmarkStore } from "@/stores/benchmarkStore"; -import { cancelBenchmark } from "./benchmarks"; +import { useToastStore } from "@/stores/toastStore"; +import type { BenchmarkStatus, DiskTargetInfo } from "@/types"; +import { cancelBenchmark, initBenchmarks, loadDiskTargets, runBenchmark } from "./benchmarks"; -const cancel = vi.hoisted(() => vi.fn<() => Promise>()); +const api = vi.hoisted(() => ({ + cancelBenchmark: vi.fn<() => Promise>(), + startBenchmark: vi.fn<() => Promise>(), + getBenchmarkStatus: vi.fn<() => Promise>(), + getBenchmarkHistory: vi.fn(() => Promise.resolve([])), + listDiskTargets: vi.fn<() => Promise>(), +})); vi.mock("@/services/ipc", async (orig) => ({ ...(await orig()), - backend: { cancelBenchmark: cancel }, + isBackendAvailable: () => true, + backend: api, })); +const listen = vi.hoisted(() => vi.fn()); +vi.mock("@tauri-apps/api/event", () => ({ listen })); + +const IDLE = useBenchmarkStore.getState().status; +const running: BenchmarkStatus = { + ...IDLE, + state: "running", + runId: "fixture", + kind: "internet", + fraction: 0.5, + elapsedMs: 5000, +}; beforeEach(() => { - cancel.mockReset(); - const store = useBenchmarkStore.getState(); - store.setCancelling(false); - store.setStatus({ ...store.status, state: "running", runId: "fixture", kind: "internet" }); + Object.values(api).forEach((m) => m.mockReset()); + api.getBenchmarkHistory.mockResolvedValue([]); + listen.mockReset(); + useToastStore.setState({ toasts: [] }); + useBenchmarkStore.setState({ status: IDLE, cancelling: false, diskTargets: null }); + useBenchmarkStore.getState().setStatus(running); +}); + +describe("cancelling", () => { + it("shows cancelling until the worker finishes and retains the active state", async () => { + let finish: () => void = () => {}; + api.cancelBenchmark.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const pending = cancelBenchmark(); + expect(useBenchmarkStore.getState().cancelling).toBe(true); + expect(isActive(useBenchmarkStore.getState().status)).toBe(true); + finish(); + await pending; + const store = useBenchmarkStore.getState(); + store.setStatus({ ...store.status, stage: "Download" }); + expect(useBenchmarkStore.getState().cancelling).toBe(true); + store.setStatus({ ...store.status, state: "cancelled" }); + expect(useBenchmarkStore.getState().cancelling).toBe(false); + }); + + it("allows retry if the cancellation request fails", async () => { + api.cancelBenchmark.mockRejectedValueOnce(new Error("fixture failure")); + await cancelBenchmark(); + expect(useBenchmarkStore.getState().cancelling).toBe(false); + expect(isActive(useBenchmarkStore.getState().status)).toBe(true); + }); +}); + +describe("status ordering", () => { + const status = () => useBenchmarkStore.getState().status; + + it("ignores an older snapshot of the same run", () => { + useBenchmarkStore.getState().setStatus({ ...running, elapsedMs: 4000, fraction: 0.4 }); + expect(status().elapsedMs).toBe(5000); + useBenchmarkStore.getState().setStatus({ ...running, elapsedMs: 6000, fraction: 0.6 }); + expect(status().elapsedMs).toBe(6000); + }); + + it("never returns a finished run to running", () => { + useBenchmarkStore.getState().setStatus({ ...running, state: "completed", fraction: 1 }); + useBenchmarkStore.getState().setStatus({ ...running, elapsedMs: 9000 }); + expect(status().state).toBe("completed"); + }); + + it("accepts any status of a different run", () => { + useBenchmarkStore + .getState() + .setStatus({ ...running, runId: "next", elapsedMs: 0, fraction: 0 }); + expect(status().runId).toBe("next"); + }); + + it("keeps newer progress events when the start reply arrives late", async () => { + let reply: (s: BenchmarkStatus) => void = () => {}; + api.startBenchmark.mockImplementationOnce( + () => + new Promise((resolve) => { + reply = resolve; + }), + ); + useBenchmarkStore.setState({ status: IDLE }); + const started = runBenchmark("internet", "standard"); + useBenchmarkStore.getState().setStatus(running); + reply({ ...running, state: "preparing", elapsedMs: 0, fraction: 0 }); + await started; + expect(status()).toEqual(running); + }); }); -it("shows cancelling until the worker finishes and retains the active state", async () => { - let finish: () => void = () => {}; - cancel.mockImplementationOnce( - () => - new Promise((resolve) => { - finish = resolve; - }), - ); - const pending = cancelBenchmark(); - expect(useBenchmarkStore.getState().cancelling).toBe(true); - expect(isActive(useBenchmarkStore.getState().status)).toBe(true); - finish(); - await pending; - const store = useBenchmarkStore.getState(); - store.setStatus({ ...store.status, stage: "Download" }); - expect(useBenchmarkStore.getState().cancelling).toBe(true); - store.setStatus({ ...store.status, state: "cancelled" }); - expect(useBenchmarkStore.getState().cancelling).toBe(false); +describe("initBenchmarks", () => { + it("can be retried after subscribing to events fails, without leaking listeners", async () => { + const unlisten = vi.fn(); + listen.mockResolvedValueOnce(unlisten).mockRejectedValueOnce(new Error("no event bus")); + await initBenchmarks(); + expect(unlisten).toHaveBeenCalledTimes(1); + expect(useToastStore.getState().toasts[0]?.message).toBe("no event bus"); + expect(api.getBenchmarkStatus).not.toHaveBeenCalled(); + + listen.mockResolvedValue(vi.fn()); + api.getBenchmarkStatus.mockResolvedValue(running); + await initBenchmarks(); + expect(listen).toHaveBeenCalledTimes(4); + expect(api.getBenchmarkStatus).toHaveBeenCalledTimes(1); + }); }); -it("allows retry if the cancellation request fails", async () => { - cancel.mockRejectedValueOnce(new Error("fixture failure")); - await cancelBenchmark(); - expect(useBenchmarkStore.getState().cancelling).toBe(false); - expect(isActive(useBenchmarkStore.getState().status)).toBe(true); +describe("loadDiskTargets", () => { + const target = (id: string) => ({ id }) as DiskTargetInfo; + + it("lists once, and again only on reload, keeping the old list meanwhile", async () => { + api.listDiskTargets.mockResolvedValueOnce([target("a")]); + await loadDiskTargets(); + await loadDiskTargets(); + expect(api.listDiskTargets).toHaveBeenCalledTimes(1); + + let reply: (t: DiskTargetInfo[]) => void = () => {}; + api.listDiskTargets.mockImplementationOnce( + () => + new Promise((resolve) => { + reply = resolve; + }), + ); + const reloading = loadDiskTargets({ reload: true }); + expect(useBenchmarkStore.getState().diskTargets).toEqual([target("a")]); + reply([target("b")]); + await reloading; + expect(useBenchmarkStore.getState().diskTargets).toEqual([target("b")]); + }); + + it("keeps the old list when a reload fails, and applies only the newest of overlapping reloads", async () => { + useBenchmarkStore.setState({ diskTargets: [target("a")] }); + api.listDiskTargets.mockRejectedValueOnce({ kind: "systemQueryFailed", message: "boom" }); + await loadDiskTargets({ reload: true }); + expect(useBenchmarkStore.getState().diskTargets).toEqual([target("a")]); + expect(useBenchmarkStore.getState().diskTargetsError).toBe("boom"); + + let slow: (t: DiskTargetInfo[]) => void = () => {}; + api.listDiskTargets + .mockImplementationOnce( + () => + new Promise((resolve) => { + slow = resolve; + }), + ) + .mockResolvedValueOnce([target("new")]); + const first = loadDiskTargets({ reload: true }); + await loadDiskTargets({ reload: true }); + slow([target("old")]); + await first; + expect(useBenchmarkStore.getState().diskTargets).toEqual([target("new")]); + }); }); diff --git a/src/services/benchmarks.ts b/src/services/benchmarks.ts index 89f3853..391c59d 100644 --- a/src/services/benchmarks.ts +++ b/src/services/benchmarks.ts @@ -1,5 +1,5 @@ /** Benchmark lifecycle: backend events into the store, and the run controls. */ -import { listen } from "@tauri-apps/api/event"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { isActive, useBenchmarkStore } from "@/stores/benchmarkStore"; import { toast } from "@/stores/toastStore"; import type { BenchmarkKind, BenchmarkProfile, BenchmarkResult, BenchmarkStatus } from "@/types"; @@ -9,6 +9,9 @@ const STATUS_EVENT = "benchmark://status"; const RESULT_EVENT = "benchmark://result"; let started = false; +let unlisteners: UnlistenFn[] = []; +/** Only the newest disk listing is applied when reloads overlap. */ +let diskTargetsRequest = 0; async function refreshHistory(): Promise { try { @@ -23,11 +26,23 @@ export async function initBenchmarks(): Promise { if (started || !isBackendAvailable()) return; started = true; const store = useBenchmarkStore.getState(); - await listen(STATUS_EVENT, (e) => store.setStatus(e.payload)); - await listen(RESULT_EVENT, (e) => { - store.setLatest(e.payload); - void refreshHistory(); - }); + try { + unlisteners.push( + await listen(STATUS_EVENT, (e) => store.setStatus(e.payload)), + ); + unlisteners.push( + await listen(RESULT_EVENT, (e) => { + store.setLatest(e.payload); + void refreshHistory(); + }), + ); + } catch (e) { + for (const unlisten of unlisteners) unlisten(); + unlisteners = []; + started = false; + toast.error(toAppError(e).message); + return; + } try { store.setStatus(await backend.getBenchmarkStatus()); } catch (e) { @@ -68,14 +83,20 @@ export async function loadGpuAdapters(): Promise { } } -/** Lists disk locations once; a failure is shown in the Disk card. */ -export async function loadDiskTargets(): Promise { +/** + * Lists disk locations once, or again with `reload` (drives changed); a + * failure is shown in the Disk card. The previous list stays up meanwhile. + */ +export async function loadDiskTargets({ reload = false } = {}): Promise { const store = useBenchmarkStore.getState(); - if (store.diskTargets !== null || !isBackendAvailable()) return; + if ((!reload && store.diskTargets !== null) || !isBackendAvailable()) return; + const request = ++diskTargetsRequest; try { - store.setDiskTargets(await backend.listDiskTargets()); + const targets = await backend.listDiskTargets(); + if (request === diskTargetsRequest) store.setDiskTargets(targets); } catch (e) { - store.setDiskTargets([], toAppError(e).message); + if (request !== diskTargetsRequest) return; + store.setDiskTargets(useBenchmarkStore.getState().diskTargets ?? [], toAppError(e).message); } } diff --git a/src/services/ipc.ts b/src/services/ipc.ts index 66e4146..f186974 100644 --- a/src/services/ipc.ts +++ b/src/services/ipc.ts @@ -137,8 +137,8 @@ export const backend = { /** Per-process shared-memory accounting; extra reads, so only while it is shown. */ setMemoryDetail: (enabled: boolean) => call("set_memory_detail", { enabled }), - getProcessDetails: (pid: number, startTime?: number) => - call("get_process_details", { pid, startTime: startTime ?? null }), + getProcessDetails: (pid: number, startTime: number) => + call("get_process_details", { pid, startTime }), assessProcessAction: (pid: number, action: ProcessAction) => call("assess_process_action", { pid, action }), diff --git a/src/services/monitoring.test.ts b/src/services/monitoring.test.ts new file mode 100644 index 0000000..fe2e55a --- /dev/null +++ b/src/services/monitoring.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const api = vi.hoisted(() => ({ + getStaticInfo: vi.fn(), + getMonitoringState: vi.fn(), + subscribeMetrics: vi.fn(), + getHistory: vi.fn(), + getRecentAlerts: vi.fn(), + getActivity: vi.fn(), + setMonitoringState: vi.fn(), + refreshNow: vi.fn(), +})); +vi.mock("@/services/ipc", async (orig) => ({ + ...(await orig()), + isBackendAvailable: () => true, + backend: api, +})); +vi.mock("./settings", () => ({ loadSettings: vi.fn(() => Promise.resolve()) })); + +// `startMonitoring` connects once per page load, so each test gets fresh modules. +async function load() { + vi.resetModules(); + const monitoring = await import("./monitoring"); + const { useToastStore } = await import("@/stores/toastStore"); + const { useMetricsStore } = await import("@/stores/metricsStore"); + return { ...monitoring, useToastStore, useMetricsStore }; +} + +const failure = { kind: "systemQueryFailed", message: "backend hiccup" }; + +beforeEach(() => { + Object.values(api).forEach((m) => m.mockReset()); + api.getStaticInfo.mockResolvedValue({}); + api.getMonitoringState.mockResolvedValue({ paused: false, intervalMs: 1000 }); + api.subscribeMetrics.mockResolvedValue(null); + api.getHistory.mockResolvedValue([]); + api.getRecentAlerts.mockResolvedValue([]); + api.getActivity.mockResolvedValue([]); + Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" }); +}); + +describe("startMonitoring", () => { + it("reports a failed backfill but never subscribes twice and still catches up on show", async () => { + const m = await load(); + api.getHistory.mockRejectedValueOnce(failure); + await m.startMonitoring(); + + expect(api.getRecentAlerts).toHaveBeenCalledTimes(1); + expect(m.useToastStore.getState().toasts[0]?.message).toMatch(/backend hiccup/); + await m.startMonitoring(); + expect(api.subscribeMetrics).toHaveBeenCalledTimes(1); + + document.dispatchEvent(new Event("visibilitychange")); + expect(api.getHistory).toHaveBeenCalledTimes(2); + }); + + it("allows a retry when it fails before subscribing", async () => { + const m = await load(); + api.getStaticInfo.mockRejectedValueOnce(failure); + await m.startMonitoring(); + expect(m.useMetricsStore.getState().status).toBe("error"); + expect(api.subscribeMetrics).not.toHaveBeenCalled(); + + await m.startMonitoring(); + expect(api.subscribeMetrics).toHaveBeenCalledTimes(1); + }); +}); + +describe("controls", () => { + it("rolls back and shows a toast when pausing fails", async () => { + const m = await load(); + api.setMonitoringState.mockRejectedValueOnce(failure); + await m.togglePaused(); + expect(m.useMetricsStore.getState().monitoring.paused).toBe(false); + expect(m.useToastStore.getState().toasts[0]).toMatchObject({ + tone: "error", + message: "Could not change monitoring: backend hiccup", + }); + }); + + it("shows a toast when a manual refresh fails", async () => { + const m = await load(); + api.refreshNow.mockRejectedValueOnce(failure); + await m.refreshNow(); + expect(m.useToastStore.getState().toasts[0]?.message).toBe("Could not refresh: backend hiccup"); + }); +}); diff --git a/src/services/monitoring.ts b/src/services/monitoring.ts index e29c73c..6d14e11 100644 --- a/src/services/monitoring.ts +++ b/src/services/monitoring.ts @@ -17,7 +17,11 @@ const BACKFILL_SECONDS = 3_600; let started = false; -/** Connects to the engine once per page load. Safe to call repeatedly. */ +/** + * Connects to the engine once per page load. Safe to call repeatedly: a + * failure before the subscription allows a retry; after it, the stream is + * live and a retry would subscribe twice, so later failures are only reported. + */ export async function startMonitoring(): Promise { if (started) return; started = true; @@ -39,16 +43,32 @@ export async function startMonitoring(): Promise { const latest = await backend.subscribeMetrics(onTick); if (latest) store.ingestTick(latest); + } catch (error) { + started = false; + store.setStatus("error", toAppError(error)); + return; + } - // Backfill after subscribing so no tick falls into a gap. + watchVisibility(); + // Backfill after subscribing so no tick falls into a gap. + try { store.backfillHistory(await backend.getHistory(BACKFILL_SECONDS)); + } catch (error) { + report("Could not load earlier history", error); + } + try { useAlertStore.getState().replace(await backend.getRecentAlerts()); - backfillActivity(); - watchVisibility(); } catch (error) { - started = false; - store.setStatus("error", toAppError(error)); + report("Could not load recent alerts", error); } + backfillActivity(); +} + +function report(what: string, error: unknown): void { + const store = useMetricsStore.getState(); + const appError = toAppError(error); + store.setStatus(store.status, appError); + toast.error(`${what}: ${appError.message}`); } function onTick(incoming: MetricsTick): void { @@ -110,7 +130,7 @@ async function applyState(next: MonitoringState): Promise { store.setMonitoring(await backend.setMonitoringState(next)); } catch (error) { store.setMonitoring(previous); - store.setStatus(store.status, toAppError(error)); + report("Could not change monitoring", error); } } @@ -119,17 +139,11 @@ export function togglePaused(): Promise { return applyState({ ...monitoring, paused: !monitoring.paused }); } -export function setRefreshInterval(intervalMs: number): Promise { - const { monitoring } = useMetricsStore.getState(); - return applyState({ ...monitoring, intervalMs }); -} - export async function refreshNow(): Promise { if (!isBackendAvailable()) return; try { await backend.refreshNow(); } catch (error) { - const store = useMetricsStore.getState(); - store.setStatus(store.status, toAppError(error)); + report("Could not refresh", error); } } diff --git a/src/services/settings.test.ts b/src/services/settings.test.ts index 3c10ddd..1a9f443 100644 --- a/src/services/settings.test.ts +++ b/src/services/settings.test.ts @@ -1,6 +1,18 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { DEFAULT_VISIBLE_COLUMNS } from "@/features/processes/logic"; -import { columnsFromSettings } from "./settings"; +import { useProcessStore } from "@/stores/processStore"; +import { DEFAULT_SETTINGS, useSettingsStore } from "@/stores/settingsStore"; +import { columnsFromSettings, loadSettings } from "./settings"; + +const api = vi.hoisted(() => ({ + getSettings: vi.fn(), + updateSettings: vi.fn((s: unknown) => Promise.resolve(s)), +})); +vi.mock("@/services/ipc", async (orig) => ({ + ...(await orig()), + isBackendAvailable: () => true, + backend: api, +})); describe("columnsFromSettings", () => { it("falls back to defaults when nothing is stored", () => { @@ -18,3 +30,24 @@ describe("columnsFromSettings", () => { expect(r.columnWidths).toEqual({ cpu: 120 }); }); }); + +describe("column sync", () => { + it("keeps the table and settings in step in both directions without echoing", async () => { + api.getSettings.mockResolvedValue({ + ...DEFAULT_SETTINGS, + visibleColumns: ["name", "cpu"], + columnWidths: { cpu: 120 }, + }); + await loadSettings(); + expect(useProcessStore.getState().visibleColumns).toEqual(["name", "cpu"]); + + useProcessStore.getState().setColumnWidth("cpu", 150); + expect(useSettingsStore.getState().columnWidths).toEqual({ cpu: 150 }); + + // A rolled-back save or a reset replaces the settings from outside the table. + useSettingsStore.setState({ visibleColumns: ["pid"], columnWidths: {} }); + expect(useProcessStore.getState().visibleColumns).toEqual(["name", "pid"]); + expect(useProcessStore.getState().columnWidths).toEqual({}); + expect(useSettingsStore.getState().visibleColumns).toEqual(["pid"]); + }); +}); diff --git a/src/services/settings.ts b/src/services/settings.ts index c1327db..b3a8e0c 100644 --- a/src/services/settings.ts +++ b/src/services/settings.ts @@ -27,6 +27,22 @@ export function columnsFromSettings(s: Pick void) | undefined; +/** Set while one store is copying columns into the other, so the copy is not echoed back. */ +let syncing = false; + +type Columns = { visibleColumns: unknown; columnWidths: unknown }; +const sameColumns = (a: Columns, b: Columns) => + a.visibleColumns === b.visibleColumns && a.columnWidths === b.columnWidths; + +function sync(copy: () => void): void { + if (syncing) return; + syncing = true; + try { + copy(); + } finally { + syncing = false; + } +} export async function loadSettings(): Promise { if (!isBackendAvailable()) return; @@ -39,15 +55,24 @@ export async function loadSettings(): Promise { } unsubscribe?.(); - unsubscribe = useProcessStore.subscribe((state, prev) => { - if (state.visibleColumns === prev.visibleColumns && state.columnWidths === prev.columnWidths) { - return; - } - useSettingsStore.getState().update({ - visibleColumns: state.visibleColumns, - columnWidths: Object.fromEntries( - Object.entries(state.columnWidths).filter(([, w]) => w != null), - ) as Record, - }); + const fromTable = useProcessStore.subscribe((state, prev) => { + if (sameColumns(state, prev)) return; + sync(() => + useSettingsStore.getState().update({ + visibleColumns: state.visibleColumns, + columnWidths: Object.fromEntries( + Object.entries(state.columnWidths).filter(([, w]) => w != null), + ) as Record, + }), + ); + }); + // Settings change on their own after a reset or a rolled-back save. + const fromSettings = useSettingsStore.subscribe((state, prev) => { + if (sameColumns(state, prev)) return; + sync(() => useProcessStore.setState(columnsFromSettings(state))); }); + unsubscribe = () => { + fromTable(); + fromSettings(); + }; } diff --git a/src/stores/benchmarkStore.ts b/src/stores/benchmarkStore.ts index 67f8703..0f4e25f 100644 --- a/src/stores/benchmarkStore.ts +++ b/src/stores/benchmarkStore.ts @@ -60,10 +60,14 @@ export const useBenchmarkStore = create((set) => ({ diskTargetsError: null, setStatus: (status) => - set((s) => ({ - status, - cancelling: isActive(status) && s.status.runId === status.runId && s.cancelling, - })), + set((s) => + isOutdated(s.status, status) + ? s + : { + status, + cancelling: isActive(status) && s.status.runId === status.runId && s.cancelling, + }, + ), setCancelling: (cancelling) => set({ cancelling }), setHistory: (history) => set({ history }), setLatest: (latest) => set({ latest, selectedId: null }), @@ -77,6 +81,16 @@ export function isActive(status: BenchmarkStatus): boolean { return !["idle", "completed", "cancelled", "failed"].includes(status.state); } +/** + * Command replies (start, get status) can arrive after newer progress events + * for the same run; such a snapshot would move progress backwards. + */ +function isOutdated(current: BenchmarkStatus, next: BenchmarkStatus): boolean { + if (next.runId === null || next.runId !== current.runId) return false; + if (!isActive(current)) return isActive(next); + return next.elapsedMs < current.elapsedMs || next.fraction < current.fraction; +} + /** * The result to show in the card for `kind`: the one the user opened if it is * of that kind, else the latest run of that kind, else the newest saved one. diff --git a/src/stores/toastStore.test.ts b/src/stores/toastStore.test.ts new file mode 100644 index 0000000..3542c51 --- /dev/null +++ b/src/stores/toastStore.test.ts @@ -0,0 +1,42 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { toast, useToastStore } from "./toastStore"; + +const messages = () => useToastStore.getState().toasts.map((t) => t.message); + +beforeEach(() => { + vi.useFakeTimers(); + useToastStore.setState({ toasts: [] }); +}); +afterEach(() => { + vi.useRealTimers(); +}); + +it("dismisses plain toasts after their duration", () => { + toast.success("Saved"); + vi.advanceTimersByTime(3999); + expect(messages()).toEqual(["Saved"]); + vi.advanceTimersByTime(1); + expect(messages()).toEqual([]); +}); + +it("keeps a toast with an action until it is used or dismissed", () => { + toast.error("Could not close", { label: "Force end…", run: () => {} }); + vi.advanceTimersByTime(60_000); + expect(messages()).toEqual(["Could not close"]); + useToastStore.getState().dismiss(useToastStore.getState().toasts[0]!.id); + expect(messages()).toEqual([]); +}); + +it("pauses the countdown and resumes with the time that was left", () => { + toast.info("Copied"); + const { id } = useToastStore.getState().toasts[0]!; + vi.advanceTimersByTime(3000); + useToastStore.getState().pause(id); + vi.advanceTimersByTime(60_000); + expect(messages()).toEqual(["Copied"]); + useToastStore.getState().resume(id); + vi.advanceTimersByTime(999); + expect(messages()).toEqual(["Copied"]); + vi.advanceTimersByTime(1); + expect(messages()).toEqual([]); +}); diff --git a/src/stores/toastStore.ts b/src/stores/toastStore.ts index b6c3d05..54c259d 100644 --- a/src/stores/toastStore.ts +++ b/src/stores/toastStore.ts @@ -6,7 +6,7 @@ export interface Toast { id: number; tone: ToastTone; message: string; - /** Optional follow-up action, e.g. "Force end" after a failed close. */ + /** Optional follow-up action, e.g. "Force end" after a failed close. Stays until used or dismissed. */ action?: { label: string; run: () => void }; } @@ -14,19 +14,61 @@ interface ToastState { toasts: Toast[]; push: (toast: Omit) => void; dismiss: (id: number) => void; + /** Stops a toast's dismissal countdown while the user is reading or using it. */ + pause: (id: number) => void; + resume: (id: number) => void; } const DURATION_MS = { success: 4000, info: 4000, error: 8000 } as const; +const MAX_TOASTS = 4; let nextId = 1; -export const useToastStore = create((set, get) => ({ +interface Countdown { + handle: ReturnType | undefined; + remainingMs: number; + startedAt: number; +} +const countdowns = new Map(); + +function startCountdown(id: number, c: Countdown): void { + c.startedAt = Date.now(); + c.handle = setTimeout(() => useToastStore.getState().dismiss(id), c.remainingMs); +} + +function clearCountdown(id: number): void { + clearTimeout(countdowns.get(id)?.handle); + countdowns.delete(id); +} + +export const useToastStore = create((set) => ({ toasts: [], push: (toast) => { const id = nextId++; - set((s) => ({ toasts: [...s.toasts.slice(-3), { ...toast, id }] })); - setTimeout(() => get().dismiss(id), DURATION_MS[toast.tone]); + set((s) => { + const kept = s.toasts.slice(-(MAX_TOASTS - 1)); + for (const t of s.toasts) if (!kept.includes(t)) clearCountdown(t.id); + return { toasts: [...kept, { ...toast, id }] }; + }); + if (toast.action) return; + const c: Countdown = { handle: undefined, remainingMs: DURATION_MS[toast.tone], startedAt: 0 }; + countdowns.set(id, c); + startCountdown(id, c); + }, + dismiss: (id) => { + clearCountdown(id); + set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })); + }, + pause: (id) => { + const c = countdowns.get(id); + if (c?.handle === undefined) return; + clearTimeout(c.handle); + c.handle = undefined; + c.remainingMs = Math.max(0, c.remainingMs - (Date.now() - c.startedAt)); + }, + resume: (id) => { + const c = countdowns.get(id); + if (c && c.handle === undefined) startCountdown(id, c); }, - dismiss: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })), })); export const toast = { diff --git a/src/types/generated/SocketEntry.ts b/src/types/generated/SocketEntry.ts index ae224b4..a9dddea 100644 --- a/src/types/generated/SocketEntry.ts +++ b/src/types/generated/SocketEntry.ts @@ -9,4 +9,9 @@ export type SocketEntry = { connection: Connection, * `None` when the owner cannot be determined (another user's process * without privileges, or a closing socket the kernel no longer attributes). */ -pid: number | null, processName: string | null, }; +pid: number | null, processName: string | null, +/** + * Start time of the owner, as in `ProcessRow::start_time`, so the UI can + * tell it from a later process that reused the PID. `None` without an owner. + */ +startTime: number | null, };