From 5a62a240bc434a2bf7688fcce7790d9efab1f632 Mon Sep 17 00:00:00 2001 From: Geoffrey Vancoetsem <10533139+geeooff@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:18:07 +0200 Subject: [PATCH 1/2] Record the zip's update path, seen from 0.3.0 to 0.4.0 Lot 17's last check: a 0.3.0 zip copy updated from its menu wrote its part to the single log it knows, and the 0.4.0 install-task its shell ran wrote to the dated file. 0.8 s from stop to start. Co-Authored-By: Claude Opus 5.5 --- docs/design/17-log-rotation.md | 19 +++++++++++++++---- docs/design/README.md | 2 +- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/docs/design/17-log-rotation.md b/docs/design/17-log-rotation.md index 8f3c99b..9c67dce 100644 --- a/docs/design/17-log-rotation.md +++ b/docs/design/17-log-rotation.md @@ -1,9 +1,9 @@ # 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. Left to see: the next real `purge` +taking the updates folder. The updater's zip path was seen on 2026-10-17, +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. @@ -13,7 +13,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`, @@ -162,6 +162,17 @@ 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 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 diff --git a/docs/design/README.md b/docs/design/README.md index 340ccaf..31ba1ae 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -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; the zip's update path 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 | From d8b12a3894d24290655382df6c67151aa4929a96 Mon Sep 17 00:00:00 2001 From: Geoffrey Vancoetsem <10533139+geeooff@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:28:23 +0200 Subject: [PATCH 2/2] Let purge remove its folders when they are read-only The real purge of 2026-10-17 took the updates folder but left logs and its parent, empty: the logs folder was read-only, copied back on 2026-09-26 from a OneDrive backup whose folders carry that attribute, and Windows refuses to remove a read-only folder. purge now clears the attribute of an empty folder of its own before removing it, and the after-exit shell passes -Force; a folder that still holds something is left untouched. A real-shell test with read-only folders failed before the change. Lot 17 records it as its last check. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +++ docs/design/17-log-rotation.md | 20 ++++++++-- docs/design/README.md | 2 +- src/purge.rs | 71 ++++++++++++++++++++++++++++++++-- 4 files changed, 90 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69da342..7feb10f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,11 @@ a section is written. built it from the tagged commit: `gh attestation verify --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 diff --git a/docs/design/17-log-rotation.md b/docs/design/17-log-rotation.md index 9c67dce..f4d6a66 100644 --- a/docs/design/17-log-rotation.md +++ b/docs/design/17-log-rotation.md @@ -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 next real `purge` -taking the updates folder. The updater's zip path was seen on 2026-10-17, -below. 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. @@ -173,6 +174,19 @@ 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 diff --git a/docs/design/README.md b/docs/design/README.md index 31ba1ae..da8e1e1 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -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 seen 2026-10-17 | +| 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 | diff --git a/src/purge.rs b/src/purge.rs index 2f54fe0..9f30e30 100644 --- a/src/purge.rs +++ b/src/purge.rs @@ -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. @@ -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 { @@ -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"))); @@ -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.