Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,27 @@
(`Error::InvalidAssetKeyPattern`); a captured version that does not parse as semver skips the
key. Unset keeps the existing matcher unchanged.
([#61](https://github.com/jaemk/self_update/issues/61))
- `self_update::restart` module: `restart()` and `restart_with(args)` relaunch the (already
replaced) executable after an update so a long-running process picks up the new binary
immediately. `restart()` reuses the current arguments; `restart_with(args)` supplies a fresh
argument list (e.g. to drop an `--upgrade` flag so the restarted process does not update again).
On unix the process image is replaced with `exec`; on windows the new binary is spawned and the
current process exits. No feature gate, no new dependencies.
([#62](https://github.com/jaemk/self_update/issues/62))
- `self_update::check_interval::UpdateCheckGuard`: a small stamp-file guard that throttles how often
an application checks for updates. `should_check()` reports whether the configured interval has
elapsed since the last recorded check (a missing, corrupt, or future-dated stamp counts as due);
`record_check()` stamps the current time via a write-to-temp-then-rename so a concurrent reader
never sees a partial stamp. The caller owns the stamp-file path; it is a guard, not a scheduler,
and pulls in no time/date dependency. ([#79](https://github.com/jaemk/self_update/issues/79))
- Docs: a "GitHub rate limits" section in the crate docs covering GitHub's 60/hour (unauthenticated)
vs 5000/hour (authenticated) API limits, that a rate-limited response surfaces as
`Error::Unauthorized { status: 403, .. }`, and how to mitigate (a token, and checking less often
via `UpdateCheckGuard`). ([#78](https://github.com/jaemk/self_update/issues/78))
- Docs: the Features section now names the exact `no HTTP client selected` compile error a
client-less build (e.g. `default-features = false, features = ["rustls"]`) produces, and shows the
fix (add a client, e.g. `features = ["ureq", "rustls", "github"]`).
([#168](https://github.com/jaemk/self_update/issues/168))

### Changed
- A recognized-but-unsupported compression extension now fails loudly instead of silently
Expand Down
46 changes: 44 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,12 @@ fn update() -> Result<(), Box<dyn std::error::Error>> {

### Features

At least one HTTP client must be selected; having zero clients is a compile error. Multiple
clients and multiple TLS backends may coexist (reqwest is preferred when both are present):
At least one HTTP client must be selected. A build with **no** client -- for example
`default-features = false` with only a TLS feature such as `features = ["rustls"]` -- fails to
compile with `no HTTP client selected - enable at least one of the reqwest (default) or ureq
features`. Add a client explicitly, e.g. `default-features = false, features = ["ureq", "rustls",
"github"]`. Multiple clients and multiple TLS backends may coexist (reqwest is preferred when both
are present):

* `reqwest` (default): use the [`reqwest`](https://docs.rs/reqwest) HTTP client;
* `ureq`: use the [`ureq`](https://docs.rs/ureq) HTTP client, either alongside reqwest or as a drop-in replacement (set `default-features = false` to drop reqwest);
Expand Down Expand Up @@ -269,6 +273,44 @@ fn check() -> Result<(), Box<dyn std::error::Error>> {
}
```

### Restarting after an update

After `update()` returns [`VersionStatus::Updated`](crate::VersionStatus::Updated) the on-disk
executable has been replaced, but the running process keeps executing the old code until it exits.
To relaunch into the new binary immediately, use the [`restart`](crate::restart) module:
`restart::restart()` re-runs with the current arguments, and `restart::restart_with(args)` re-runs
with a fresh argument list (e.g. to drop an `--upgrade` flag so the new process does not update
again). On unix the process image is replaced with `exec` (the PID is preserved); on windows the new
binary is spawned and the current process exits. See the module docs for the platform details.

### Periodic update checks

Every `update()` / `is_update_available()` call makes a network request. To avoid checking on every
run, gate the check behind [`UpdateCheckGuard`](crate::check_interval::UpdateCheckGuard), a small
stamp-file guard: `should_check()` reports whether the configured interval has elapsed since the
last recorded check, and `record_check()` stamps the current time. The caller owns the stamp-file
path. It is a guard, not a scheduler -- no threads or timers, and no extra dependencies. See the
[`check_interval`](crate::check_interval) module for the semantics.

### GitHub rate limits

Requests to the GitHub REST API are rate limited by GitHub itself, not by this crate:

- **Unauthenticated** requests are limited to **60 per hour per source IP**; **authenticated**
requests (set any personal access token via `auth_token`) get **5000 per hour**. A token needs no
scopes to raise the limit for a public repository.
- An update check costs **one** API request (the latest-release lookup, or one request per page of a
paginated listing). The asset **download** itself is a CDN redirect and does not count against the
core API limit.
- When you are rate limited, GitHub responds with **HTTP 403** (and an `x-ratelimit-remaining: 0`
header), which this crate surfaces as `Error::Unauthorized { status: 403, .. }` -- the same
variant as a genuine auth failure, so recognize it by the symptom (a 403 that appears only under
frequent checking).
- To avoid it: set an `auth_token` (5000/hour), and check less often -- the
[`UpdateCheckGuard`](crate::check_interval::UpdateCheckGuard) above throttles how often you check.
The retry/backoff setters do **not** help here; retrying a rate-limited request only consumes more
quota.

### Listing releases (`ReleaseList`)

Each built-in backend exposes a `ReleaseList` builder for fetching the list of available releases
Expand Down
2 changes: 2 additions & 0 deletions specs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ design before it can be built). Keep each row's status current with `spec.py set
| Choose Latest Release Sort | done | [choose-latest-release-sort.md](choose-latest-release-sort.md) |
| Embedded Key Verification | done | [embedded-key-verification.md](embedded-key-verification.md) |
| Corporate Network Config | pending | [corporate-network-config.md](corporate-network-config.md) |
| Restart After Update | done | [ref-restart.md](ref-restart.md) |
| Update-check Interval Guard | done | [ref-check-interval.md](ref-check-interval.md) |

## Conventions

Expand Down
81 changes: 81 additions & 0 deletions specs/ref-check-interval.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Update-check interval guard (reference)

Status: implemented

## Scope

Documents the `self_update::check_interval` module (`src/check_interval.rs`): the
`UpdateCheckGuard` type that throttles how often an application checks for updates using a
timestamp stamp file. No feature gate; depends only on `std` and the existing `tempfile`
dependency.

## Behavior

Each `update()` / `is_update_available()` call makes a network request. `UpdateCheckGuard` gates
that behind a stamp file recording the last check time (unix epoch seconds), so an application that
would otherwise check on every run checks at most once per interval. It is a guard, not a scheduler:
no threads or timers, and no `chrono`/`time` dependency (the timestamp is stored via
`std::time::SystemTime`).

The guard owns two fields: `stamp_path: PathBuf` and `interval: Duration`
(`src/check_interval.rs`). The caller chooses both; the file and its parent directory need not exist
until `record_check` runs.

- `UpdateCheckGuard::new(stamp_path: impl Into<PathBuf>, interval: Duration) -> Self`
(`src/check_interval.rs`): construct a guard.
- `should_check(&self) -> Result<bool>` (`src/check_interval.rs`): whether a check is due. Reads the
stamp with `std::fs::read_to_string`:
- Missing file (`ErrorKind::NotFound`): due (`Ok(true)`), the first-run case.
- Any other read error (e.g. permissions, or the path is a directory): surfaced as
`Err(Error::Io(..))`, not silently treated as due.
- Contents not parseable as `u64`: due (`Ok(true)`). A corrupt stamp self-heals rather than
erroring.
- Parsed stamp dated in the future relative to now (clock skew): due (`Ok(true)`).
- Otherwise: due iff `now_secs - stamp_secs >= interval.as_secs()`.
- `record_check(&self) -> Result<()>` (`src/check_interval.rs`): stamp the current time. Writes the
epoch seconds to a `tempfile::NamedTempFile` in the stamp's parent directory (or `.` when the path
has no parent), flushes, and `persist`s it over `stamp_path`, so the replacement is atomic and a
concurrent reader never observes a partial write. An IO failure (missing or unwritable directory)
surfaces as `Err(Error::Io(..))`.

Now is computed by the private `now_epoch_secs()` (`src/check_interval.rs`) as
`SystemTime::now().duration_since(UNIX_EPOCH)` seconds, clamped to `0` on the impossible pre-1970
clock so the guard never panics.

## Public surface

- `self_update::check_interval::UpdateCheckGuard` (`Clone`, `Debug`).
- `UpdateCheckGuard::new(stamp_path: impl Into<PathBuf>, interval: Duration) -> Self`.
- `UpdateCheckGuard::should_check(&self) -> Result<bool>`.
- `UpdateCheckGuard::record_check(&self) -> Result<()>`.

## Invariants and regression checklist

- A missing stamp is due and is not an error; only a genuine (non-`NotFound`) IO read error surfaces.
- A corrupt/unparseable stamp is treated as due (self-healing), not an error.
- A future-dated stamp is due (clock-skew safety).
- `record_check` writes parseable epoch seconds and replaces the stamp atomically
(temp-file-then-rename in the same directory).
- The caller owns the path; no xdg/dirs dependency is pulled in. No feature gate; only `std` +
`tempfile`.

## Tests

`src/check_interval.rs` (`tests` module):

- `missing_stamp_is_due`: a first run (no stamp) is due.
- `record_then_not_due_within_interval`: after `record_check`, `should_check` is `false` within the
interval, and the stamp holds parseable epoch seconds.
- `back_dated_stamp_is_due`: a stamp older than the interval is due.
- `zero_interval_is_always_due`: a zero interval is due immediately after recording.
- `garbage_stamp_is_due_not_error`: a corrupt stamp is due, not an error.
- `future_stamp_is_due`: a future-dated stamp is due.
- `record_into_missing_dir_errors` / `record_into_readonly_dir_errors` (`#[cfg(unix)]` for the 0o555
case): `record_check` errors when the directory is missing or unwritable.
- `unreadable_stamp_surfaces_io_error`: a non-`NotFound` read error (stamp path is a directory)
surfaces as `Err`, not "due".

## Related

- `ref-restart.md` (the companion application-flow helper).
- `ref-update-pipeline.md` (the `update()` path this throttles).
5 changes: 4 additions & 1 deletion specs/ref-feature-flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,10 @@ The only `compile_error!` guards that remain:

- neither `reqwest` nor `ureq` -> error (`http_client/mod.rs:111`):
"no HTTP client selected - enable at least one of the `reqwest` (default) or
`ureq` features". A build with no client cannot service any request.
`ureq` features". A build with no client cannot service any request. This is the exact failure a
`default-features = false` manifest that enables only a TLS feature (e.g. `features = ["rustls"]`)
hits; the crate-level Features section (`src/lib.rs`) names the error and shows the fix (add a
client, e.g. `features = ["ureq", "rustls", "github"]`).
- `async` without `reqwest` -> error (`lib.rs:434`):
"feature `async` requires the `reqwest` client - `ureq` has no async API".
(`async` implies `reqwest` in `Cargo.toml`, so this only fires on a manual
Expand Down
13 changes: 13 additions & 0 deletions specs/ref-github-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,19 @@ host does not receive the token; `dangerously_allow_non_https_auth_forwarding()`
https requirement for a host-matched request. A user-set `Authorization` via `request_header`
overrides the crate's token header.

### Rate limits

GitHub rate limits its REST API: 60 requests/hour/IP unauthenticated, 5000/hour with a token (no
scopes needed for a public repo). This crate does not track the limit; it is documented in the
crate-level "GitHub rate limits" section (`src/lib.rs`) and pointed at from the `auth_token` setter
rustdoc (`github.rs:138-149`). An update check costs one API request (a `/latest` or `/tags/{tag}`
lookup, or one per page of a listing); the asset download is a CDN redirect and does not count
against the core limit. A rate-limited response is HTTP 403 (with `x-ratelimit-remaining: 0`), which
maps through `status_to_error` to `Error::Unauthorized { status: 403, .. }` (the same variant as a
genuine auth failure). Mitigation is an `auth_token` and checking less often (the
`check_interval::UpdateCheckGuard` throttle); the retry/backoff setters do not help, since retrying
a rate-limited request only consumes more quota.

### Pagination

The listing is described transport-free as a `PageRequest<Release>` via `releases_plan(base,
Expand Down
72 changes: 72 additions & 0 deletions specs/ref-restart.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Restart after update (reference)

Status: implemented

## Scope

Documents the `self_update::restart` module (`src/restart.rs`): the `restart()` and
`restart_with(args)` helpers that relaunch the running executable after an update has replaced it on
disk. No feature gate, no dependencies beyond `std`.

## Behavior

`update()` replaces the on-disk executable via `self_replace` (`src/update.rs`, `install_binary`),
but the live process keeps running the old image until it exits. The `restart` helpers relaunch the
(now-updated) binary so a long-running process picks up the new code immediately.

Both helpers target `std::env::current_exe()` and inherit the current environment. They share a
private `restart_command(args)` helper that builds a `std::process::Command` for the current exe
with the given args (`src/restart.rs`), which is the single point the platform paths and the unit
test agree on.

- `restart() -> Result<Infallible>` (`src/restart.rs`): re-runs with the current arguments, i.e.
`std::env::args_os().skip(1)`. Implemented as `restart_with(std::env::args_os().skip(1))`.
- `restart_with<I, S>(args) -> Result<Infallible>` where `I: IntoIterator<Item = S>, S: AsRef<OsStr>`
(`src/restart.rs`): re-runs with `args` verbatim, replacing (not appending to) the original
arguments. The bound mirrors `Command::args`, so `&str`, `String`, and `OsString` iterators all
work.

The return type is `std::convert::Infallible`: on success neither helper returns, so a returned
value can only be `Err`.

### Platform behavior

- Unix (`#[cfg(unix)]`): the process image is replaced in place with
`std::os::unix::process::CommandExt::exec`. `exec` returns only on failure, which is mapped to
`Err(Error::Io(..))`; on success the call never returns and the PID is preserved.
- Windows (`#[cfg(windows)]`): there is no `exec`. The updated binary is spawned as a separate
process (inheriting stdio) with `Command::spawn`, then the current process exits with
`std::process::exit(0)`. The PID therefore changes; a console application's replacement runs in
the same console. A spawn failure surfaces as `Err(Error::Io(..))` via the `?` on `spawn()`.

## Public surface

- `self_update::restart::restart() -> Result<std::convert::Infallible>` (`src/restart.rs`).
- `self_update::restart::restart_with<I, S>(args: I) -> Result<std::convert::Infallible>`
where `I: IntoIterator<Item = S>, S: AsRef<std::ffi::OsStr>` (`src/restart.rs`).

## Invariants and regression checklist

- Both helpers re-exec `current_exe()`, never a hard-coded path.
- `restart_with` forwards its args verbatim, in order, with nothing appended and the original
arguments discarded.
- `restart()` forwards `args_os().skip(1)` (the current arguments minus the program name).
- Success never returns (unix `exec` / windows `exit(0)`); only failures yield `Err(Error::Io)`.
- No feature gate and no new dependencies: the module compiles on a default build.

## Tests

- `src/restart.rs` (`tests` module): `restart_command_targets_current_exe_with_given_args` pins that
the built command targets `current_exe()` and forwards exactly the supplied args;
`restart_command_with_no_args_forwards_none` pins that an empty arg list is honored verbatim.
These run on every platform without replacing the process.
- `tests/restart.rs` (`#[cfg(unix)]`): `restart_with_reexecs_current_exe_with_given_args` drives a
real `exec` end to end. It spawns a fresh copy of the test binary that execs itself via
`restart_with`, and asserts the re-executed process observed exactly the forwarded arguments. The
windows spawn-then-exit path is verified manually (a detached spawn followed by `exit(0)` is racy
to assert on in an automated test).

## Related

- `ref-update-pipeline.md` (the `install_binary` / `self_replace` step this composes with).
- `ref-check-interval.md` (the companion application-flow helper).
3 changes: 3 additions & 0 deletions src/backends/github.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,9 @@ impl ReleaseListBuilder {
/// **Make sure not to bake the token into your app**; it is recommended
/// you obtain it via another mechanism, such as environment variables
/// or prompting the user for input
///
/// A token also raises GitHub's API rate limit from 60 to 5000 requests/hour (no scopes are
/// needed for a public repo); see the crate-level "GitHub rate limits" section.
pub fn auth_token(&mut self, auth_token: impl Into<String>) -> &mut Self {
self.auth_token = Some(auth_token.into());
self
Expand Down
Loading
Loading