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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ a section is written.
built it from the tagged commit:
`gh attestation verify <file> --repo Geeooff/GameModeExecutor`.

### Fixed

- `purge` removes its folders even when they are marked read-only, as a
folder copied back from a OneDrive backup is.

## [0.4.0] - 2026-10-17

One log file a day, a Start menu entry to bring the watcher back, stop
Expand Down
33 changes: 29 additions & 4 deletions docs/design/17-log-rotation.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
# Lot 17 — A log that turns over each day

**Status: done 2026-09-26**, with `tracing-appender`, once a spike showed
the file turning over without a restart. Left to see: the updater's zip path
at the next release, and the next real `purge` taking the updates folder. The
old single file was seen pruned on 2026-10-16: eight dated files left, from
the file turning over without a restart. Its last two checks were seen on
2026-10-17: the updater's zip path, and a real `purge` taking the updates
folder, which found a read-only logs folder it could not remove, now handled;
both below. The old single file was seen pruned on 2026-10-16: eight dated files left, from
2026-10-06 to 2026-10-15, ten calendar days, since a day with no line has no
file.

Expand All @@ -13,7 +14,7 @@ file.
- [x] What the library now does removed from our code, and nothing kept that it already answers — 2026-09-25, below, the local timestamps included
- [x] *Open log*, `purge` and the documentation following the dated files — built 2026-09-25
- [x] `purge` of an installed copy leaving no log behind, the uninstall's own lines included — built 2026-09-25, seen on a real purge 2026-09-26, below
- [ ] The commands that write the same log — `stop`, `init`, `install-task`, `uninstall-task`, `update` — lose no line and prune nothing they should not — the package's install and uninstall, `purge` and `update --check` seen 2026-09-26, below; the updater's zip path waits for the next release
- [x] The commands that write the same log — `stop`, `init`, `install-task`, `uninstall-task`, `update` — lose no line and prune nothing they should not — the package's install and uninstall, `purge` and `update --check` seen 2026-09-26, the updater's zip path 2026-10-17, below
- [x] Verified in the field across a real turnover on the maintainer's machine — 2026-09-26, below

**Done when** each day's log is its own file, `gamemode-executor.YYYY-MM-DD.log`,
Expand Down Expand Up @@ -162,6 +163,30 @@ the one updating. Updating a zip copy now could only install 0.3.0, whose
`install-task` predates this lot. It is seen at the next release: a zip
copy of 0.3.0 updated to the release that carries this lot.

**Seen on 2026-10-17**, 0.3.0 unpacked from its zip and updated to 0.4.0
from its menu by the maintainer. The 0.3.0 watcher wrote its part —
*This copy updates as* `Zip`, the 0.4.0 zip downloaded and verified,
*Installing 0.4.0*, *Stopped* at 21:16:07.881 — to the single
`gamemode-executor.log` it knows, recreated for the occasion. The shell then
unpacked 0.4.0 and ran its `install-task`, whose *Logon task kept* went to
the day's dated file at 21:16:08.619, followed by *Updated to 0.4.0* and the
new watcher's start: 0.8 s from stop to start, the `.old` executables gone
once the new one ran, the updates folder empty. The recreated single file is
the oldest match of the appender's pattern, and goes as the first did.

**The real purge, 2026-10-17**, run by the maintainer in their own terminal
after saving the configuration and the logs: the task, the configuration,
seven log files and the updates folder went, then Windows Installer removed
the program at 21:21:07.9 and the sweep took the uninstall's own log line a
second later. But `logs` and its parent stayed, empty: the logs folder was
read-only. It had been made on 2026-09-26 by the maintainer's restore, a
`Copy-Item` of the folder back from a backup on the OneDrive desktop, where
folders carry the read-only and *unpinned* attributes; Windows refuses to
remove a read-only folder, and both the in-process removal and the shell's
`Remove-Item` failed on it. `purge` now clears the attribute of an empty
folder of its own before removing it, and the shell passes `-Force`; a test
with read-only folders failed without the change and passes with it.

The footprint, the watcher just started: 161 handles and 1.99 MB private,
against 162 and 2.09 MB for 0.3.0 just started in
[Lot 16](16-footprint.md)'s table. The executables grew by 90 KB, the
Expand Down
2 changes: 1 addition & 1 deletion docs/design/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ the pages keep the history.
| 14 | [Release notes people can read](14-release-notes.md) | done 2026-09-18 |
| 15 | [Games Windows knows only from you](15-marked-games.md) | done 2026-09-23 |
| 16 | [What the watcher keeps](16-footprint.md) | done 2026-09-23; memory at rest to measure again against 0.3.0 |
| 17 | [A log that turns over each day](17-log-rotation.md) | done 2026-09-26; the zip's update path to see at the next release |
| 17 | [A log that turns over each day](17-log-rotation.md) | done 2026-09-26; its last checks seen 2026-10-17 |
| 18 | [When the game is gone and nobody is there](18-game-gone-nobody-there.md) | proposed, not decided |
| 19 | [Starting from Windows' Startup list](19-startup-list.md) | proposed; a spike first |
| 20 | [The build chain, official and kept current](20-build-chain.md) | built 2026-10-17; the first wait and the first attested release to see |
Expand Down
71 changes: 67 additions & 4 deletions src/purge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -279,12 +279,44 @@ pub fn discover(config: Option<&config::Config>, config_path: &Path) -> Layout {
/// accident, measured on 2026-09-25.
fn remove_if_empty(dir: &Path) -> String {
let dir = quoted(dir);
// `-Force` for a folder marked read-only, as `remove_empty_dir` says.
format!(
"if (-not (Get-ChildItem -LiteralPath {dir} -Force -ErrorAction SilentlyContinue)) \
{{ Remove-Item -LiteralPath {dir} -ErrorAction SilentlyContinue }}"
{{ Remove-Item -LiteralPath {dir} -Force -ErrorAction SilentlyContinue }}"
)
}

/// Remove a folder of ours once it is empty, read-only or not, and change
/// nothing on one that still holds something.
///
/// Windows refuses to remove a read-only folder, and a folder copied back
/// from a OneDrive backup comes back read-only: the logs folder restored
/// that way on 2026-09-26 outlived the real purge of 2026-10-17, and its
/// parent with it.
fn remove_empty_dir(dir: &Path) -> std::io::Result<()> {
use std::os::windows::fs::MetadataExt;
use windows::Win32::Storage::FileSystem::{
FILE_ATTRIBUTE_READONLY, FILE_FLAGS_AND_ATTRIBUTES, SetFileAttributesW,
};

if std::fs::read_dir(dir)?.next().is_some() {
return Err(std::io::ErrorKind::DirectoryNotEmpty.into());
}
let attributes = std::fs::metadata(dir)?.file_attributes();
if attributes & FILE_ATTRIBUTE_READONLY.0 != 0 {
let path = windows::core::HSTRING::from(dir);
// SAFETY: `path` is a NUL-terminated wide string that outlives the
// call, which reads it and keeps nothing.
unsafe {
SetFileAttributesW(
&path,
FILE_FLAGS_AND_ATTRIBUTES(attributes & !FILE_ATTRIBUTE_READONLY.0),
)
}?;
}
std::fs::remove_dir(dir)
}

/// The shell's steps for an installed copy: the uninstall, waited for, then
/// the log its `stop` and `uninstall-task` wrote and the folders that leaves
/// empty.
Expand Down Expand Up @@ -326,10 +358,13 @@ pub fn execute(plan: &Plan) -> Result<()> {
println!("Deleted {}.", tree.display());
}
for dir in &plan.dirs {
match std::fs::remove_dir(dir) {
match remove_empty_dir(dir) {
Ok(()) => println!("Removed {}.", dir.display()),
// Not empty, or already gone: either way it is not ours to force.
Err(_) => println!("Left {} alone: it holds something else.", dir.display()),
// Something else in it is not ours to force.
Err(error) if error.kind() == std::io::ErrorKind::DirectoryNotEmpty => {
println!("Left {} alone: it holds something else.", dir.display())
}
Err(error) => println!("Could not remove {}: {error}.", dir.display()),
}
}
match &plan.program {
Expand Down Expand Up @@ -579,6 +614,9 @@ mod tests {
}
touch(&shared.join("logs").join("notes.txt"));
touch(&shared.join("updates").join("pending.txt"));
// Read-only, as a logs folder copied back from OneDrive comes.
read_only(&alone.join("logs"));
read_only(&alone);

for local in [&alone, &shared] {
let steps = uninstall_steps("{CODE}", Some(&local.join("logs")));
Expand All @@ -595,6 +633,31 @@ mod tests {
assert!(shared.join("updates").join("pending.txt").exists());
}

fn read_only(dir: &Path) {
let mut permissions = std::fs::metadata(dir).unwrap().permissions();
permissions.set_readonly(true);
std::fs::set_permissions(dir, permissions).unwrap();
}

#[test]
fn an_empty_folder_goes_read_only_or_not_and_a_full_one_stays_as_it_was() {
let empty = scratch().join("empty");
std::fs::create_dir_all(&empty).unwrap();
read_only(&empty);
remove_empty_dir(&empty).unwrap();
assert!(!empty.exists());

let full = scratch().join("full");
touch(&full.join("notes.txt"));
read_only(&full);
let error = remove_empty_dir(&full).unwrap_err();
assert_eq!(error.kind(), std::io::ErrorKind::DirectoryNotEmpty);
assert!(
std::fs::metadata(&full).unwrap().permissions().readonly(),
"nothing changed on a folder that holds something"
);
}

/// The hand-installed case hands the executables to a shell that waits
/// for this process to exit. The test cannot exit, so the shell is told
/// to wait for a process that already has.
Expand Down