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
33 changes: 20 additions & 13 deletions src/pages/startup/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ mod sampler;
// 启动项页列出 Run 键和“启动”文件夹中的项目,并在后台 worker 上启用或禁用。
// UI 线程只提交请求、维护列表选择和绘制。刷新保留来源加名称对应的选择、焦点和滚动位置。
// 某个来源枚举失败时保留该来源上一轮的行,并把这些行和页面提示标成未刷新。
// 启用和禁用按接受顺序进入同一 worker 队列。后到的刷新不会丢掉尚未执行的切换,
// 已在等待的刷新也不会丢掉后到的切换。写入前用页面保存的身份再核对;对不上就跳过并报告原有失败。
// 启用和禁用不删除原项;写入结果读回不一致、项目已消失或 API 失败时立即显示错误。
// 页面销毁时不在 UI 线程上 join 这个 worker。

use std::mem::zeroed;

Expand Down Expand Up @@ -350,18 +353,20 @@ impl StartupPageState {
if self.worker.is_some() {
return Ok(());
}
self.worker = Some(SingleFlightWorker::spawn(
// Asynchronous retirement closes completion posts and does not join this worker
// on the UI thread, including while a queued toggle is still writing.
self.worker = Some(SingleFlightWorker::spawn_cancellable_initialized(
"taskmgr-rs-startup-sampler",
PWM_STARTUP_WORKER_COMPLETE,
merge_startup_requests,
perform,
|_stop| perform,
Comment thread
JamesLinYJ marked this conversation as resolved.
Resolved
)?);
Ok(())
}

fn refresh(&mut self) {
self.drain_worker();
self.submit(StartupRequest::Refresh);
self.submit(StartupRequest::refresh());
}

fn request_toggle(&mut self, toggle: StartupToggle) {
Expand All @@ -378,14 +383,11 @@ impl StartupPageState {
self.beep();
return;
}
self.submit(StartupRequest::SetEnabled { id, toggle });
self.submit(StartupRequest::toggle(id, toggle));
}

fn submit(&mut self, request: StartupRequest) {
let toggle = match &request {
StartupRequest::SetEnabled { toggle, .. } => Some(*toggle),
StartupRequest::Refresh => None,
};
let toggle = request.toggles.first().map(|command| command.toggle);
let Some(worker) = self.worker.as_mut() else {
self.report_submit_failure(StartupQueryFailure::Win32(ERROR_BROKEN_PIPE), toggle);
return;
Expand Down Expand Up @@ -432,12 +434,17 @@ impl StartupPageState {
Ok(collection) => self.apply_collection(collection),
Err(failure) => self.apply_refresh_failure(failure),
},
StartupWorkerCompletion::Action(report) => {
match report.outcome {
Ok(state) => self.apply_toggled_state(&report.id, state),
Err(failure) => self.show_action_failure(report.toggle, failure),
StartupWorkerCompletion::Actions(reports) => {
if reports.is_empty() {
return;
}
for report in reports {
match report.outcome {
Ok(state) => self.apply_toggled_state(&report.id, state),
Err(failure) => self.show_action_failure(report.toggle, failure),
}
}
self.submit(StartupRequest::Refresh);
self.submit(StartupRequest::refresh());
}
}
}
Expand Down
157 changes: 128 additions & 29 deletions src/pages/startup/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,12 +134,35 @@ impl StartupToggle {
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum StartupRequest {
Refresh,
SetEnabled {
id: StartupEntryId,
toggle: StartupToggle,
},
pub(crate) struct StartupToggleCommand {
pub(crate) id: StartupEntryId,
pub(crate) toggle: StartupToggle,
}

/// One coalesced worker request.
///
/// Enable and disable commands stay in [`StartupRequest::toggles`] in acceptance order.
/// `refresh` records that a snapshot is also wanted. Neither field replaces the other.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct StartupRequest {
pub(crate) toggles: Vec<StartupToggleCommand>,
pub(crate) refresh: bool,
}

impl StartupRequest {
pub(crate) fn refresh() -> Self {
Self {
toggles: Vec::new(),
refresh: true,
}
}

pub(crate) fn toggle(id: StartupEntryId, toggle: StartupToggle) -> Self {
Self {
toggles: vec![StartupToggleCommand { id, toggle }],
refresh: false,
}
}
}

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
Expand Down Expand Up @@ -271,6 +294,22 @@ pub(crate) fn classify_startup_result(
}
}

/// `stored` is the identity the page already kept for this command.
///
/// The worker reads the live entry immediately before the registry or startup-folder write and
/// passes that entry here. A missing entry, or one that is no longer the same source and name,
/// is [`StartupActionFailure::Missing`]. The caller skips the write.
pub(crate) fn confirm_startup_identity(
stored: &StartupEntryId,
observed: Option<&StartupEntryId>,
) -> Result<(), StartupActionFailure> {
if observed.is_some_and(|observed| stored.matches(observed)) {
Ok(())
} else {
Err(StartupActionFailure::Missing)
}
}

pub(crate) fn command_state(selected: Option<StartupEnabledState>) -> StartupCommandState {
let Some(state) = selected else {
return StartupCommandState::disabled();
Expand All @@ -281,12 +320,11 @@ pub(crate) fn command_state(selected: Option<StartupEnabledState>) -> StartupCom
}
}

/// Keeps a pending enable or disable ahead of a coalesced refresh.
/// Appends every accepted enable or disable. A refresh does not erase that queue, and a toggle
/// is not dropped when a refresh is already pending.
pub(crate) fn merge_startup_requests(pending: &mut StartupRequest, incoming: StartupRequest) {
match (&*pending, &incoming) {
(StartupRequest::SetEnabled { .. }, StartupRequest::Refresh) => {}
_ => *pending = incoming,
}
pending.toggles.extend(incoming.toggles);
pending.refresh = pending.refresh || incoming.refresh;
}

pub(crate) const fn refresh_scroll_policy() -> StartupScrollPolicy {
Expand Down Expand Up @@ -616,30 +654,91 @@ mod tests {
);
}

#[test]
fn pending_toggle_survives_a_coalesced_refresh() {
let original = StartupRequest::SetEnabled {
id: id(StartupSource::CurrentUserRun, "OneDrive"),
toggle: StartupToggle::Disable,
};
let mut pending = original.clone();
merge_startup_requests(&mut pending, StartupRequest::Refresh);
assert_eq!(pending, original);
fn queued_toggle(
source: StartupSource,
name: &str,
toggle: StartupToggle,
) -> StartupToggleCommand {
StartupToggleCommand {
id: id(source, name),
toggle,
}
}

#[test]
fn two_pending_toggles_survive_a_merge_and_a_refresh() {
let first = queued_toggle(
StartupSource::CurrentUserRun,
"OneDrive",
StartupToggle::Disable,
);
let second = queued_toggle(
StartupSource::LocalMachineRun,
"SecurityHealth",
StartupToggle::Enable,
);
let mut pending = StartupRequest::toggle(first.id.clone(), first.toggle);
merge_startup_requests(
&mut pending,
StartupRequest::SetEnabled {
id: id(StartupSource::LocalMachineRun, "SecurityHealth"),
toggle: StartupToggle::Enable,
},
StartupRequest::toggle(second.id.clone(), second.toggle),
);
assert!(matches!(
assert_eq!(
pending,
StartupRequest::SetEnabled {
toggle: StartupToggle::Enable,
..
StartupRequest {
toggles: vec![first.clone(), second.clone()],
refresh: false,
}
));
);

merge_startup_requests(&mut pending, StartupRequest::refresh());
assert_eq!(
pending,
StartupRequest {
toggles: vec![first.clone(), second],
refresh: true,
}
);

let mut refresh_first = StartupRequest::refresh();
merge_startup_requests(
&mut refresh_first,
StartupRequest::toggle(first.id.clone(), first.toggle),
);
assert_eq!(
refresh_first,
StartupRequest {
toggles: vec![first],
refresh: true,
}
);
}

#[test]
fn a_changed_startup_identity_is_the_existing_missing_failure() {
let stored = id(StartupSource::CurrentUserRun, "OneDrive");
assert_eq!(confirm_startup_identity(&stored, Some(&stored)), Ok(()));
assert_eq!(
confirm_startup_identity(
&stored,
Some(&id(StartupSource::CurrentUserRun, "onedrive"))
),
Ok(())
);
assert_eq!(
confirm_startup_identity(&stored, None),
Err(StartupActionFailure::Missing)
);
assert_eq!(
confirm_startup_identity(
&stored,
Some(&id(StartupSource::LocalMachineRun, "OneDrive"))
),
Err(StartupActionFailure::Missing)
);
assert_eq!(
confirm_startup_identity(&stored, Some(&id(StartupSource::CurrentUserRun, "Teams"))),
Err(StartupActionFailure::Missing)
);
}

#[test]
Expand Down
48 changes: 28 additions & 20 deletions src/pages/startup/sampler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@
//! The page worker owns every registry key and find handle. Enumeration reads the native
//! 64-bit registry view, including the explicit WOW6432Node Run key. Enable and disable rewrite
//! only the 12-byte StartupApproved value and then read it back, keeping the 2/3 family distinct
//! from the 6/7 family. The Run value and the Startup folder file are checked before and after
//! that write so a missing entry is never reported as a successful change.
//! from the 6/7 family. Queued commands run in acceptance order. Immediately before each write,
//! the Run value or startup file is read again and compared with the identity the page stored.
//! A missing or different entry skips that command and is [`StartupActionFailure::Missing`].
//! The entry is checked again after the write so a disappearance is never a successful change.
//!
//! A source that fails to enumerate is listed in `omitted_sources` and contributes no rows.
//! The page keeps the previous rows for that source. The snapshot is an error only when every
Expand Down Expand Up @@ -51,9 +53,9 @@ use crate::infrastructure::native::{to_wide_null, widestr_ptr_to_string};

use super::model::{
StartupActionFailure, StartupEnabledState, StartupEntryId, StartupSource, StartupToggle,
approved_blob, approved_flag, classify_startup_result, executable_candidate,
has_version_resource_extension, is_absolute_win32_path, registry_value_name_is_safe,
startup_file_name_is_safe, state_from_approved,
approved_blob, approved_flag, classify_startup_result, confirm_startup_identity,
executable_candidate, has_version_resource_extension, is_absolute_win32_path,
registry_value_name_is_safe, startup_file_name_is_safe, state_from_approved,
};

const VALUE_ENUM_LIMIT: u32 = 4_096;
Expand Down Expand Up @@ -126,7 +128,7 @@ pub(super) struct StartupRefreshFailure {
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) enum StartupWorkerCompletion {
Snapshot(Result<StartupCollection, StartupRefreshFailure>),
Action(StartupActionReport),
Actions(Vec<StartupActionReport>),
}

struct RegKey {
Expand Down Expand Up @@ -185,14 +187,22 @@ impl SourcePart {
}

pub(super) fn perform(request: super::model::StartupRequest) -> StartupWorkerCompletion {
match request {
super::model::StartupRequest::Refresh => {
StartupWorkerCompletion::Snapshot(collect_startup_entries())
}
super::model::StartupRequest::SetEnabled { id, toggle } => {
StartupWorkerCompletion::Action(set_enabled(&id, toggle))
}
}
// Commands stay ahead of a coalesced snapshot. Each one re-checks the stored identity
// before it writes. The page requests the snapshot after these reports, so a refresh that
// arrived with the queue still runs, and the queue is not held inside enumeration.
if !request.toggles.is_empty() {
return StartupWorkerCompletion::Actions(
request
.toggles
.into_iter()
.map(|command| set_enabled(&command.id, command.toggle))
.collect(),
);
}
if request.refresh {
return StartupWorkerCompletion::Snapshot(collect_startup_entries());
}
StartupWorkerCompletion::Actions(Vec::new())
}

fn collect_startup_entries() -> Result<StartupCollection, StartupRefreshFailure> {
Expand Down Expand Up @@ -424,9 +434,8 @@ fn set_registry_enabled(
if !registry_value_name_is_safe(&id.name) {
return Err(StartupActionFailure::Win32(ERROR_INVALID_PARAMETER));
}
if !registry_entry_exists(id)? {
return Err(StartupActionFailure::Missing);
}
// The page stored this source and name. Re-read the Run value immediately before the write.
confirm_startup_identity(id, registry_entry_exists(id)?.then_some(id))?;
let observed = write_and_read_approved(id, toggle)?;
if !registry_entry_exists(id)? {
return Err(StartupActionFailure::Missing);
Expand All @@ -442,9 +451,8 @@ fn set_folder_enabled(
return Err(StartupActionFailure::Win32(ERROR_INVALID_PARAMETER));
}
let path = startup_file_path(id)?;
if !startup_file_exists(&path)? {
return Err(StartupActionFailure::Missing);
}
// The page stored this source and name. Re-read the startup file immediately before the write.
confirm_startup_identity(id, startup_file_exists(&path)?.then_some(id))?;
let observed = write_and_read_approved(id, toggle)?;
if !startup_file_exists(&path)? {
return Err(StartupActionFailure::Missing);
Expand Down