From 8e2759eb392fd47f0440270de818eff710847dcc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 20 Oct 2026 03:32:01 +0000 Subject: [PATCH 1/2] fix: run Users session actions on the background worker Disconnect, log off, and send message still confirm on the UI thread, but the identity re-check and WTS calls now run on the users worker. A reused session is reported as a failure, and the three commands have status-bar help in every locale. Co-authored-by: jameslincn --- localization/de.toml | 3 + localization/en_us.toml | 3 + localization/es.toml | 3 + localization/fr.toml | 3 + localization/pt.toml | 3 + localization/ru.toml | 3 + localization/zh_cn.toml | 3 + localization/zh_tw.toml | 3 + src/pages/users.rs | 495 +++++++++++++++++++++++--------- src/ui/localization/mod.rs | 9 +- src/ui/localization/text_key.rs | 3 + 11 files changed, 396 insertions(+), 135 deletions(-) diff --git a/localization/de.toml b/localization/de.toml index 7aab88e..ce43740 100644 --- a/localization/de.toml +++ b/localization/de.toml @@ -320,6 +320,9 @@ ServiceStartFailed = "Der Dienst konnte nicht gestartet werden." ServiceStopFailed = "Der Dienst konnte nicht beendet werden." ServiceStateUnchanged = "Der Dienst hat den angeforderten Zustand nicht erreicht." ServiceProcessPid = "PID" +DisconnectStatusHelp = "Trennt die ausgewaehlte Benutzersitzung" +LogoffStatusHelp = "Meldet die ausgewaehlte Benutzersitzung ab" +SendMessageStatusHelp = "Sendet eine Nachricht an die ausgewaehlte Benutzersitzung" [menu_status_help] IDM_RUN = "Runs a new program" diff --git a/localization/en_us.toml b/localization/en_us.toml index 5755c36..3d68cd3 100644 --- a/localization/en_us.toml +++ b/localization/en_us.toml @@ -327,6 +327,9 @@ ServiceStartFailed = "Unable to start the service." ServiceStopFailed = "Unable to stop the service." ServiceStateUnchanged = "The service did not enter the requested state." ServiceProcessPid = "PID" +DisconnectStatusHelp = "Disconnects the selected user session" +LogoffStatusHelp = "Logs off the selected user session" +SendMessageStatusHelp = "Sends a message to the selected user session" [menu_status_help] IDM_RUN = "Runs a new program" diff --git a/localization/es.toml b/localization/es.toml index aece701..047355a 100644 --- a/localization/es.toml +++ b/localization/es.toml @@ -320,6 +320,9 @@ ServiceStartFailed = "No se pudo iniciar el servicio." ServiceStopFailed = "No se pudo detener el servicio." ServiceStateUnchanged = "El servicio no entró en el estado solicitado." ServiceProcessPid = "PID" +DisconnectStatusHelp = "Desconecta la sesión del usuario seleccionado" +LogoffStatusHelp = "Cierra la sesión del usuario seleccionado" +SendMessageStatusHelp = "Envía un mensaje a la sesión del usuario seleccionado" [menu_status_help] IDM_RUN = "Runs a new program" diff --git a/localization/fr.toml b/localization/fr.toml index a4077f1..b10c747 100644 --- a/localization/fr.toml +++ b/localization/fr.toml @@ -320,6 +320,9 @@ ServiceStartFailed = "Impossible de démarrer le service." ServiceStopFailed = "Impossible d'arrêter le service." ServiceStateUnchanged = "Le service n'a pas atteint l'état demandé." ServiceProcessPid = "PID" +DisconnectStatusHelp = "Déconnecte la session de l'utilisateur sélectionné" +LogoffStatusHelp = "Ferme la session de l'utilisateur sélectionné" +SendMessageStatusHelp = "Envoie un message à la session de l'utilisateur sélectionné" [menu_status_help] IDM_RUN = "Runs a new program" diff --git a/localization/pt.toml b/localization/pt.toml index 2ca1980..c5ea1b8 100644 --- a/localization/pt.toml +++ b/localization/pt.toml @@ -320,6 +320,9 @@ ServiceStartFailed = "Não foi possível iniciar o serviço." ServiceStopFailed = "Não foi possível parar o serviço." ServiceStateUnchanged = "O serviço não entrou no estado solicitado." ServiceProcessPid = "PID" +DisconnectStatusHelp = "Desconecta a sessão do usuário selecionado" +LogoffStatusHelp = "Encerra a sessão do usuário selecionado" +SendMessageStatusHelp = "Envia uma mensagem para a sessão do usuário selecionado" [menu_status_help] IDM_RUN = "Runs a new program" diff --git a/localization/ru.toml b/localization/ru.toml index ab7be1d..3b22580 100644 --- a/localization/ru.toml +++ b/localization/ru.toml @@ -320,6 +320,9 @@ ServiceStartFailed = "Не удалось запустить службу." ServiceStopFailed = "Не удалось остановить службу." ServiceStateUnchanged = "Служба не перешла в запрошенное состояние." ServiceProcessPid = "PID" +DisconnectStatusHelp = "Отключает выбранный сеанс пользователя" +LogoffStatusHelp = "Завершает выбранный сеанс пользователя" +SendMessageStatusHelp = "Отправляет сообщение выбранному сеансу пользователя" [menu_status_help] IDM_RUN = "Runs a new program" diff --git a/localization/zh_cn.toml b/localization/zh_cn.toml index bfdb7c4..3e6e74a 100644 --- a/localization/zh_cn.toml +++ b/localization/zh_cn.toml @@ -317,6 +317,9 @@ ServiceStartFailed = "无法启动该服务。" ServiceStopFailed = "无法停止该服务。" ServiceStateUnchanged = "该服务没有进入请求的状态。" ServiceProcessPid = "PID" +DisconnectStatusHelp = "断开所选用户会话" +LogoffStatusHelp = "注销所选用户会话" +SendMessageStatusHelp = "向所选用户会话发送消息" [menu_status_help] IDM_RUN = "Runs a new program" diff --git a/localization/zh_tw.toml b/localization/zh_tw.toml index f6a0d26..f98945b 100644 --- a/localization/zh_tw.toml +++ b/localization/zh_tw.toml @@ -317,6 +317,9 @@ ServiceStartFailed = "無法啟動服務。" ServiceStopFailed = "無法停止服務。" ServiceStateUnchanged = "服務沒有進入要求的狀態。" ServiceProcessPid = "PID" +DisconnectStatusHelp = "中斷選取的使用者工作階段" +LogoffStatusHelp = "將選取的使用者工作階段登出" +SendMessageStatusHelp = "傳送訊息給選取的使用者工作階段" [menu_status_help] IDM_RUN = "Runs a new program" diff --git a/src/pages/users.rs b/src/pages/users.rs index 8b6b21e..be22aa9 100644 --- a/src/pages/users.rs +++ b/src/pages/users.rs @@ -12,7 +12,8 @@ use std::collections::HashMap; // 用户页实现。 // 这里负责枚举终端服务会话、刷新用户列表,并处理发送消息、断开连接、 -// 注销等会话级操作。 +// 注销等会话级操作。枚举、身份复核和 WTS 调用都在持久 worker 上执行。 +// UI 线程只分发命令、布局、绘制,并在 worker 返回后显示已有的错误对话框。 use std::mem::{size_of, zeroed}; use std::ptr::null_mut; use std::slice; @@ -51,7 +52,7 @@ use crate::infrastructure::native::{ record_win32_error_with_fields, set_window_userdata, subclass_list_view, to_wide_null, widestr_ptr_to_string, window_rect_relative_to_page, }; -use crate::infrastructure::worker::{SingleFlightWorker, keep_pending}; +use crate::infrastructure::worker::SingleFlightWorker; use crate::ui::dialogs::dialog_box; use crate::ui::localization::{ TextKey, localize_dialog, session_state, text, user_column_titles, user_session_column_title, @@ -131,7 +132,7 @@ pub struct UserPageState { sessions: Vec, sort_column: usize, sort_ascending: bool, - worker: Option>, + worker: Option>, last_refresh_error: Option, last_row_errors: Vec, } @@ -190,8 +191,8 @@ impl UserPageState { self.worker = Some(SingleFlightWorker::spawn( "taskmgr-rs-user-sampler", PWM_USER_WORKER_COMPLETE, - keep_pending, - |()| collect_user_sessions(), + merge_user_requests, + perform_user_request, )?); Ok(()) } @@ -397,15 +398,18 @@ impl UserPageState { } fn schedule_collection(&mut self) { - let Some(worker) = self.worker.as_mut() else { - self.set_refresh_error(windows_sys::Win32::Foundation::ERROR_BROKEN_PIPE); - return; - }; - if let Err(error) = worker.request((), self.hwnd) { + if let Err(error) = self.submit_request(UserWorkerRequest::Refresh) { self.set_refresh_error(error); } } + fn submit_request(&mut self, request: UserWorkerRequest) -> Result<(), u32> { + let Some(worker) = self.worker.as_mut() else { + return Err(windows_sys::Win32::Foundation::ERROR_BROKEN_PIPE); + }; + worker.request(request, self.hwnd).map(|_| ()) + } + fn drain_worker_results(&mut self) { let drained = match self.worker.as_mut() { Some(worker) => worker.drain(self.hwnd), @@ -413,7 +417,7 @@ impl UserPageState { }; for completion in drained.completions { crate::infrastructure::diagnostics::with_operation_id(completion.operation_id, || { - self.apply_user_worker_result(completion.value); + self.apply_user_worker_completion(completion.value); }); } if let Some(error) = drained.error { @@ -421,6 +425,26 @@ impl UserPageState { } } + fn apply_user_worker_completion(&mut self, completion: UserWorkerCompletion) { + match completion { + UserWorkerCompletion::Snapshot(result) => self.apply_user_worker_result(result), + UserWorkerCompletion::Actions(reports) => { + let mut refresh = false; + for report in reports { + if let Some(error) = report.error { + self.show_action_error(report.kind, error); + refresh = true; + } else if report.kind != UserSessionKind::SendMessage { + refresh = true; + } + } + if refresh && let Err(error) = self.submit_request(UserWorkerRequest::Refresh) { + self.set_refresh_error(error); + } + } + } + } + fn apply_user_worker_result(&mut self, result: UserWorkerResult) { match result { Ok(collection) => { @@ -841,135 +865,99 @@ impl UserPageState { } fn send_message(&mut self) { - // 安全性: this function is a safe facade over Win32/FFI work; all callers run it on the owning UI thread and the existing body preserves its original handle/pointer invariants. - unsafe { - // 发送消息会先弹出输入对话框,再逐个会话调用 WTSSendMessageW。 - let selected = self.selected_session_identities(); - if selected.is_empty() { - return; - } - - let mut result = MessageDialogResult::default(); - match dialog_box( - self.hinstance as _, - IDD_MESSAGE, - self.hwnd, - Some(message_dialog_proc), - &mut result as *mut _ as LPARAM, - ) { - Ok(dialog_result) if dialog_result == IDOK as isize => {} - Ok(_) => return, - Err(error) => { - self.show_command_failure_with_error( - text(TextKey::MessageCouldNotBeSent), - error, - ); - return; - } - } - - if let Err(error) = validate_session_identities(&selected) { + // 对话框收集标题和正文。身份复核与 WTSSendMessageW 留在 worker。 + let Some(selected) = self.verified_selection(UserSessionKind::SendMessage) else { + return; + }; + let mut result = MessageDialogResult::default(); + match dialog_box( + self.hinstance as _, + IDD_MESSAGE, + self.hwnd, + Some(message_dialog_proc), + &mut result as *mut _ as LPARAM, + ) { + Ok(dialog_result) if dialog_result == IDOK as isize => {} + Ok(_) => return, + Err(error) => { self.show_command_failure_with_error(text(TextKey::MessageCouldNotBeSent), error); - self.refresh(); return; } - - let title = to_wide_null(&result.title); - let body = to_wide_null(&result.body); - for identity in selected { - if let Err(error) = validate_session_identity(&identity) { - self.show_command_failure_with_error( - text(TextKey::MessageCouldNotBeSent), - error, - ); - break; - } - let mut response = 0i32; - if WTSSendMessageW( - WTS_CURRENT_SERVER_HANDLE, - identity.session_id, - title.as_ptr(), - (result.title.encode_utf16().count() * 2) as u32, - body.as_ptr(), - (result.body.encode_utf16().count() * 2) as u32, - MB_OK | MB_TOPMOST | MB_ICONINFORMATION, - 0, - &mut response, - 0, - ) == 0 - { - self.show_command_failure(text(TextKey::MessageCouldNotBeSent)); - break; - } - } } + self.submit_session_command(UserSessionCommand { + kind: UserSessionKind::SendMessage, + sessions: selected, + title: result.title, + body: result.body, + }); } fn change_session_state(&mut self, command_id: u16) { - // 安全性: this function is a safe facade over Win32/FFI work; all callers run it on the owning UI thread and the existing body preserves its original handle/pointer invariants. - unsafe { - // 断开/注销属于高影响操作,先确认,再逐个会话执行,失败时立即报错并停止。 - let selected = self.selected_session_identities(); - if selected.is_empty() { - return; - } - - let prompt = if command_id == IDM_LOGOFF { - text(TextKey::ConfirmLogoffSelectedUsers) - } else { - text(TextKey::ConfirmDisconnectSelectedUsers) - }; + // 确认框留在 UI 线程。断开和注销的 WTS 调用留在 worker。 + let kind = if command_id == IDM_LOGOFF { + UserSessionKind::Logoff + } else { + UserSessionKind::Disconnect + }; + let Some(selected) = self.verified_selection(kind) else { + return; + }; + let prompt = if kind == UserSessionKind::Logoff { + text(TextKey::ConfirmLogoffSelectedUsers) + } else { + text(TextKey::ConfirmDisconnectSelectedUsers) + }; + // 安全性: both strings are NUL-terminated and live for the modal confirmation. + let confirmed = unsafe { let prompt_wide = to_wide_null(prompt); let caption_wide = to_wide_null(text(TextKey::AppTitle)); - if MessageBoxW( + MessageBoxW( self.hwnd, prompt_wide.as_ptr(), caption_wide.as_ptr(), MB_YESNO | MB_DEFBUTTON2 | MB_ICONEXCLAMATION, - ) != IDYES - { - return; - } - - let failure_message = if command_id == IDM_LOGOFF { - text(TextKey::SelectedUserCouldNotBeLoggedOff) - } else { - text(TextKey::SelectedUserCouldNotBeDisconnected) - }; - if let Err(error) = validate_session_identities(&selected) { - self.show_command_failure_with_error(failure_message, error); - self.refresh(); - return; - } - - for identity in selected { - if let Err(error) = validate_session_identity(&identity) { - self.show_command_failure_with_error(failure_message, error); - break; - } - let succeeded = if command_id == IDM_LOGOFF { - WTSLogoffSession(WTS_CURRENT_SERVER_HANDLE, identity.session_id, 0) != 0 - } else { - WTSDisconnectSession(WTS_CURRENT_SERVER_HANDLE, identity.session_id, 0) != 0 - }; - if !succeeded { - self.show_command_failure(failure_message); - break; - } - } + ) == IDYES + }; + if !confirmed { + return; + } + self.submit_session_command(UserSessionCommand { + kind, + sessions: selected, + title: String::new(), + body: String::new(), + }); + } - self.refresh(); + fn verified_selection(&self, kind: UserSessionKind) -> Option> { + let selected = self.selected_session_identities(); + if selected.is_empty() { + return None; + } + if selected.iter().all(UserSessionIdentity::is_verified) { + Some(selected) + } else { + self.show_action_error(kind, ERROR_INVALID_PARAMETER); + None } } - fn show_command_failure(&self, message: &str) { - // 安全性: this function is a safe facade over Win32/FFI work; all callers run it on the owning UI thread and the existing body preserves its original handle/pointer invariants. - unsafe { - // 统一附带最后一个 Win32 错误码,方便排查权限或会话状态问题。 - self.show_command_failure_with_error(message, GetLastError()); + fn submit_session_command(&mut self, command: UserSessionCommand) { + let kind = command.kind; + if let Err(error) = self.submit_request(UserWorkerRequest::Actions(vec![command])) { + self.show_action_error(kind, error); } } + fn show_action_error(&self, kind: UserSessionKind, error: u32) { + let message = match kind { + UserSessionKind::Disconnect => text(TextKey::SelectedUserCouldNotBeDisconnected), + UserSessionKind::Logoff => text(TextKey::SelectedUserCouldNotBeLoggedOff), + UserSessionKind::SendMessage => text(TextKey::MessageCouldNotBeSent), + }; + self.show_command_failure_with_error(message, error); + } + fn show_command_failure_with_error(&self, message: &str, error: u32) { // 安全性: the strings are converted to stable, null-terminated UTF-16 buffers for the // duration of the synchronous MessageBoxW call. @@ -996,6 +984,68 @@ impl UserPageState { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum UserSessionKind { + Disconnect, + Logoff, + SendMessage, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct UserSessionCommand { + kind: UserSessionKind, + sessions: Vec, + title: String, + body: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum UserWorkerRequest { + Refresh, + Actions(Vec), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct UserActionReport { + kind: UserSessionKind, + error: Option, +} + +enum UserWorkerCompletion { + Snapshot(UserWorkerResult), + Actions(Vec), +} + +/// Keeps a confirmed session action ahead of a coalesced refresh, and queues a later action +/// behind one that has not started. +fn merge_user_requests(pending: &mut UserWorkerRequest, incoming: UserWorkerRequest) { + match (&mut *pending, incoming) { + (UserWorkerRequest::Actions(queued), UserWorkerRequest::Actions(more)) => { + queued.extend(more); + } + (UserWorkerRequest::Actions(_), UserWorkerRequest::Refresh) => {} + (pending, incoming) => *pending = incoming, + } +} + +fn perform_user_request(request: UserWorkerRequest) -> UserWorkerCompletion { + match request { + UserWorkerRequest::Refresh => UserWorkerCompletion::Snapshot(collect_user_sessions()), + UserWorkerRequest::Actions(commands) => UserWorkerCompletion::Actions( + commands + .into_iter() + .map(|command| { + let kind = command.kind; + UserActionReport { + kind, + error: execute_session_command(&command).err(), + } + }) + .collect(), + ), + } +} + fn collect_user_sessions() -> UserWorkerResult { // Enumeration defines the candidate session set. Per-session fields are independent: a // client-name or extended-info failure must not hide another session's verified user name. @@ -1157,15 +1207,107 @@ fn format_session_display_name(identity: &UserSessionIdentity, show_domain_names } } -fn validate_session_identity(expected: &UserSessionIdentity) -> Result<(), u32> { - let info = query_session_info(expected.session_id)?; - let current = UserSessionIdentity { - session_id: expected.session_id, +fn execute_session_command(command: &UserSessionCommand) -> Result<(), u32> { + if command.sessions.is_empty() { + return Err(ERROR_INVALID_PARAMETER); + } + let message = if command.kind == UserSessionKind::SendMessage { + Some(prepare_session_message(&command.title, &command.body)?) + } else { + None + }; + for identity in &command.sessions { + // Re-query immediately before the destructive call. A reused session id, a missing + // logon time, or a different user or domain is a failure, not success. + let current = observed_session_identity(identity.session_id)?; + run_session_action_if_verified(identity, ¤t, || { + invoke_session_action(command.kind, identity.session_id, message.as_ref()) + })?; + } + Ok(()) +} + +fn prepare_session_message(title: &str, body: &str) -> Result { + Ok(PreparedSessionMessage { + title: to_wide_null(title), + body: to_wide_null(body), + title_bytes: wide_byte_length(title)?, + body_bytes: wide_byte_length(body)?, + }) +} + +struct PreparedSessionMessage { + title: Vec, + body: Vec, + title_bytes: u32, + body_bytes: u32, +} + +fn wide_byte_length(value: &str) -> Result { + let units = u32::try_from(value.encode_utf16().count()).map_err(|_| ERROR_INVALID_PARAMETER)?; + units.checked_mul(2).ok_or(ERROR_INVALID_PARAMETER) +} + +fn observed_session_identity(session_id: u32) -> Result { + let info = query_session_info(session_id)?; + Ok(UserSessionIdentity { + session_id, logon_time_100ns: info.LogonTime, - user_name: query_session_string(expected.session_id, WTSUserName)?, - domain_name: query_session_string(expected.session_id, WTSDomainName)?, + user_name: query_session_string(session_id, WTSUserName)?, + domain_name: query_session_string(session_id, WTSDomainName)?, + }) +} + +fn run_session_action_if_verified( + expected: &UserSessionIdentity, + current: &UserSessionIdentity, + invoke: impl FnOnce() -> Result<(), u32>, +) -> Result<(), u32> { + validate_observed_session_identity(expected, current)?; + invoke() +} + +fn invoke_session_action( + kind: UserSessionKind, + session_id: u32, + message: Option<&PreparedSessionMessage>, +) -> Result<(), u32> { + // 安全性: WTS_CURRENT_SERVER_HANDLE is the documented local-server sentinel. Message buffers, + // when present, outlive this synchronous call and include a NUL terminator. GetLastError is + // read before any other Win32 call. + let succeeded = match kind { + UserSessionKind::Disconnect => unsafe { + WTSDisconnectSession(WTS_CURRENT_SERVER_HANDLE, session_id, 0) + }, + UserSessionKind::Logoff => unsafe { + WTSLogoffSession(WTS_CURRENT_SERVER_HANDLE, session_id, 0) + }, + UserSessionKind::SendMessage => { + let Some(message) = message else { + return Err(ERROR_INVALID_PARAMETER); + }; + let mut response = 0i32; + unsafe { + WTSSendMessageW( + WTS_CURRENT_SERVER_HANDLE, + session_id, + message.title.as_ptr(), + message.title_bytes, + message.body.as_ptr(), + message.body_bytes, + MB_OK | MB_TOPMOST | MB_ICONINFORMATION, + 0, + &mut response, + 0, + ) + } + } }; - validate_observed_session_identity(expected, ¤t) + if succeeded != 0 { + Ok(()) + } else { + Err(win32_error_or_gen_failure(unsafe { GetLastError() })) + } } fn validate_observed_session_identity( @@ -1179,13 +1321,6 @@ fn validate_observed_session_identity( } } -fn validate_session_identities(expected: &[UserSessionIdentity]) -> Result<(), u32> { - for identity in expected { - validate_session_identity(identity)?; - } - Ok(()) -} - fn win32_error_or_gen_failure(error: u32) -> u32 { if error == 0 { ERROR_GEN_FAILURE } else { error } } @@ -1446,6 +1581,98 @@ mod tests { assert_eq!(state.last_refresh_error, Some(1722)); } + #[test] + fn user_or_domain_change_is_not_the_same_identity() { + let expected = identity(1, 100); + let mut renamed = expected.clone(); + renamed.user_name = "Other".to_string(); + assert_eq!( + validate_observed_session_identity(&expected, &renamed), + Err(ERROR_INVALID_PARAMETER) + ); + + let mut other_domain = expected.clone(); + other_domain.domain_name = "OTHER".to_string(); + assert_eq!( + validate_observed_session_identity(&expected, &other_domain), + Err(ERROR_INVALID_PARAMETER) + ); + } + + #[test] + fn mismatched_identity_does_not_run_the_session_action() { + let mut called = false; + let result = run_session_action_if_verified(&identity(1, 100), &identity(1, 200), || { + called = true; + Ok(()) + }); + assert_eq!(result, Err(ERROR_INVALID_PARAMETER)); + assert!(!called); + } + + #[test] + fn verified_identity_runs_the_session_action() { + let expected = identity(1, 100); + let mut called = false; + let result = run_session_action_if_verified(&expected, &expected, || { + called = true; + Ok(()) + }); + assert_eq!(result, Ok(())); + assert!(called); + } + + #[test] + fn empty_session_action_is_not_success() { + let command = UserSessionCommand { + kind: UserSessionKind::Disconnect, + sessions: Vec::new(), + title: String::new(), + body: String::new(), + }; + assert_eq!( + execute_session_command(&command), + Err(ERROR_INVALID_PARAMETER) + ); + } + + #[test] + fn pending_session_action_survives_a_coalesced_refresh() { + let action = UserWorkerRequest::Actions(vec![session_command(UserSessionKind::Disconnect)]); + let mut pending = action.clone(); + merge_user_requests(&mut pending, UserWorkerRequest::Refresh); + assert_eq!(pending, action); + } + + #[test] + fn session_action_replaces_a_pending_refresh() { + let action = UserWorkerRequest::Actions(vec![session_command(UserSessionKind::Logoff)]); + let mut pending = UserWorkerRequest::Refresh; + merge_user_requests(&mut pending, action.clone()); + assert_eq!(pending, action); + } + + #[test] + fn a_second_session_action_stays_queued() { + let first = session_command(UserSessionKind::Disconnect); + let second = session_command(UserSessionKind::SendMessage); + let mut pending = UserWorkerRequest::Actions(vec![first.clone()]); + merge_user_requests( + &mut pending, + UserWorkerRequest::Actions(vec![second.clone()]), + ); + assert_eq!(pending, UserWorkerRequest::Actions(vec![first, second])); + } + + fn session_command(kind: UserSessionKind) -> UserSessionCommand { + UserSessionCommand { + kind, + sessions: vec![identity(1, 100)], + title: String::new(), + body: String::new(), + } + } + #[test] fn domain_visibility_only_changes_display_name() { let identity = identity(1, 100); diff --git a/src/ui/localization/mod.rs b/src/ui/localization/mod.rs index ed79c03..4bca0fb 100644 --- a/src/ui/localization/mod.rs +++ b/src/ui/localization/mod.rs @@ -82,5 +82,12 @@ pub fn text(key: TextKey) -> &'static str { } pub fn menu_status_help(command_id: u16) -> Option<&'static str> { - generated_menu_status_help(current_language(), command_id) + // Users-page session commands publish status text through TextKey. The generated + // command table covers the other menu items. + match command_id { + crate::ui::resource_ids::IDM_DISCONNECT => Some(text(TextKey::DisconnectStatusHelp)), + crate::ui::resource_ids::IDM_LOGOFF => Some(text(TextKey::LogoffStatusHelp)), + crate::ui::resource_ids::IDM_SENDMESSAGE => Some(text(TextKey::SendMessageStatusHelp)), + _ => generated_menu_status_help(current_language(), command_id), + } } diff --git a/src/ui/localization/text_key.rs b/src/ui/localization/text_key.rs index c6ef46f..8295e08 100644 --- a/src/ui/localization/text_key.rs +++ b/src/ui/localization/text_key.rs @@ -328,4 +328,7 @@ pub enum TextKey { ServiceStopFailed, ServiceStateUnchanged, ServiceProcessPid, + DisconnectStatusHelp, + LogoffStatusHelp, + SendMessageStatusHelp, } From f6263ad3d658d1631f321eb9d77d9594705b6a63 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 20 Oct 2026 03:43:54 +0000 Subject: [PATCH 2/2] fix: do not report an exiting process as a failed tree kill TerminateProcess fails once a process has started exiting, and a zero timeout can miss the process object becoming signaled. Wait briefly after that failure so a descendant that exits on its own is not reported as a partial failure. Co-authored-by: jameslincn --- src/pages/processes/actions.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/pages/processes/actions.rs b/src/pages/processes/actions.rs index 4f8c72e..5af7d64 100644 --- a/src/pages/processes/actions.rs +++ b/src/pages/processes/actions.rs @@ -87,6 +87,11 @@ struct AffinityDialogContext { const PROCESS_TREE_ACCESS: u32 = PROCESS_TERMINATE | PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE; +// `TerminateProcess` fails once a process has begun to exit, and the process object may not be +// signaled in that same instant. This bound only applies after termination has already failed. +// The wait returns as soon as the process exits. +const EXITED_PROCESS_CONFIRM_MS: u32 = 250; + #[derive(Debug, PartialEq, Eq)] pub(super) enum DescendantProcessOutcome { Verified(T), @@ -1593,8 +1598,11 @@ pub(super) fn terminate_prepared_process_tree( } // Capture TerminateProcess's error before WaitForSingleObject can change last-error. + // A zero timeout treats a process that is already inside exit as a failure. let error = nonzero_last_error(); - let wait_result = unsafe { WaitForSingleObject(handle.as_raw(), 0) }; + // SAFETY: `handle` was opened with SYNCHRONIZE and is owned for this call. + let wait_result = + unsafe { WaitForSingleObject(handle.as_raw(), EXITED_PROCESS_CONFIRM_MS) }; match classify_failed_termination(error, wait_result) { FailedTerminationOutcome::AlreadyTerminated => { outcome.any_completed = true;