mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-27 20:18:16 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6494740fd1 | ||
|
|
c0bb291754 | ||
|
|
37cf7a3a42 |
No files matched your search
Generated
+2
-1
@@ -4693,12 +4693,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "usage-monitor"
|
name = "usage-monitor"
|
||||||
version = "0.8.1"
|
version = "0.9.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"auto-launch",
|
"auto-launch",
|
||||||
"dbus",
|
"dbus",
|
||||||
"dirs 6.0.0",
|
"dirs 6.0.0",
|
||||||
"flate2",
|
"flate2",
|
||||||
|
"jiff",
|
||||||
"libc",
|
"libc",
|
||||||
"libloading 0.9.0",
|
"libloading 0.9.0",
|
||||||
"log",
|
"log",
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "usage-monitor"
|
name = "usage-monitor"
|
||||||
version = "0.8.1"
|
version = "0.9.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
description = "Floating bubble showing Claude Code and Codex usage"
|
description = "Floating bubble showing Claude Code and Codex usage"
|
||||||
@@ -23,6 +23,8 @@ tauri-plugin-single-instance = "2"
|
|||||||
ureq = { version = "3", features = ["platform-verifier", "socks-proxy"] }
|
ureq = { version = "3", features = ["platform-verifier", "socks-proxy"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
# Local time zone for the clock-aligned poll and update check.
|
||||||
|
jiff = "0.2"
|
||||||
dirs = "6"
|
dirs = "6"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
simplelog = "0.12"
|
simplelog = "0.12"
|
||||||
|
|||||||
@@ -4,291 +4,160 @@
|
|||||||
|
|
||||||
# Usage Monitor
|
# Usage Monitor
|
||||||
|
|
||||||
A floating, draggable circular bubble that shows your Claude Code and/or
|
A floating, see-through bubble that shows how much of your Claude Code and
|
||||||
Codex usage on Windows and Linux — inspired by the floating "memory boost
|
Codex limits you have used, on Windows and Linux x86_64. Linux ARM64 and macOS
|
||||||
ball" UX of 360 Security and IObit Advanced SystemCare.
|
are not supported yet; see [ROADMAP.md](ROADMAP.md).
|
||||||
|
|
||||||
Drop it anywhere on screen, drag it around, snap it to a monitor edge,
|
## Features
|
||||||
left-click for a panel with both your 5-hour and 7-day windows, right-click
|
|
||||||
for the menu.
|
|
||||||
|
|
||||||
Linux ARM64 and macOS are not supported yet; see [ROADMAP.md](ROADMAP.md).
|
- **The bubble:** a round head with your 5-hour usage and a progress ring,
|
||||||
|
and a tail with the 7-day usage and time to reset. Left-click it for a
|
||||||
## Acknowledgements
|
panel with both windows and their countdowns.
|
||||||
|
- **Place it anywhere:** drag it, and it snaps to a screen edge when you let
|
||||||
Inspired by [CodeZeno/Claude-Code-Usage-Monitor](https://github.com/CodeZeno/Claude-Code-Usage-Monitor),
|
go near one. Resize it with `Ctrl` + mouse wheel.
|
||||||
which solves the same "how close am I to the Claude Code limit?" problem
|
- **Always on top:** it stays above other windows, chat notifications
|
||||||
with a horizontal taskbar widget. This project takes the UX in a different
|
included, and hides while a fullscreen app (a game, a video, slides) is
|
||||||
direction — a floating, draggable circular bubble that the user can place
|
in front.
|
||||||
anywhere on screen — and is a clean-room implementation: the HTTP client,
|
- **Click-through:** an option to let clicks pass through the bubble to the
|
||||||
provider polling, credential discovery, tray rendering, and
|
window below.
|
||||||
self-updater are all written from scratch against the same public APIs
|
- **One bubble per provider:** Claude Code is on by default; Codex is
|
||||||
(Anthropic, ChatGPT, GitHub Releases).
|
optional.
|
||||||
|
- **Tray and alerts:** a tray icon with a usage badge per provider, and
|
||||||
## What you get
|
desktop notifications when usage crosses a threshold or a sign-in expires.
|
||||||
|
- **Updates itself:** new releases install automatically.
|
||||||
- A see-through floating bubble: a round head showing your current 5-hour
|
|
||||||
Claude Code or Codex usage as a percentage and a colored progress ring,
|
|
||||||
with a slimmer tail holding the 7-day usage and reset bars
|
|
||||||
- Drag anywhere — the bubble snaps to monitor work-area edges when
|
|
||||||
released
|
|
||||||
- Resize with `Ctrl + MouseWheel` on the bubble, or use **Controls** →
|
|
||||||
**Make smaller / Make larger / Reset size** from the right-click menu
|
|
||||||
(140–360 logical pixels)
|
|
||||||
- Left-click the bubble for an expanded panel with both **5h** and **7d**
|
|
||||||
bars plus reset countdowns
|
|
||||||
- Right-click for refresh, displayed providers, update frequency, startup,
|
|
||||||
**Reset position**, updates, **Show widget**, **Click-through**,
|
|
||||||
**Restart** and **Exit**
|
|
||||||
- A system tray icon per enabled provider, with a usage badge
|
|
||||||
- Desktop notifications when usage crosses a threshold, when a sign-in
|
|
||||||
expires and after an update was applied
|
|
||||||
- Auto-hide when a fullscreen app is in the foreground (games, video,
|
|
||||||
presentations) — reappears when you leave fullscreen
|
|
||||||
- Bubbles stay above other always-on-top windows, such as chat
|
|
||||||
notifications: on Windows a bubble goes back on top the moment another
|
|
||||||
app shows such a window over it, and on every platform shown bubbles are
|
|
||||||
put back on top every minute, so nothing can keep them covered or hidden
|
|
||||||
|
|
||||||
## Who this is for
|
|
||||||
|
|
||||||
Windows 10/11 and Linux x86_64 users who already have **Claude Code (CLI or
|
|
||||||
App) installed and signed in** on the same machine. Codex support is
|
|
||||||
optional — install and sign in to the Codex CLI, then enable Codex from the
|
|
||||||
right-click **Providers** menu.
|
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Windows 10 or Windows 11, or Linux x86_64 (see below)
|
- Windows 10/11, or Linux x86_64.
|
||||||
- Claude Code (CLI or App) installed and authenticated
|
- Claude Code (CLI or app), installed and signed in. The `claude` CLI must
|
||||||
- The `claude` CLI, for automatic token refresh. The app looks for it on
|
be on `PATH` (on Linux, `~/.local/bin` and `~/.claude/local` work too) so
|
||||||
`PATH`, and on Linux also in `~/.local/bin` and `~/.claude/local`. Without
|
the app can refresh an expired sign-in.
|
||||||
it an expired sign-in is not refreshed and you have to sign in again.
|
- For Codex: the Codex CLI, installed and signed in.
|
||||||
- Optional: Codex CLI installed and authenticated, if you want Codex usage.
|
- **Windows:** the
|
||||||
Its token is refreshed through the `codex` CLI, found the same way.
|
[WebView2 runtime](https://developer.microsoft.com/microsoft-edge/webview2/).
|
||||||
|
Windows 11 and most up-to-date Windows 10 machines have it; if not, the
|
||||||
### Windows
|
app tells you and exits.
|
||||||
|
- **Linux:**
|
||||||
The app draws its windows with the Microsoft Edge **WebView2 runtime**.
|
- WebKitGTK 4.1 and `libayatana-appindicator3-1` for the tray icons. The
|
||||||
Windows 11 ships it, and most up-to-date Windows 10 machines already have it.
|
`.deb` installs both.
|
||||||
If it is missing, the app shows a message with the download link and exits;
|
- A compositing window manager, or the bubble's corners are drawn
|
||||||
install the
|
opaque.
|
||||||
[Evergreen WebView2 runtime](https://developer.microsoft.com/microsoft-edge/webview2/)
|
- An X11 session. Under Wayland the app runs through Xwayland. Without
|
||||||
and start the app again.
|
Xwayland the bubbles show, but cannot be positioned, snapped or kept on
|
||||||
|
top.
|
||||||
### Linux
|
|
||||||
|
|
||||||
- **Runtime libraries:** WebKitGTK 4.1 (`libwebkit2gtk-4.1-0` on
|
|
||||||
Debian/Ubuntu) and, for the tray icons, `libayatana-appindicator3-1`.
|
|
||||||
The `.deb` pulls these in. Without an AppIndicator library the app runs
|
|
||||||
without tray icons.
|
|
||||||
- **A compositing window manager.** The bubble is a transparent window;
|
|
||||||
without a compositor its corners are drawn opaque.
|
|
||||||
- **X11 is the supported session.** Under Wayland the app runs through
|
|
||||||
Xwayland, where positioning, snapping and always-on-top work. Without
|
|
||||||
Xwayland (or with `GDK_BACKEND=wayland` set) the bubbles still show, but
|
|
||||||
they cannot be positioned, snapped, kept on top or saved where you drop
|
|
||||||
them.
|
|
||||||
- **Tray limits:** Linux tray icons (AppIndicator) only open the menu. They
|
|
||||||
show no tooltip, and a left click does not toggle the bubble; use the
|
|
||||||
menu instead.
|
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
### Windows
|
Download from the
|
||||||
|
[latest release](https://github.com/tiennm99/usage-monitor/releases/latest).
|
||||||
|
|
||||||
Grab `usage-monitor.exe` from the
|
- **Windows:** `usage-monitor.exe`. Put it in a folder you can write to,
|
||||||
[Releases page](https://github.com/tiennm99/usage-monitor/releases/latest).
|
such as `%LOCALAPPDATA%\UsageMonitor\`, and run it. The exe is unsigned:
|
||||||
Put it in a folder of its own that you can write to (e.g.
|
when SmartScreen warns, click **More info** → **Run anyway**.
|
||||||
`%LOCALAPPDATA%\UsageMonitor\`) and run it. The app is portable: it keeps
|
- **Linux AppImage:** `usage-monitor_X.Y.Z_amd64.AppImage`. Put it in a
|
||||||
`settings.json` next to the exe, so moving the folder keeps your settings. It
|
folder you can write to, run `chmod +x` on it, and run it.
|
||||||
updates itself from the same Releases feed: when a check finds a new
|
- **Linux .deb:** `sudo apt install ./usage-monitor_X.Y.Z_amd64.deb`.
|
||||||
release, it installs it and restarts on its own, with no click needed.
|
|
||||||
|
|
||||||
**First-run note:** the binary is unsigned, so SmartScreen will show
|
The exe and the AppImage are portable: they keep `settings.json` next to
|
||||||
"Windows protected your PC". Click **More info** → **Run anyway**. Code
|
themselves.
|
||||||
signing is on the roadmap.
|
|
||||||
|
|
||||||
### Linux x86_64
|
## Use
|
||||||
|
|
||||||
From the same
|
- **Left-click** the bubble for the details panel.
|
||||||
[Releases page](https://github.com/tiennm99/usage-monitor/releases/latest),
|
- **Right-click** the bubble or the tray icon for the menu:
|
||||||
pick one:
|
- **Refresh**
|
||||||
|
- **Update frequency:** how often usage is fetched.
|
||||||
|
- **Providers:** which bubbles to show.
|
||||||
|
- **Settings:** start at login, **Reset position**, **Check for updates**
|
||||||
|
and the **Auto-update check** interval.
|
||||||
|
- **Controls:** resize.
|
||||||
|
- **Show widget**
|
||||||
|
- **Click-through**
|
||||||
|
- **Restart** and **Exit**
|
||||||
|
- **Tray icon:** a left click shows or hides the bubbles. On Linux any click
|
||||||
|
opens the menu, and there is no tooltip.
|
||||||
|
- **With click-through on**, the bubbles ignore the mouse. Turn it off from
|
||||||
|
the tray menu.
|
||||||
|
|
||||||
- `usage-monitor_X.Y.Z_amd64.AppImage`: make it executable
|
Timers follow your local clock. Every 5 minutes means :00, :05, :10 and so
|
||||||
(`chmod +x`) and run it. Like the Windows exe it is portable and keeps
|
on; hourly means the top of the hour, daily means midnight, and weekly means
|
||||||
`settings.json` next to the AppImage file, so give it a folder you can
|
Monday at midnight.
|
||||||
write to. The AppImage updates itself in place, the same way. If you turned on "Start
|
|
||||||
at login" in v0.6.0 or earlier and the AppImage path contains a space,
|
|
||||||
turn it off and on again once: those releases wrote the path unquoted,
|
|
||||||
so the app never started at login.
|
|
||||||
- `usage-monitor_X.Y.Z_amd64.deb`: install it with
|
|
||||||
`sudo apt install ./usage-monitor_X.Y.Z_amd64.deb`. The app
|
|
||||||
still checks for updates, but installing one is left to you: download the
|
|
||||||
new `.deb` and install it the same way.
|
|
||||||
|
|
||||||
### Build from source
|
### Updates
|
||||||
|
|
||||||
Prerequisites: the [Rust toolchain](https://www.rust-lang.org/tools/install)
|
The app checks GitHub Releases on the **Auto-update check** schedule
|
||||||
and [Node.js](https://nodejs.org/) with npm. The Tauri CLI is a local dev
|
(hourly by default). At launch it checks only if a scheduled time has passed
|
||||||
dependency, so `npm ci` installs it.
|
since the last check. A failed check is retried after 1, 2, 4… minutes, but
|
||||||
|
never later than the next scheduled time.
|
||||||
|
|
||||||
On Windows:
|
When a check finds a new release, the exe and the AppImage install it and
|
||||||
|
restart on their own. The `.deb` only tells you; install the new `.deb` the
|
||||||
|
same way you installed the first.
|
||||||
|
|
||||||
```powershell
|
## Files and diagnostics
|
||||||
git clone https://github.com/tiennm99/usage-monitor
|
|
||||||
cd usage-monitor
|
|
||||||
npm ci
|
|
||||||
npm run tauri build -- --no-bundle
|
|
||||||
```
|
|
||||||
|
|
||||||
The binary lands at `target\release\usage-monitor.exe`.
|
Run with `--diagnose` to write a log, which is cleared on each start.
|
||||||
|
|
||||||
On Linux x86_64, install the build dependencies first (Debian/Ubuntu):
|
| What | Windows | Linux |
|
||||||
|
|------|---------|-------|
|
||||||
|
| Log (`--diagnose`) | `%TEMP%\usage-monitor.log` | `~/.local/state/usage-monitor/usage-monitor.log` |
|
||||||
|
| Settings | `settings.json` next to the exe | next to the AppImage; for the `.deb`, `~/.config/UsageMonitor/settings.json` |
|
||||||
|
| Update downloads, `update-error.log` | `%LOCALAPPDATA%\UsageMonitor\updates\` | `~/.local/share/UsageMonitor/updates/` |
|
||||||
|
| Web view cache | `%LOCALAPPDATA%\io.github.tiennm99.usagemonitor\` | `~/.local/share/io.github.tiennm99.usagemonitor/` |
|
||||||
|
|
||||||
|
On Linux the `XDG_*` variables move these folders when they are set. On
|
||||||
|
Windows the app also writes `%LOCALAPPDATA%\UsageMonitor\icon.png` and an
|
||||||
|
`HKCU\Software\Classes\AppUserModelId` entry, which notifications from a
|
||||||
|
portable exe need.
|
||||||
|
|
||||||
|
## Privacy
|
||||||
|
|
||||||
|
- **Reads:** your Claude Code credentials (`~/.claude/.credentials.json`)
|
||||||
|
and, with Codex on, your Codex credentials (`$CODEX_HOME/auth.json` or
|
||||||
|
`~/.codex/auth.json`).
|
||||||
|
- **Sends:** usage requests to Anthropic and, with Codex on, to ChatGPT,
|
||||||
|
plus update checks to GitHub Releases. Nothing else: no analytics, no
|
||||||
|
backend, and no credentials sent to any third party.
|
||||||
|
- **Runs:** when a sign-in has expired, `claude -p .` or `codex exec .`
|
||||||
|
runs hidden in the temp folder, so the CLI refreshes its token. Codex runs
|
||||||
|
with `--skip-git-repo-check` and a read-only sandbox. Each run sends one
|
||||||
|
tiny prompt, which counts toward your usage.
|
||||||
|
- **Stores:** bubble positions and size, the menu choices, and the time of
|
||||||
|
the last update check.
|
||||||
|
|
||||||
|
## Build from source
|
||||||
|
|
||||||
|
You need [Rust](https://www.rust-lang.org/tools/install) and
|
||||||
|
[Node.js](https://nodejs.org/). On Debian/Ubuntu, install the build
|
||||||
|
dependencies first:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file \
|
sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file \
|
||||||
libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev
|
libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Then:
|
||||||
|
|
||||||
|
```bash
|
||||||
git clone https://github.com/tiennm99/usage-monitor
|
git clone https://github.com/tiennm99/usage-monitor
|
||||||
cd usage-monitor
|
cd usage-monitor
|
||||||
npm ci
|
npm ci
|
||||||
npm run tauri build -- --no-bundle
|
npm run tauri build -- --no-bundle
|
||||||
```
|
```
|
||||||
|
|
||||||
The binary lands at `target/release/usage-monitor`. To build the
|
The binary lands in `target/release/`. For the `.deb` and AppImage, install
|
||||||
`.deb` and AppImage instead, also install `patchelf` (the AppImage needs it)
|
`patchelf` and run `npm run tauri build -- --bundles deb,appimage` instead.
|
||||||
and run `npm run tauri build -- --bundles deb,appimage`; they land under
|
Local builds are unsigned, so they can't be published as updates; see
|
||||||
`target/release/bundle/`.
|
|
||||||
|
|
||||||
Local builds carry no updater signature (`createUpdaterArtifacts` is off in
|
|
||||||
`tauri.conf.json`), so they cannot be published as an update. Only the
|
|
||||||
release workflow signs builds; see
|
|
||||||
[docs/release-process.md](docs/release-process.md).
|
[docs/release-process.md](docs/release-process.md).
|
||||||
|
|
||||||
## Use
|
## Acknowledgements
|
||||||
|
|
||||||
Run the app. The bubble appears near the bottom-right corner of your
|
Inspired by
|
||||||
primary monitor on first launch. Drag it where you want it, release to snap
|
[CodeZeno/Claude-Code-Usage-Monitor](https://github.com/CodeZeno/Claude-Code-Usage-Monitor),
|
||||||
to the nearest edge if you let go close to one.
|
a taskbar widget for the same problem, and by the floating "boost ball" of
|
||||||
|
360 Security and IObit Advanced SystemCare. This is a clean-room
|
||||||
- **Left-click** the bubble to open the expanded panel (5h + 7d + countdowns)
|
implementation against the same public APIs.
|
||||||
- **Right-click** for **Refresh**, **Update frequency**, **Providers**,
|
|
||||||
**Settings**, **Controls**, **Show widget**, **Click-through**,
|
|
||||||
**Restart** and **Exit**.
|
|
||||||
**Settings** holds "Start with Windows" ("Start at login" on Linux),
|
|
||||||
**Reset position**, a manual **Check for updates** and the
|
|
||||||
**Auto-update check** interval (Disabled / Hourly / Daily / Weekly)
|
|
||||||
- **Drag** anywhere — it floats on top of all other windows
|
|
||||||
- **Ctrl + MouseWheel** on the bubble, or **Controls** in the right-click
|
|
||||||
menu, to resize it
|
|
||||||
- **Tray icon:** left-click toggles the bubble visibility, right-click opens
|
|
||||||
the same menu (on Linux, any click opens the menu)
|
|
||||||
- **Click-through:** when checked, the bubbles ignore the mouse, so clicks
|
|
||||||
reach the windows underneath. You can't drag, resize or right-click a
|
|
||||||
bubble then; uncheck it from the tray icon's menu
|
|
||||||
|
|
||||||
### Providers
|
|
||||||
|
|
||||||
Use the right-click **Providers** menu to choose what is shown:
|
|
||||||
|
|
||||||
- **Claude Code** is enabled by default
|
|
||||||
- **Codex** can be enabled alongside Claude Code or shown by itself
|
|
||||||
|
|
||||||
When multiple providers are shown, each gets its own bubble that you can position
|
|
||||||
independently.
|
|
||||||
|
|
||||||
### Changes from the v0.5.0 Windows build
|
|
||||||
|
|
||||||
The app moved from a hand-written Win32 UI to Tauri. Most behaviour is the
|
|
||||||
same; these differences are visible:
|
|
||||||
|
|
||||||
- The transparent corners around the round bubble no longer pass clicks
|
|
||||||
through to the window below; they just ignore clicks.
|
|
||||||
- Alerts are Windows toast notifications instead of tray balloons, so they
|
|
||||||
no longer use a warning or info icon.
|
|
||||||
- Proxy auto-configuration (PAC scripts and WPAD) is no longer used. A
|
|
||||||
static system proxy, its bypass list and the `HTTPS_PROXY`-style
|
|
||||||
environment variables still are, including a SOCKS `ALL_PROXY` such as
|
|
||||||
`socks5h://127.0.0.1:7890`, for usage requests and updates alike.
|
|
||||||
- Only one copy runs per signed-in user session, instead of one per machine.
|
|
||||||
|
|
||||||
## Diagnostics
|
|
||||||
|
|
||||||
```text
|
|
||||||
usage-monitor.exe --diagnose # Windows
|
|
||||||
usage-monitor --diagnose # Linux .deb (pass the same flag to the AppImage)
|
|
||||||
```
|
|
||||||
|
|
||||||
This writes a log file, truncated on each start, to:
|
|
||||||
|
|
||||||
| OS | Log file |
|
|
||||||
|----|----------|
|
|
||||||
| Windows | `%TEMP%\usage-monitor.log` |
|
|
||||||
| Linux | `$XDG_STATE_HOME/usage-monitor/usage-monitor.log`, usually `~/.local/state/usage-monitor/usage-monitor.log` (readable by your user only) |
|
|
||||||
|
|
||||||
Settings are saved to:
|
|
||||||
|
|
||||||
| OS | Settings file |
|
|
||||||
|----|---------------|
|
|
||||||
| Windows | `settings.json` next to `usage-monitor.exe` |
|
|
||||||
| Linux AppImage | `settings.json` next to the `.AppImage` file |
|
|
||||||
| Linux `.deb` | `$XDG_CONFIG_HOME/UsageMonitor/settings.json`, usually `~/.config/UsageMonitor/settings.json` |
|
|
||||||
|
|
||||||
Other files the app writes:
|
|
||||||
|
|
||||||
| What | Windows | Linux |
|
|
||||||
|------|---------|-------|
|
|
||||||
| Update downloads and `update-error.log` | `%LOCALAPPDATA%\UsageMonitor\updates\` | `~/.local/share/UsageMonitor/updates/` |
|
|
||||||
| Web view profile (cache, storage) | `%LOCALAPPDATA%\io.github.tiennm99.usagemonitor\` (WebView2) | `~/.local/share/io.github.tiennm99.usagemonitor/` (WebKitGTK) |
|
|
||||||
|
|
||||||
On Windows the app also writes its notification icon to
|
|
||||||
`%LOCALAPPDATA%\UsageMonitor\icon.png` and registers it under
|
|
||||||
`HKCU\Software\Classes\AppUserModelId\io.github.tiennm99.usagemonitor`,
|
|
||||||
which Windows needs to show toasts from a portable exe.
|
|
||||||
|
|
||||||
## Privacy and security
|
|
||||||
|
|
||||||
What the app reads:
|
|
||||||
|
|
||||||
- Your local Claude Code OAuth credentials from `~/.claude/.credentials.json`
|
|
||||||
- If Codex is enabled, your local Codex credentials from `$CODEX_HOME/auth.json`
|
|
||||||
or `~/.codex/auth.json`
|
|
||||||
|
|
||||||
What the app sends over the network:
|
|
||||||
|
|
||||||
- Requests to Anthropic's Claude endpoints to read your usage
|
|
||||||
- Requests to ChatGPT's Codex usage endpoint, if Codex is enabled
|
|
||||||
- Requests for `latest.json` from GitHub Releases, hourly by default and at
|
|
||||||
launch when the last check is older than that. A failed check is retried
|
|
||||||
sooner, starting after a minute and backing off up to the interval. Set **Settings** → **Auto-update check** to
|
|
||||||
**Disabled** in the right-click menu to stop them; **Check for updates**
|
|
||||||
still works when you click it.
|
|
||||||
|
|
||||||
What the app runs:
|
|
||||||
|
|
||||||
- When a usage request is rejected because the sign-in has expired, the app
|
|
||||||
runs `claude -p .` for Claude Code and `codex exec .` for Codex, for each
|
|
||||||
provider that was rejected. They run hidden in the background, so the CLI
|
|
||||||
refreshes its token. Each run sends one tiny prompt, which counts toward
|
|
||||||
your usage. The runs use the system temporary folder as their working
|
|
||||||
folder, and Codex runs with `--skip-git-repo-check` and a read-only
|
|
||||||
sandbox.
|
|
||||||
|
|
||||||
What the app stores locally:
|
|
||||||
|
|
||||||
- Bubble position(s) per model
|
|
||||||
- Bubble size
|
|
||||||
- Whether the bubbles are shown (**Show widget**)
|
|
||||||
- Whether the bubbles let clicks through (**Click-through**)
|
|
||||||
- Polling frequency
|
|
||||||
- Auto-update check interval
|
|
||||||
- Last update check time
|
|
||||||
- Displayed provider preferences
|
|
||||||
|
|
||||||
What it does **not** do: send credentials to any third-party server, run a
|
|
||||||
backend service, collect analytics, upload your project files, or write to
|
|
||||||
your Codex `auth.json` directly.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Apache License 2.0 — see [LICENSE](LICENSE).
|
Apache License 2.0. See [LICENSE](LICENSE).
|
||||||
+110
-22
@@ -8,7 +8,11 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::time::{Duration, Instant, SystemTime};
|
use std::time::{Duration, Instant, SystemTime};
|
||||||
|
|
||||||
|
use jiff::tz::TimeZone;
|
||||||
|
use jiff::{Timestamp, ToSpan, Zoned};
|
||||||
|
|
||||||
use crate::core::i18n::{self, LocaleStrings};
|
use crate::core::i18n::{self, LocaleStrings};
|
||||||
|
use crate::core::settings;
|
||||||
use crate::usage::{ProviderId, UsageWindows, Window};
|
use crate::usage::{ProviderId, UsageWindows, Window};
|
||||||
|
|
||||||
/// Minimum gap between two cooldown-gated alerts (threshold and session
|
/// Minimum gap between two cooldown-gated alerts (threshold and session
|
||||||
@@ -291,27 +295,78 @@ pub enum UpdateCheckDue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Schedule the automatic update check from the last check time (unix
|
/// Schedule the automatic update check from the last check time (unix
|
||||||
/// seconds). `None` when auto-check is disabled. Due when never checked or
|
/// seconds). `None` when auto-check is disabled. Checks fall on local
|
||||||
/// when `now - last >= interval`; otherwise after the remainder.
|
/// clock boundaries: hourly at the top of each hour, daily at midnight,
|
||||||
|
/// weekly at midnight on Monday. Due when never checked or when the last
|
||||||
|
/// check came before the boundary that most recently passed; otherwise
|
||||||
|
/// after the time left until the next boundary.
|
||||||
pub fn update_check_due(
|
pub fn update_check_due(
|
||||||
last_check_unix: Option<u64>,
|
last_check_unix: Option<u64>,
|
||||||
interval_secs: Option<u64>,
|
interval_secs: Option<u64>,
|
||||||
now_unix: u64,
|
now_unix: u64,
|
||||||
|
tz: &TimeZone,
|
||||||
) -> Option<UpdateCheckDue> {
|
) -> Option<UpdateCheckDue> {
|
||||||
let interval = interval_secs?;
|
let interval = interval_secs?;
|
||||||
let Some(last) = last_check_unix else {
|
let (start, next) = clock_slot(interval, now_unix, tz);
|
||||||
return Some(UpdateCheckDue::Now);
|
// A last check after `now` (a clock set back) counts as this slot's.
|
||||||
};
|
match last_check_unix {
|
||||||
let elapsed = now_unix.saturating_sub(last);
|
Some(last) if last >= start => Some(UpdateCheckDue::After(Duration::from_secs(
|
||||||
if elapsed >= interval {
|
next.saturating_sub(now_unix),
|
||||||
Some(UpdateCheckDue::Now)
|
))),
|
||||||
} else {
|
_ => Some(UpdateCheckDue::Now),
|
||||||
Some(UpdateCheckDue::After(Duration::from_secs(
|
|
||||||
interval - elapsed,
|
|
||||||
)))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The slot of a clock-aligned timer that holds `now_unix`: the boundary
|
||||||
|
/// that most recently passed and the next one, in unix seconds. An hour,
|
||||||
|
/// a day and a week follow the local calendar (top of the hour, midnight,
|
||||||
|
/// Monday midnight), across daylight-saving changes too. Any other interval
|
||||||
|
/// splits the local day evenly, so 5 minutes falls on :00, :05, :10 and
|
||||||
|
/// so on.
|
||||||
|
pub fn clock_slot(interval_secs: u64, now_unix: u64, tz: &TimeZone) -> (u64, u64) {
|
||||||
|
let calendar = || -> Result<(Zoned, Zoned), jiff::Error> {
|
||||||
|
let now = Timestamp::from_second(i64::try_from(now_unix).unwrap_or(i64::MAX))?
|
||||||
|
.to_zoned(tz.clone());
|
||||||
|
let (start, step) = match interval_secs {
|
||||||
|
settings::UPDATE_CHECK_HOURLY_SECS => (
|
||||||
|
now.with()
|
||||||
|
.minute(0)
|
||||||
|
.second(0)
|
||||||
|
.subsec_nanosecond(0)
|
||||||
|
.build()?,
|
||||||
|
1.hour(),
|
||||||
|
),
|
||||||
|
settings::UPDATE_CHECK_DAILY_SECS => (now.start_of_day()?, 1.day()),
|
||||||
|
settings::UPDATE_CHECK_WEEKLY_SECS => {
|
||||||
|
let days = now.weekday().to_monday_zero_offset();
|
||||||
|
let monday = now.start_of_day()?.checked_sub(i64::from(days).days())?;
|
||||||
|
(monday.start_of_day()?, 1.week())
|
||||||
|
}
|
||||||
|
_ => return Err(jiff::Error::from_args(format_args!("no calendar slot"))),
|
||||||
|
};
|
||||||
|
let next = start.checked_add(step)?;
|
||||||
|
Ok((start, next))
|
||||||
|
};
|
||||||
|
match calendar() {
|
||||||
|
Ok((start, next)) => (unix_secs(&start), unix_secs(&next)),
|
||||||
|
Err(_) => {
|
||||||
|
let interval = i64::try_from(interval_secs.max(1)).unwrap_or(i64::MAX);
|
||||||
|
let now = i64::try_from(now_unix).unwrap_or(i64::MAX);
|
||||||
|
let offset = Timestamp::from_second(now)
|
||||||
|
.map(|t| i64::from(tz.to_offset(t).seconds()))
|
||||||
|
.unwrap_or(0);
|
||||||
|
let local = now.saturating_add(offset);
|
||||||
|
let start = local - local.rem_euclid(interval) - offset;
|
||||||
|
let start = u64::try_from(start).unwrap_or(0);
|
||||||
|
(start, start.saturating_add(interval.unsigned_abs()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unix_secs(zoned: &Zoned) -> u64 {
|
||||||
|
u64::try_from(zoned.timestamp().as_second()).unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -620,28 +675,61 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn update_schedule_due_and_remaining() {
|
fn update_schedule_due_and_remaining() {
|
||||||
let hour = 3_600;
|
let utc = TimeZone::UTC;
|
||||||
assert_eq!(update_check_due(None, None, 10_000), None);
|
let hour = settings::UPDATE_CHECK_HOURLY_SECS;
|
||||||
assert_eq!(update_check_due(Some(9_000), None, 10_000), None);
|
// 10_000 s is 02:46:40 UTC; the hour started at 7_200.
|
||||||
|
assert_eq!(update_check_due(None, None, 10_000, &utc), None);
|
||||||
|
assert_eq!(update_check_due(Some(9_000), None, 10_000, &utc), None);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
update_check_due(None, Some(hour), 10_000),
|
update_check_due(None, Some(hour), 10_000, &utc),
|
||||||
Some(UpdateCheckDue::Now)
|
Some(UpdateCheckDue::Now)
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
update_check_due(Some(10_000 - hour), Some(hour), 10_000),
|
update_check_due(Some(7_199), Some(hour), 10_000, &utc),
|
||||||
Some(UpdateCheckDue::Now)
|
Some(UpdateCheckDue::Now)
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
update_check_due(Some(10_000 - 600), Some(hour), 10_000),
|
update_check_due(Some(7_200), Some(hour), 10_000, &utc),
|
||||||
Some(UpdateCheckDue::After(Duration::from_secs(3_000)))
|
Some(UpdateCheckDue::After(Duration::from_secs(800)))
|
||||||
);
|
);
|
||||||
// A clock that moved backwards counts as zero elapsed.
|
// A clock that moved backwards counts as checked this slot.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
update_check_due(Some(20_000), Some(hour), 10_000),
|
update_check_due(Some(20_000), Some(hour), 10_000, &utc),
|
||||||
Some(UpdateCheckDue::After(Duration::from_secs(hour)))
|
Some(UpdateCheckDue::After(Duration::from_secs(800)))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clock_slots_follow_the_local_calendar() {
|
||||||
|
let utc = TimeZone::UTC;
|
||||||
|
// 5 minutes: 02:46:40 sits in 02:45..02:50.
|
||||||
|
assert_eq!(clock_slot(300, 10_000, &utc), (9_900, 10_200));
|
||||||
|
// At +05:30 local hours start at UTC :30.
|
||||||
|
let india = TimeZone::fixed(jiff::tz::Offset::from_seconds(5 * 3_600 + 30 * 60).unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
clock_slot(settings::UPDATE_CHECK_HOURLY_SECS, 10_000, &india),
|
||||||
|
(9_000, 12_600)
|
||||||
|
);
|
||||||
|
// 1970-01-01 was a Thursday: its week started Monday 1969-12-29,
|
||||||
|
// before the epoch, so the slot start clamps to 0.
|
||||||
|
assert_eq!(
|
||||||
|
clock_slot(settings::UPDATE_CHECK_WEEKLY_SECS, 10_000, &utc),
|
||||||
|
(0, 4 * 86_400)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_daily_slot_spans_a_short_daylight_saving_day() {
|
||||||
|
let Ok(berlin) = TimeZone::get("Europe/Berlin") else {
|
||||||
|
return; // no tz database on this machine
|
||||||
|
};
|
||||||
|
// 2026-03-29 is 23 hours long in Berlin; it starts at 23:00 UTC.
|
||||||
|
let noon = 1_774_785_600; // 2026-03-29 12:00 UTC
|
||||||
|
let (start, next) = clock_slot(settings::UPDATE_CHECK_DAILY_SECS, noon, &berlin);
|
||||||
|
assert_eq!(start, 1_774_738_800);
|
||||||
|
assert_eq!(next - start, 23 * 3_600);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn countdown_delay_takes_the_nearest_change_with_a_floor() {
|
fn countdown_delay_takes_the_nearest_change_with_a_floor() {
|
||||||
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000);
|
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000);
|
||||||
|
|||||||
@@ -1034,6 +1034,7 @@ fn setup(app: &mut tauri::App, cli: &CliArgs) -> Result<(), Box<dyn Error>> {
|
|||||||
poll_interval: Duration::from_millis(u64::from(state.settings.poll_interval_ms)),
|
poll_interval: Duration::from_millis(u64::from(state.settings.poll_interval_ms)),
|
||||||
update_interval_secs: state.settings.update_check_interval_secs,
|
update_interval_secs: state.settings.update_check_interval_secs,
|
||||||
last_update_check_unix: state.settings.last_update_check_unix,
|
last_update_check_unix: state.settings.last_update_check_unix,
|
||||||
|
tz: jiff::tz::TimeZone::system(),
|
||||||
};
|
};
|
||||||
let providers = enabled_providers(&state.settings);
|
let providers = enabled_providers(&state.settings);
|
||||||
|
|
||||||
|
|||||||
+224
-178
@@ -7,17 +7,20 @@
|
|||||||
//! each due [`Action`] to its [`SchedulerHost`]. A host must return quickly:
|
//! each due [`Action`] to its [`SchedulerHost`]. A host must return quickly:
|
||||||
//! slow work (HTTP, the CLI refresh) runs on worker threads it spawns.
|
//! slow work (HTTP, the CLI refresh) runs on worker threads it spawns.
|
||||||
//!
|
//!
|
||||||
//! `Instant` stops while the machine sleeps, so the update check, which is
|
//! The poll and the update check run on local clock boundaries (see
|
||||||
//! due at a wall-clock time, is kept as a unix timestamp, and the thread
|
//! [`clock_slot`]): a 5-minute poll at :00, :05, :10 and so on, a daily
|
||||||
//! wakes at least every [`MAX_WAIT`] to notice a resume (see
|
//! update check at midnight. `Instant` stops while the machine sleeps, so
|
||||||
//! [`resumed_from_sleep`]).
|
//! those deadlines are kept as unix timestamps, and the thread wakes at
|
||||||
|
//! least every [`MAX_WAIT`] to notice a resume (see [`resumed_from_sleep`]).
|
||||||
|
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::sync::mpsc::{Receiver, RecvTimeoutError};
|
use std::sync::mpsc::{Receiver, RecvTimeoutError};
|
||||||
use std::thread::JoinHandle;
|
use std::thread::JoinHandle;
|
||||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use crate::core::state::{update_check_due, UpdateCheckDue};
|
use jiff::tz::TimeZone;
|
||||||
|
|
||||||
|
use crate::core::state::{clock_slot, update_check_due, UpdateCheckDue};
|
||||||
|
|
||||||
/// Shortest poll interval, guarding against a hand-edited `0` in the
|
/// Shortest poll interval, guarding against a hand-edited `0` in the
|
||||||
/// settings file turning into a busy loop.
|
/// settings file turning into a busy loop.
|
||||||
@@ -42,7 +45,8 @@ const RESUME_THRESHOLD: Duration = Duration::from_secs(30);
|
|||||||
pub enum Message {
|
pub enum Message {
|
||||||
/// Poll at once. The periodic poll deadline is left as it is.
|
/// Poll at once. The periodic poll deadline is left as it is.
|
||||||
PollNow,
|
PollNow,
|
||||||
/// Change the poll interval and re-arm it from now, without polling.
|
/// Change the poll interval and re-arm it at its next boundary, without
|
||||||
|
/// polling.
|
||||||
SetPollInterval(Duration),
|
SetPollInterval(Duration),
|
||||||
/// Re-render the countdowns after this delay; `None` clears the deadline.
|
/// Re-render the countdowns after this delay; `None` clears the deadline.
|
||||||
RecomputeCountdown(Option<Duration>),
|
RecomputeCountdown(Option<Duration>),
|
||||||
@@ -53,9 +57,9 @@ pub enum Message {
|
|||||||
interval_secs: Option<u64>,
|
interval_secs: Option<u64>,
|
||||||
last_check_unix: Option<u64>,
|
last_check_unix: Option<u64>,
|
||||||
},
|
},
|
||||||
/// An update check finished. Only this re-arms the check: after the
|
/// An update check finished. Only this re-arms the check: at the next
|
||||||
/// full interval when it succeeded, after a growing retry delay capped
|
/// boundary when it succeeded, after a growing retry delay capped at
|
||||||
/// at the interval when it failed.
|
/// that boundary when it failed.
|
||||||
UpdateCheckDone {
|
UpdateCheckDone {
|
||||||
interval_secs: Option<u64>,
|
interval_secs: Option<u64>,
|
||||||
succeeded: bool,
|
succeeded: bool,
|
||||||
@@ -77,17 +81,21 @@ pub trait SchedulerHost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Settings the deadlines start from.
|
/// Settings the deadlines start from.
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub poll_interval: Duration,
|
pub poll_interval: Duration,
|
||||||
pub update_interval_secs: Option<u64>,
|
pub update_interval_secs: Option<u64>,
|
||||||
pub last_update_check_unix: Option<u64>,
|
pub last_update_check_unix: Option<u64>,
|
||||||
|
/// The zone whose clock the poll and the update check follow.
|
||||||
|
pub tz: TimeZone,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Deadlines {
|
pub struct Deadlines {
|
||||||
|
tz: TimeZone,
|
||||||
poll_interval: Duration,
|
poll_interval: Duration,
|
||||||
poll_at: Instant,
|
/// Unix seconds the next poll is due at.
|
||||||
|
poll_at_unix: u64,
|
||||||
countdown_at: Option<Instant>,
|
countdown_at: Option<Instant>,
|
||||||
/// Unix seconds the next update check is due at.
|
/// Unix seconds the next update check is due at.
|
||||||
update_at_unix: Option<u64>,
|
update_at_unix: Option<u64>,
|
||||||
@@ -98,15 +106,16 @@ pub struct Deadlines {
|
|||||||
impl Deadlines {
|
impl Deadlines {
|
||||||
/// Arm the deadlines at startup. Returns the actions due at once: the
|
/// Arm the deadlines at startup. Returns the actions due at once: the
|
||||||
/// update check when the last one is older than its interval.
|
/// update check when the last one is older than its interval.
|
||||||
pub fn new(config: Config, now: Instant, now_unix: u64) -> (Self, Vec<Action>) {
|
pub fn new(config: Config, now_unix: u64) -> (Self, Vec<Action>) {
|
||||||
let poll_interval = config.poll_interval.max(MIN_POLL_INTERVAL);
|
|
||||||
let mut deadlines = Self {
|
let mut deadlines = Self {
|
||||||
poll_interval,
|
tz: config.tz,
|
||||||
poll_at: now + poll_interval,
|
poll_interval: config.poll_interval.max(MIN_POLL_INTERVAL),
|
||||||
|
poll_at_unix: 0,
|
||||||
countdown_at: None,
|
countdown_at: None,
|
||||||
update_at_unix: None,
|
update_at_unix: None,
|
||||||
update_failures: 0,
|
update_failures: 0,
|
||||||
};
|
};
|
||||||
|
deadlines.poll_at_unix = deadlines.next_poll(now_unix);
|
||||||
let actions = deadlines.arm_update(
|
let actions = deadlines.arm_update(
|
||||||
config.update_interval_secs,
|
config.update_interval_secs,
|
||||||
config.last_update_check_unix,
|
config.last_update_check_unix,
|
||||||
@@ -121,7 +130,7 @@ impl Deadlines {
|
|||||||
Message::PollNow => vec![Action::Poll],
|
Message::PollNow => vec![Action::Poll],
|
||||||
Message::SetPollInterval(interval) => {
|
Message::SetPollInterval(interval) => {
|
||||||
self.poll_interval = interval.max(MIN_POLL_INTERVAL);
|
self.poll_interval = interval.max(MIN_POLL_INTERVAL);
|
||||||
self.poll_at = now + self.poll_interval;
|
self.poll_at_unix = self.next_poll(now_unix);
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
Message::RecomputeCountdown(delay) => {
|
Message::RecomputeCountdown(delay) => {
|
||||||
@@ -136,29 +145,31 @@ impl Deadlines {
|
|||||||
interval_secs,
|
interval_secs,
|
||||||
succeeded,
|
succeeded,
|
||||||
} => {
|
} => {
|
||||||
let delay = if succeeded {
|
self.update_at_unix = interval_secs.map(|secs| {
|
||||||
self.update_failures = 0;
|
let next = self.next_update(secs, now_unix);
|
||||||
interval_secs.map(update_interval)
|
if succeeded {
|
||||||
} else {
|
self.update_failures = 0;
|
||||||
let delay = interval_secs.map(|secs| self.retry_delay(secs));
|
next
|
||||||
self.update_failures = self.update_failures.saturating_add(1);
|
} else {
|
||||||
delay
|
let retry = now_unix.saturating_add(self.retry_delay().as_secs());
|
||||||
};
|
self.update_failures = self.update_failures.saturating_add(1);
|
||||||
self.update_at_unix = delay.map(|d| now_unix.saturating_add(d.as_secs()));
|
retry.min(next)
|
||||||
|
}
|
||||||
|
});
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
Message::Shutdown => Vec::new(),
|
Message::Shutdown => Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fire every deadline that has passed. The poll re-arms for a full
|
/// Fire every deadline that has passed. The poll re-arms at its next
|
||||||
/// interval; the countdown and update check stay disarmed until the
|
/// boundary; the countdown and update check stay disarmed until the
|
||||||
/// host re-arms them (after the re-render, after the check finished).
|
/// host re-arms them (after the re-render, after the check finished).
|
||||||
pub fn take_due(&mut self, now: Instant, now_unix: u64) -> Vec<Action> {
|
pub fn take_due(&mut self, now: Instant, now_unix: u64) -> Vec<Action> {
|
||||||
let mut due = Vec::new();
|
let mut due = Vec::new();
|
||||||
if self.poll_at <= now {
|
if self.poll_at_unix <= now_unix {
|
||||||
due.push(Action::Poll);
|
due.push(Action::Poll);
|
||||||
self.poll_at = now + self.poll_interval;
|
self.poll_at_unix = self.next_poll(now_unix);
|
||||||
}
|
}
|
||||||
if self.countdown_at.is_some_and(|at| at <= now) {
|
if self.countdown_at.is_some_and(|at| at <= now) {
|
||||||
due.push(Action::Countdown);
|
due.push(Action::Countdown);
|
||||||
@@ -171,25 +182,23 @@ impl Deadlines {
|
|||||||
due
|
due
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The machine woke from sleep: poll at once and re-arm the poll for a
|
/// The machine woke from sleep: poll at once and re-arm the poll at its
|
||||||
/// full interval from now. The update check needs nothing here, since
|
/// next boundary. The update check needs nothing here, since
|
||||||
/// [`Self::take_due`] compares its wall-clock deadline with `now_unix`.
|
/// [`Self::take_due`] compares its wall-clock deadline with `now_unix`.
|
||||||
pub fn resume(&mut self, now: Instant) -> Vec<Action> {
|
pub fn resume(&mut self, now_unix: u64) -> Vec<Action> {
|
||||||
self.poll_at = now + self.poll_interval;
|
self.poll_at_unix = self.next_poll(now_unix);
|
||||||
vec![Action::Poll]
|
vec![Action::Poll]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Time until the nearest deadline. The poll deadline is always armed,
|
/// Time until the nearest deadline. The poll deadline is always armed,
|
||||||
/// so there always is one.
|
/// so there always is one.
|
||||||
pub fn next_wakeup(&self, now: Instant, now_unix: u64) -> Duration {
|
pub fn next_wakeup(&self, now: Instant, now_unix: u64) -> Duration {
|
||||||
let update = self
|
let until = |at: u64| Duration::from_secs(at.saturating_sub(now_unix));
|
||||||
.update_at_unix
|
|
||||||
.map(|at| Duration::from_secs(at.saturating_sub(now_unix)));
|
|
||||||
[
|
[
|
||||||
Some(self.poll_at.saturating_duration_since(now)),
|
Some(until(self.poll_at_unix)),
|
||||||
self.countdown_at
|
self.countdown_at
|
||||||
.map(|at| at.saturating_duration_since(now)),
|
.map(|at| at.saturating_duration_since(now)),
|
||||||
update,
|
self.update_at_unix.map(until),
|
||||||
]
|
]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.flatten()
|
.flatten()
|
||||||
@@ -204,7 +213,7 @@ impl Deadlines {
|
|||||||
now_unix: u64,
|
now_unix: u64,
|
||||||
) -> Vec<Action> {
|
) -> Vec<Action> {
|
||||||
let interval_secs = interval_secs.map(|secs| secs.max(MIN_UPDATE_INTERVAL.as_secs()));
|
let interval_secs = interval_secs.map(|secs| secs.max(MIN_UPDATE_INTERVAL.as_secs()));
|
||||||
match update_check_due(last_check_unix, interval_secs, now_unix) {
|
match update_check_due(last_check_unix, interval_secs, now_unix, &self.tz) {
|
||||||
None => {
|
None => {
|
||||||
self.update_at_unix = None;
|
self.update_at_unix = None;
|
||||||
Vec::new()
|
Vec::new()
|
||||||
@@ -220,18 +229,24 @@ impl Deadlines {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delay before the next try after the current run of failures:
|
/// The poll boundary after `now_unix`.
|
||||||
/// `UPDATE_RETRY_BASE * 2^failures`, capped at the check interval.
|
fn next_poll(&self, now_unix: u64) -> u64 {
|
||||||
fn retry_delay(&self, interval_secs: u64) -> Duration {
|
clock_slot(self.poll_interval.as_secs(), now_unix, &self.tz).1
|
||||||
let factor = 1u32.checked_shl(self.update_failures).unwrap_or(u32::MAX);
|
|
||||||
UPDATE_RETRY_BASE
|
|
||||||
.saturating_mul(factor)
|
|
||||||
.min(update_interval(interval_secs))
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fn update_interval(secs: u64) -> Duration {
|
/// The update-check boundary after `now_unix`.
|
||||||
Duration::from_secs(secs).max(MIN_UPDATE_INTERVAL)
|
fn next_update(&self, interval_secs: u64, now_unix: u64) -> u64 {
|
||||||
|
let interval_secs = interval_secs.max(MIN_UPDATE_INTERVAL.as_secs());
|
||||||
|
clock_slot(interval_secs, now_unix, &self.tz).1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delay before the next try after the current run of failures:
|
||||||
|
/// `UPDATE_RETRY_BASE * 2^failures`; the caller caps it at the next
|
||||||
|
/// boundary.
|
||||||
|
fn retry_delay(&self) -> Duration {
|
||||||
|
let factor = 1u32.checked_shl(self.update_failures).unwrap_or(u32::MAX);
|
||||||
|
UPDATE_RETRY_BASE.saturating_mul(factor)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the wall clock ran ahead of `Instant` by more than
|
/// Whether the wall clock ran ahead of `Instant` by more than
|
||||||
@@ -260,7 +275,7 @@ where
|
|||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("scheduler".into())
|
.name("scheduler".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
let (deadlines, initial) = Deadlines::new(config, Instant::now(), unix_now());
|
let (deadlines, initial) = Deadlines::new(config, unix_now());
|
||||||
for action in initial {
|
for action in initial {
|
||||||
host.run(action);
|
host.run(action);
|
||||||
}
|
}
|
||||||
@@ -282,7 +297,7 @@ fn run_loop<H: SchedulerHost>(mut deadlines: Deadlines, rx: &Receiver<Message>,
|
|||||||
"wall clock moved {wall_elapsed:?} while Instant moved {instant_elapsed:?}; \
|
"wall clock moved {wall_elapsed:?} while Instant moved {instant_elapsed:?}; \
|
||||||
treating it as a resume from sleep"
|
treating it as a resume from sleep"
|
||||||
);
|
);
|
||||||
for action in deadlines.resume(now) {
|
for action in deadlines.resume(unix_now()) {
|
||||||
host.run(action);
|
host.run(action);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -308,71 +323,97 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use std::sync::mpsc::{self, Sender};
|
use std::sync::mpsc::{self, Sender};
|
||||||
|
|
||||||
|
use jiff::tz;
|
||||||
|
|
||||||
use crate::core::settings::{
|
use crate::core::settings::{
|
||||||
UPDATE_CHECK_DAILY_SECS, UPDATE_CHECK_HOURLY_SECS, UPDATE_CHECK_WEEKLY_SECS,
|
UPDATE_CHECK_DAILY_SECS, UPDATE_CHECK_HOURLY_SECS, UPDATE_CHECK_WEEKLY_SECS,
|
||||||
};
|
};
|
||||||
|
|
||||||
const MIN: Duration = Duration::from_secs(60);
|
const MIN: Duration = Duration::from_secs(60);
|
||||||
|
const M: u64 = 60;
|
||||||
|
const HOUR: u64 = 60 * M;
|
||||||
|
const DAY: u64 = 24 * HOUR;
|
||||||
|
/// Friday 2027-01-15 08:00:00 UTC, a whole hour.
|
||||||
const NOW_UNIX: u64 = 1_800_000_000;
|
const NOW_UNIX: u64 = 1_800_000_000;
|
||||||
const DAY: u64 = 24 * 60 * 60;
|
/// The Monday 00:00 UTC that starts `NOW_UNIX`'s week.
|
||||||
|
const MONDAY: u64 = NOW_UNIX - 4 * DAY - 8 * HOUR;
|
||||||
|
|
||||||
fn config(update: Option<u64>, last: Option<u64>) -> Config {
|
fn config(update: Option<u64>, last: Option<u64>) -> Config {
|
||||||
Config {
|
Config {
|
||||||
poll_interval: 5 * MIN,
|
poll_interval: 5 * MIN,
|
||||||
update_interval_secs: update,
|
update_interval_secs: update,
|
||||||
last_update_check_unix: last,
|
last_update_check_unix: last,
|
||||||
|
tz: TimeZone::UTC,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn poll_fires_every_interval() {
|
fn poll_fires_on_interval_boundaries() {
|
||||||
let t0 = Instant::now();
|
let t0 = Instant::now();
|
||||||
let (mut d, initial) = Deadlines::new(config(None, None), t0, NOW_UNIX);
|
// Started at 08:02:10, a 5-minute poll is next due at 08:05:00.
|
||||||
|
let start = NOW_UNIX + 2 * M + 10;
|
||||||
|
let (mut d, initial) = Deadlines::new(config(None, None), start);
|
||||||
assert!(initial.is_empty());
|
assert!(initial.is_empty());
|
||||||
assert_eq!(d.next_wakeup(t0, NOW_UNIX), 5 * MIN);
|
assert_eq!(d.next_wakeup(t0, start), Duration::from_secs(2 * M + 50));
|
||||||
assert!(d.take_due(t0 + 4 * MIN, NOW_UNIX).is_empty());
|
assert!(d.take_due(t0, NOW_UNIX + 5 * M - 1).is_empty());
|
||||||
assert_eq!(d.take_due(t0 + 5 * MIN, NOW_UNIX), [Action::Poll]);
|
assert_eq!(d.take_due(t0, NOW_UNIX + 5 * M), [Action::Poll]);
|
||||||
assert_eq!(d.next_wakeup(t0 + 5 * MIN, NOW_UNIX), 5 * MIN);
|
assert_eq!(d.next_wakeup(t0, NOW_UNIX + 5 * M), 5 * MIN);
|
||||||
assert_eq!(d.take_due(t0 + 10 * MIN, NOW_UNIX), [Action::Poll]);
|
// A late wake-up keeps the boundaries: 08:10:20 re-arms 08:15:00.
|
||||||
|
assert_eq!(d.take_due(t0, NOW_UNIX + 10 * M + 20), [Action::Poll]);
|
||||||
|
assert!(d.take_due(t0, NOW_UNIX + 15 * M - 1).is_empty());
|
||||||
|
assert_eq!(d.take_due(t0, NOW_UNIX + 15 * M), [Action::Poll]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn poll_boundaries_follow_the_local_clock() {
|
||||||
|
// At +05:45, 5-minute boundaries fall on the local :00, :05 and so
|
||||||
|
// on, which is UTC :15, :20 and so on.
|
||||||
|
let mut cfg = config(None, None);
|
||||||
|
cfg.tz = TimeZone::fixed(tz::Offset::from_seconds(5 * 3_600 + 45 * 60).unwrap());
|
||||||
|
cfg.poll_interval = 60 * MIN;
|
||||||
|
let (d, _) = Deadlines::new(cfg, NOW_UNIX);
|
||||||
|
assert_eq!(
|
||||||
|
d.next_wakeup(Instant::now(), NOW_UNIX),
|
||||||
|
Duration::from_secs(15 * M)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn poll_now_polls_without_moving_the_periodic_deadline() {
|
fn poll_now_polls_without_moving_the_periodic_deadline() {
|
||||||
let t0 = Instant::now();
|
let t0 = Instant::now();
|
||||||
let (mut d, _) = Deadlines::new(config(None, None), t0, NOW_UNIX);
|
let (mut d, _) = Deadlines::new(config(None, None), NOW_UNIX);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
d.handle(Message::PollNow, t0 + 2 * MIN, NOW_UNIX),
|
d.handle(Message::PollNow, t0, NOW_UNIX + 2 * M),
|
||||||
[Action::Poll]
|
[Action::Poll]
|
||||||
);
|
);
|
||||||
assert_eq!(d.take_due(t0 + 5 * MIN, NOW_UNIX), [Action::Poll]);
|
assert_eq!(d.take_due(t0, NOW_UNIX + 5 * M), [Action::Poll]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn new_poll_interval_rearms_from_now_without_polling() {
|
fn new_poll_interval_rearms_at_its_next_boundary_without_polling() {
|
||||||
let t0 = Instant::now();
|
let t0 = Instant::now();
|
||||||
let (mut d, _) = Deadlines::new(config(None, None), t0, NOW_UNIX);
|
let (mut d, _) = Deadlines::new(config(None, None), NOW_UNIX);
|
||||||
let t1 = t0 + 4 * MIN;
|
let t1 = NOW_UNIX + 4 * M;
|
||||||
assert!(d
|
assert!(d
|
||||||
.handle(Message::SetPollInterval(MIN * 15), t1, NOW_UNIX)
|
.handle(Message::SetPollInterval(MIN * 15), t0, t1)
|
||||||
.is_empty());
|
.is_empty());
|
||||||
assert!(d.take_due(t0 + 5 * MIN, NOW_UNIX).is_empty());
|
assert!(d.take_due(t0, NOW_UNIX + 5 * M).is_empty());
|
||||||
assert_eq!(d.next_wakeup(t1, NOW_UNIX), 15 * MIN);
|
assert_eq!(d.next_wakeup(t0, t1), 11 * MIN);
|
||||||
assert_eq!(d.take_due(t1 + 15 * MIN, NOW_UNIX), [Action::Poll]);
|
assert_eq!(d.take_due(t0, NOW_UNIX + 15 * M), [Action::Poll]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_zero_poll_interval_is_floored() {
|
fn a_zero_poll_interval_is_floored() {
|
||||||
let t0 = Instant::now();
|
|
||||||
let mut cfg = config(None, None);
|
let mut cfg = config(None, None);
|
||||||
cfg.poll_interval = Duration::ZERO;
|
cfg.poll_interval = Duration::ZERO;
|
||||||
let (d, _) = Deadlines::new(cfg, t0, NOW_UNIX);
|
let (d, _) = Deadlines::new(cfg, NOW_UNIX);
|
||||||
assert_eq!(d.next_wakeup(t0, NOW_UNIX), MIN_POLL_INTERVAL);
|
assert_eq!(d.next_wakeup(Instant::now(), NOW_UNIX), MIN_POLL_INTERVAL);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn countdown_fires_once_until_rearmed() {
|
fn countdown_fires_once_until_rearmed() {
|
||||||
let t0 = Instant::now();
|
let t0 = Instant::now();
|
||||||
let (mut d, _) = Deadlines::new(config(None, None), t0, NOW_UNIX);
|
let (mut d, _) = Deadlines::new(config(None, None), NOW_UNIX);
|
||||||
let delay = Duration::from_secs(31);
|
let delay = Duration::from_secs(31);
|
||||||
d.handle(Message::RecomputeCountdown(Some(delay)), t0, NOW_UNIX);
|
d.handle(Message::RecomputeCountdown(Some(delay)), t0, NOW_UNIX);
|
||||||
assert_eq!(d.next_wakeup(t0, NOW_UNIX), delay);
|
assert_eq!(d.next_wakeup(t0, NOW_UNIX), delay);
|
||||||
@@ -384,42 +425,10 @@ mod tests {
|
|||||||
assert!(d.take_due(t0 + 2 * delay, NOW_UNIX).is_empty());
|
assert!(d.take_due(t0 + 2 * delay, NOW_UNIX).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
/// Whether a check is due at `now_unix`.
|
||||||
fn startup_checks_for_updates_when_due() {
|
fn check_due(d: &mut Deadlines, now_unix: u64) -> bool {
|
||||||
let t0 = Instant::now();
|
d.take_due(Instant::now(), now_unix)
|
||||||
let hourly = Some(UPDATE_CHECK_HOURLY_SECS);
|
.contains(&Action::CheckForUpdates)
|
||||||
let (_, never_checked) = Deadlines::new(config(hourly, None), t0, NOW_UNIX);
|
|
||||||
assert_eq!(never_checked, [Action::CheckForUpdates]);
|
|
||||||
let (_, stale) = Deadlines::new(config(hourly, Some(NOW_UNIX - 2 * 3_600)), t0, NOW_UNIX);
|
|
||||||
assert_eq!(stale, [Action::CheckForUpdates]);
|
|
||||||
let (_, disabled) = Deadlines::new(config(None, None), t0, NOW_UNIX);
|
|
||||||
assert!(disabled.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn startup_waits_for_the_remainder_when_not_due() {
|
|
||||||
let t0 = Instant::now();
|
|
||||||
let (mut d, initial) = Deadlines::new(
|
|
||||||
config(Some(UPDATE_CHECK_HOURLY_SECS), Some(NOW_UNIX - 600)),
|
|
||||||
t0,
|
|
||||||
NOW_UNIX,
|
|
||||||
);
|
|
||||||
assert!(initial.is_empty());
|
|
||||||
let remaining = 3_000;
|
|
||||||
assert!(!d
|
|
||||||
.take_due(t0, NOW_UNIX + remaining - 60)
|
|
||||||
.contains(&Action::CheckForUpdates));
|
|
||||||
assert_eq!(d.next_wakeup(t0, NOW_UNIX + remaining - 60), MIN);
|
|
||||||
assert_eq!(
|
|
||||||
d.take_due(t0, NOW_UNIX + remaining),
|
|
||||||
[Action::CheckForUpdates]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether a check is due at `now_unix`, with `Instant` left at `t0`
|
|
||||||
/// to show the update deadline follows the wall clock alone.
|
|
||||||
fn check_due(d: &mut Deadlines, t0: Instant, now_unix: u64) -> bool {
|
|
||||||
d.take_due(t0, now_unix).contains(&Action::CheckForUpdates)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn done(interval_secs: Option<u64>, succeeded: bool) -> Message {
|
fn done(interval_secs: Option<u64>, succeeded: bool) -> Message {
|
||||||
@@ -430,76 +439,118 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn only_a_finished_check_rearms_the_full_interval() {
|
fn startup_checks_only_when_a_boundary_passed_since_the_last_check() {
|
||||||
let t0 = Instant::now();
|
let hourly = Some(UPDATE_CHECK_HOURLY_SECS);
|
||||||
let hour = UPDATE_CHECK_HOURLY_SECS;
|
let (_, never_checked) = Deadlines::new(config(hourly, None), NOW_UNIX + M);
|
||||||
let (mut d, initial) = Deadlines::new(config(Some(hour), None), t0, NOW_UNIX);
|
assert_eq!(never_checked, [Action::CheckForUpdates]);
|
||||||
assert_eq!(initial, [Action::CheckForUpdates]);
|
// Checked at 07:59:59, started at 08:01: 08:00 passed in between.
|
||||||
// While the check runs nothing else is scheduled for it.
|
let (_, passed) = Deadlines::new(config(hourly, Some(NOW_UNIX - 1)), NOW_UNIX + M);
|
||||||
assert!(!check_due(&mut d, t0, NOW_UNIX + 3 * hour));
|
assert_eq!(passed, [Action::CheckForUpdates]);
|
||||||
|
// Checked at 08:00:30, started at 08:59: no boundary yet, so it
|
||||||
let t1 = NOW_UNIX + 3 * hour;
|
// waits for 09:00.
|
||||||
d.handle(done(Some(hour), true), t0, t1);
|
let (mut d, waiting) =
|
||||||
assert!(!check_due(&mut d, t0, t1 + hour / 2));
|
Deadlines::new(config(hourly, Some(NOW_UNIX + 30)), NOW_UNIX + 59 * M);
|
||||||
assert!(check_due(&mut d, t0, t1 + hour));
|
assert!(waiting.is_empty());
|
||||||
// It repeats every interval, not just once.
|
assert!(!check_due(&mut d, NOW_UNIX + HOUR - 1));
|
||||||
let t2 = t1 + hour;
|
assert!(check_due(&mut d, NOW_UNIX + HOUR));
|
||||||
d.handle(done(Some(hour), true), t0, t2);
|
let (_, disabled) = Deadlines::new(config(None, None), NOW_UNIX);
|
||||||
assert!(check_due(&mut d, t0, t2 + hour));
|
assert!(disabled.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_failed_check_retries_with_a_doubling_delay_capped_at_the_interval() {
|
fn daily_and_weekly_checks_fall_on_local_midnight() {
|
||||||
let t0 = Instant::now();
|
let daily = Some(UPDATE_CHECK_DAILY_SECS);
|
||||||
|
let midnight = NOW_UNIX - 8 * HOUR;
|
||||||
|
let (_, before) = Deadlines::new(config(daily, Some(midnight - 1)), NOW_UNIX);
|
||||||
|
assert_eq!(before, [Action::CheckForUpdates]);
|
||||||
|
let (mut d, after) = Deadlines::new(config(daily, Some(midnight)), NOW_UNIX);
|
||||||
|
assert!(after.is_empty());
|
||||||
|
assert!(!check_due(&mut d, midnight + DAY - 1));
|
||||||
|
assert!(check_due(&mut d, midnight + DAY));
|
||||||
|
|
||||||
|
let weekly = Some(UPDATE_CHECK_WEEKLY_SECS);
|
||||||
|
let (mut d, initial) = Deadlines::new(config(weekly, Some(MONDAY + 1)), NOW_UNIX);
|
||||||
|
assert!(initial.is_empty());
|
||||||
|
assert!(!check_due(&mut d, MONDAY + 7 * DAY - 1));
|
||||||
|
assert!(check_due(&mut d, MONDAY + 7 * DAY));
|
||||||
|
|
||||||
|
// At UTC+07:00 local midnight is 17:00 UTC the day before.
|
||||||
|
let mut cfg = config(daily, Some(NOW_UNIX));
|
||||||
|
cfg.tz = TimeZone::fixed(tz::offset(7));
|
||||||
|
let (mut d, _) = Deadlines::new(cfg, NOW_UNIX);
|
||||||
|
assert!(!check_due(&mut d, NOW_UNIX + 9 * HOUR - 1));
|
||||||
|
assert!(check_due(&mut d, NOW_UNIX + 9 * HOUR));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_finished_check_rearms_at_the_next_boundary() {
|
||||||
let hour = UPDATE_CHECK_HOURLY_SECS;
|
let hour = UPDATE_CHECK_HOURLY_SECS;
|
||||||
let (mut d, _) = Deadlines::new(config(Some(hour), None), t0, NOW_UNIX);
|
let (mut d, initial) = Deadlines::new(config(Some(hour), None), NOW_UNIX);
|
||||||
|
assert_eq!(initial, [Action::CheckForUpdates]);
|
||||||
|
// While the check runs nothing else is scheduled for it.
|
||||||
|
assert!(!check_due(&mut d, NOW_UNIX + 3 * hour));
|
||||||
|
|
||||||
|
let t1 = NOW_UNIX + 3 * hour + 20 * M;
|
||||||
|
d.handle(done(Some(hour), true), Instant::now(), t1);
|
||||||
|
assert!(!check_due(&mut d, NOW_UNIX + 4 * hour - 1));
|
||||||
|
assert!(check_due(&mut d, NOW_UNIX + 4 * hour));
|
||||||
|
// It repeats every boundary, not just once.
|
||||||
|
d.handle(done(Some(hour), true), Instant::now(), NOW_UNIX + 4 * hour);
|
||||||
|
assert!(check_due(&mut d, NOW_UNIX + 5 * hour));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_failed_check_retries_with_a_doubling_delay_capped_at_the_next_boundary() {
|
||||||
|
let day = UPDATE_CHECK_DAILY_SECS;
|
||||||
|
let (mut d, _) = Deadlines::new(config(Some(day), None), NOW_UNIX);
|
||||||
let mut now = NOW_UNIX;
|
let mut now = NOW_UNIX;
|
||||||
for expected in [60, 120, 240, 480, 960, 1_920, hour, hour] {
|
for expected in [60, 120, 240, 480, 960, 1_920, 3_840] {
|
||||||
d.handle(done(Some(hour), false), t0, now);
|
d.handle(done(Some(day), false), Instant::now(), now);
|
||||||
assert!(!check_due(&mut d, t0, now + expected - 1));
|
assert!(!check_due(&mut d, now + expected - 1));
|
||||||
assert!(check_due(&mut d, t0, now + expected));
|
assert!(check_due(&mut d, now + expected));
|
||||||
now += expected;
|
now += expected;
|
||||||
}
|
}
|
||||||
|
// The next delay would pass midnight, so it retries at midnight.
|
||||||
|
let midnight = NOW_UNIX - 8 * HOUR + DAY;
|
||||||
|
now = midnight - 10 * M;
|
||||||
|
d.handle(done(Some(day), false), Instant::now(), now);
|
||||||
|
assert!(check_due(&mut d, midnight));
|
||||||
// A success resets the delay: the next failure retries after a minute.
|
// A success resets the delay: the next failure retries after a minute.
|
||||||
d.handle(done(Some(hour), true), t0, now);
|
d.handle(done(Some(day), true), Instant::now(), midnight);
|
||||||
assert!(check_due(&mut d, t0, now + hour));
|
d.handle(done(Some(day), false), Instant::now(), midnight + HOUR);
|
||||||
now += hour;
|
assert!(check_due(&mut d, midnight + HOUR + 60));
|
||||||
d.handle(done(Some(hour), false), t0, now);
|
|
||||||
assert!(check_due(&mut d, t0, now + 60));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_failed_check_with_auto_check_off_stays_off() {
|
fn a_failed_check_with_auto_check_off_stays_off() {
|
||||||
let t0 = Instant::now();
|
let (mut d, _) = Deadlines::new(config(None, None), NOW_UNIX);
|
||||||
let (mut d, _) = Deadlines::new(config(None, None), t0, NOW_UNIX);
|
d.handle(done(None, false), Instant::now(), NOW_UNIX);
|
||||||
d.handle(done(None, false), t0, NOW_UNIX);
|
assert!(!check_due(&mut d, NOW_UNIX + 7 * DAY));
|
||||||
assert!(!check_due(&mut d, t0, NOW_UNIX + 7 * DAY));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_update_deadline_follows_the_wall_clock_across_a_sleep() {
|
fn the_wall_clock_deadlines_survive_a_sleep() {
|
||||||
// `Instant` does not advance while the machine sleeps, but the
|
// `Instant` does not advance while the machine sleeps, but the
|
||||||
// check is still due once the wall clock passes its time.
|
// deadlines are due once the wall clock passes them.
|
||||||
let t0 = Instant::now();
|
let t0 = Instant::now();
|
||||||
let day = UPDATE_CHECK_DAILY_SECS;
|
let day = UPDATE_CHECK_DAILY_SECS;
|
||||||
let (mut d, _) = Deadlines::new(config(Some(day), Some(NOW_UNIX)), t0, NOW_UNIX);
|
let (mut d, _) = Deadlines::new(config(Some(day), Some(NOW_UNIX)), NOW_UNIX);
|
||||||
assert!(!check_due(&mut d, t0, NOW_UNIX + day - 1));
|
let midnight = NOW_UNIX - 8 * HOUR + DAY;
|
||||||
assert!(check_due(
|
assert!(!d
|
||||||
&mut d,
|
.take_due(t0, midnight - 1)
|
||||||
t0 + Duration::from_secs(1),
|
.contains(&Action::CheckForUpdates));
|
||||||
NOW_UNIX + day
|
assert!(d.take_due(t0, midnight).contains(&Action::CheckForUpdates));
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resume_polls_at_once_and_rearms_the_poll() {
|
fn resume_polls_at_once_and_rearms_the_poll_at_its_boundary() {
|
||||||
let t0 = Instant::now();
|
let t0 = Instant::now();
|
||||||
let (mut d, _) = Deadlines::new(config(None, None), t0, NOW_UNIX);
|
let (mut d, _) = Deadlines::new(config(None, None), NOW_UNIX);
|
||||||
let t1 = t0 + 2 * MIN;
|
let woke = NOW_UNIX + 7 * HOUR + 2 * M;
|
||||||
assert_eq!(d.resume(t1), [Action::Poll]);
|
assert_eq!(d.resume(woke), [Action::Poll]);
|
||||||
assert!(d.take_due(t0 + 5 * MIN, NOW_UNIX).is_empty());
|
assert!(d.take_due(t0, woke).is_empty());
|
||||||
assert_eq!(d.next_wakeup(t1, NOW_UNIX), 5 * MIN);
|
assert_eq!(d.next_wakeup(t0, woke), 3 * MIN);
|
||||||
assert_eq!(d.take_due(t1 + 5 * MIN, NOW_UNIX), [Action::Poll]);
|
assert_eq!(d.take_due(t0, woke + 3 * M), [Action::Poll]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -520,11 +571,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn weekly_to_daily_with_the_last_check_two_days_ago_checks_at_once() {
|
fn weekly_to_daily_checks_at_once_when_midnight_passed_since_the_last_check() {
|
||||||
let t0 = Instant::now();
|
|
||||||
let last = Some(NOW_UNIX - 2 * DAY);
|
let last = Some(NOW_UNIX - 2 * DAY);
|
||||||
let (mut d, initial) =
|
let (mut d, initial) =
|
||||||
Deadlines::new(config(Some(UPDATE_CHECK_WEEKLY_SECS), last), t0, NOW_UNIX);
|
Deadlines::new(config(Some(UPDATE_CHECK_WEEKLY_SECS), last), NOW_UNIX);
|
||||||
assert!(initial.is_empty());
|
assert!(initial.is_empty());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
d.handle(
|
d.handle(
|
||||||
@@ -532,7 +582,7 @@ mod tests {
|
|||||||
interval_secs: Some(UPDATE_CHECK_DAILY_SECS),
|
interval_secs: Some(UPDATE_CHECK_DAILY_SECS),
|
||||||
last_check_unix: last,
|
last_check_unix: last,
|
||||||
},
|
},
|
||||||
t0,
|
Instant::now(),
|
||||||
NOW_UNIX,
|
NOW_UNIX,
|
||||||
),
|
),
|
||||||
[Action::CheckForUpdates]
|
[Action::CheckForUpdates]
|
||||||
@@ -540,44 +590,40 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn daily_to_weekly_waits_for_the_new_remainder() {
|
fn daily_to_weekly_waits_for_monday() {
|
||||||
let t0 = Instant::now();
|
|
||||||
let last = Some(NOW_UNIX - 2 * DAY);
|
let last = Some(NOW_UNIX - 2 * DAY);
|
||||||
let (mut d, _) = Deadlines::new(config(Some(UPDATE_CHECK_HOURLY_SECS), last), t0, NOW_UNIX);
|
let (mut d, _) = Deadlines::new(config(Some(UPDATE_CHECK_HOURLY_SECS), last), NOW_UNIX);
|
||||||
let actions = d.handle(
|
let actions = d.handle(
|
||||||
Message::SetUpdateInterval {
|
Message::SetUpdateInterval {
|
||||||
interval_secs: Some(UPDATE_CHECK_WEEKLY_SECS),
|
interval_secs: Some(UPDATE_CHECK_WEEKLY_SECS),
|
||||||
last_check_unix: last,
|
last_check_unix: last,
|
||||||
},
|
},
|
||||||
t0,
|
Instant::now(),
|
||||||
NOW_UNIX,
|
NOW_UNIX,
|
||||||
);
|
);
|
||||||
assert!(actions.is_empty());
|
assert!(actions.is_empty());
|
||||||
let remaining = 5 * DAY;
|
assert!(!check_due(&mut d, MONDAY + 7 * DAY - 1));
|
||||||
assert!(!check_due(&mut d, t0, NOW_UNIX + remaining - 60));
|
assert!(check_due(&mut d, MONDAY + 7 * DAY));
|
||||||
assert!(check_due(&mut d, t0, NOW_UNIX + remaining));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn disabling_auto_check_cancels_the_deadline() {
|
fn disabling_auto_check_cancels_the_deadline() {
|
||||||
let t0 = Instant::now();
|
|
||||||
let (mut d, _) = Deadlines::new(
|
let (mut d, _) = Deadlines::new(
|
||||||
config(Some(UPDATE_CHECK_HOURLY_SECS), Some(NOW_UNIX - 600)),
|
config(Some(UPDATE_CHECK_HOURLY_SECS), Some(NOW_UNIX + 60)),
|
||||||
t0,
|
NOW_UNIX + 600,
|
||||||
NOW_UNIX,
|
|
||||||
);
|
);
|
||||||
d.handle(
|
d.handle(
|
||||||
Message::SetUpdateInterval {
|
Message::SetUpdateInterval {
|
||||||
interval_secs: None,
|
interval_secs: None,
|
||||||
last_check_unix: Some(NOW_UNIX - 600),
|
last_check_unix: Some(NOW_UNIX + 60),
|
||||||
},
|
},
|
||||||
t0,
|
Instant::now(),
|
||||||
NOW_UNIX,
|
NOW_UNIX + 600,
|
||||||
);
|
);
|
||||||
assert!(!check_due(&mut d, t0, NOW_UNIX + 7 * DAY));
|
assert!(!check_due(&mut d, NOW_UNIX + 7 * DAY));
|
||||||
// A check that finishes after auto-check was turned off stays off.
|
// A check that finishes after auto-check was turned off stays off.
|
||||||
d.handle(done(None, true), t0, NOW_UNIX);
|
d.handle(done(None, true), Instant::now(), NOW_UNIX);
|
||||||
assert!(!check_due(&mut d, t0, NOW_UNIX + 14 * DAY));
|
assert!(!check_due(&mut d, NOW_UNIX + 14 * DAY));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Records actions on a channel, standing in for the runtime.
|
/// Records actions on a channel, standing in for the runtime.
|
||||||
|
|||||||
Reference in new issue
Block a user