You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix: eager VSS helper launch with attended UAC window and decline-only memoisation (#113)
## FLAG for review (a call against the literal instruction)
Point 3 of the task said a decline should "memoise for the session
(**never re-prompt**)". I read "never re-prompt" as governing the
**automatic / lazy** path only: a fresh **off->on re-toggle** is
present-user intent, so it clears a prior decline and re-attempts (the
lazy first-locked-file path and repeat attempts still never re-prompt
within the session). This is friendlier for a mis-click, but it is a
deliberate deviation - **flag if you want strict
never-re-prompt-until-restart** and I'll drop the reset in
`set_enabled(true)`.
## Why (live smoke failure of #112)
The wired helper's lazy at-first-locked-file launch gave the human only
the ~5s pipe-connect budget to approve UAC, and one timed-out shot
memoised failure until restart, so every locked file fell to
`local.file_locked`. This hardens the launch UX (Refs #25).
## What changed
- **`SEE_MASK_NOASYNC` on `ShellExecuteExW(runas)`** - the likely root
cause. Without it the call can return early (on a no-message-loop worker
thread) while the UAC prompt is still up and surface a spurious
non-`ERROR_CANCELLED` error -> a bogus memoised failure. With it, the
call WAITS for the user to approve/decline.
- **Eager launch on enable-toggle** (attended): flipping
`windows.vss_helper` ON fires the UAC prompt immediately (the user is at
Settings), on a background thread so the IPC returns at once. **Boot
stays LAZY** (no UAC at silent startup); the first locked file triggers
it. Documented why in `vss_helper.rs`.
- **Manager built at boot whenever un-elevated** (setting-independent)
with an `enabled` flag, shared into every provider - so a **runtime
toggle works without an app restart** (the prior wiring only took effect
at the next boot).
- **Decline-only memoisation** (`ERROR_CANCELLED` = decline OR ignored
prompt -> memoise; a decline and a prompt-timeout are indistinguishable
at the Win32 level, so both count as "did not approve"). An
**approved-but-pipe-never-came-up** launch is a TRANSIENT failure - not
memoised, retried on the next enable/start.
- **Attended 90s window** for the post-approval pipe handshake (only the
one-shot launch; steady-state reconnects keep the tight budget).
- **Transient classification** (not `file_locked`): a locked file hit
WHILE the helper is launching is skipped-and-requeued as the new
`local.vss_helper_pending` (`SnapshotOutcome::Pending` ->
`FallbackDecision::SkipRetryLater` -> `SkipReason::VssHelperPending` ->
`ErrorCode::LocalVssHelperPending`), so the transient launch window is
never misreported. The file re-queues and backs up on the next cycle
once the broker is up.
- **Truthful status + UI**: `launchPending` / `launchDeclined` added to
the DTO; the Rules tab shows a polled "waiting for elevation approval"
hint and a "declined" hint.
## Tests (non-elevated-safe)
Manager state machine (eager reaches Ready; decline memoised + not
retried; transient not memoised + retries on re-enable; re-toggle clears
decline; disabled never launches; lazy `launch_status` triggers a launch
and reports Pending; production `new()`/`production_launch` path
off-Windows). Provider `launch_status` -> `SnapshotOutcome` mapping +
executor-decision tests via the real `fallback_decision` (pending ->
SkipRetryLater; declined -> SkipLocked; mapped -> OpenSnapshot).
`driven-vss` table tests for the Pending arms. A Windows executor test
(real lock + `FakeVssProvider::pending`, non-elevated-safe) asserting
`OpOutcome::Skipped{VssHelperPending}`. Status truthfulness + the UI
pending->declined poll (fake timers).
## Gates
`cargo fmt --check`, `cargo clippy --workspace --all-targets -D
warnings`, `cargo test --workspace`, `pnpm -C ui test run` (262), `pnpm
-C ui build` (vue-tsc), UI coverage (Settings.vue 94.38%) all pass
locally.
## What remains for the elevated live smoke (post-merge)
The real `runas` UAC + pipe/COM path is elevation-gated. The elevated
runner should verify: enabling the toggle raises ONE UAC prompt and
reaches Ready; a locked file then backs up via the helper; a locked file
hit DURING the pending window shows `local.vss_helper_pending` (not
`file_locked`) and succeeds next cycle; declining leaves it degraded
with the "declined" hint and does not re-prompt (until an off->on
re-toggle).
**Caveat to watch during the smoke (narrow races, not redesigned):** if
the app is quit (or the toggle disabled) WHILE a launch is Pending (UAC
still up), `shutdown()` is a no-op (state is not yet Ready) and the
background thread may still resolve afterward - an approved helper could
then come up with nobody connecting/`Shutdown`-ing it, lingering on its
pipe until process exit. Watch for a stray `driven-vss-helper.exe` after
a quit-during-prompt.
Refs #25
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01LQMbCLUj5JT2e35qQMvsyA
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
0 commit comments