diff --git a/.github/workflows/cli-package-validation.yml b/.github/workflows/cli-package-validation.yml index a11d653674..d449063ea0 100644 --- a/.github/workflows/cli-package-validation.yml +++ b/.github/workflows/cli-package-validation.yml @@ -29,8 +29,10 @@ on: - 'packages/cli/RUNTIME_HOST_PEER_*' - 'packages/cli/src/cli-core.ts' - 'packages/cli/src/runtime-host-cli.ts' + - 'packages/cli/src/runtime-host-package-deployment.ts' - 'packages/cli/src/runtime-host-peer-*' - 'packages/cli/src/runtime-host-service-*' + - 'packages/cli/src/runtime-host-windows-*' - 'packages/runtime-host/package.json' - 'packages/runtime-host/src/client/peer-client.ts' - 'packages/runtime-host/src/peer-mesh/**' diff --git a/native/runtime-host-peer/Cargo.lock b/native/runtime-host-peer/Cargo.lock index 254be6e9b2..9b5ee8a54e 100644 --- a/native/runtime-host-peer/Cargo.lock +++ b/native/runtime-host-peer/Cargo.lock @@ -1950,6 +1950,7 @@ dependencies = [ "tokio-util", "unsigned-varint 0.8.0", "webrtc", + "windows", ] [[package]] diff --git a/native/runtime-host-peer/Cargo.toml b/native/runtime-host-peer/Cargo.toml index 711ee2eeaa..993431e5c0 100644 --- a/native/runtime-host-peer/Cargo.toml +++ b/native/runtime-host-peer/Cargo.toml @@ -58,6 +58,19 @@ unsigned-varint = "0.8" # Keep this immutable until an official release contains those fixes. webrtc = { git = "https://github.com/webrtc-rs/webrtc", rev = "e132552fc67b84c30e63c5ce916a9a63e2484b6f" } +[target.'cfg(windows)'.dependencies] +windows = { version = "0.62.2", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_System_Com", + "Win32_System_Diagnostics_ToolHelp", + "Win32_System_JobObjects", + "Win32_System_Ole", + "Win32_System_TaskScheduler", + "Win32_System_Threading", + "Win32_System_Variant", +] } + [build-dependencies] napi-build = "2.4" diff --git a/native/runtime-host-peer/src/lib.rs b/native/runtime-host-peer/src/lib.rs index 67bb82a3dc..95ddec137b 100644 --- a/native/runtime-host-peer/src/lib.rs +++ b/native/runtime-host-peer/src/lib.rs @@ -20,6 +20,8 @@ mod bindings; mod engine; mod webrtc_direct; +#[cfg(target_os = "windows")] +mod windows_lifecycle; pub use bindings::{ ConfigurePeerTransitOptions, ConnectPeerOptions, PeerConnectivitySnapshot, PeerEndpoint, @@ -27,3 +29,9 @@ pub use bindings::{ PeerTransitSnapshot, StartPeerEndpointOptions, ensure_peer_identity, sign_peer_identity, start_peer_endpoint, verify_peer_identity, }; +#[cfg(target_os = "windows")] +pub use windows_lifecycle::{ + WindowsTaskStatus, own_current_process_tree, windows_task_activate, windows_task_converge, + windows_task_probe, windows_task_retire, windows_task_status, windows_task_uninstall, + windows_task_verify, +}; diff --git a/native/runtime-host-peer/src/windows_lifecycle.rs b/native/runtime-host-peer/src/windows_lifecycle.rs new file mode 100644 index 0000000000..7d4e0995cf --- /dev/null +++ b/native/runtime-host-peer/src/windows_lifecycle.rs @@ -0,0 +1,946 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +use std::{ + collections::HashSet, + mem::size_of, + os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}, + path::Path, + sync::{Mutex, OnceLock}, + time::{Duration, Instant}, +}; + +use napi::bindgen_prelude::{Error as NapiError, Result, Status}; +use napi_derive::napi; +use windows::{ + Win32::{ + Foundation::{HANDLE, RPC_E_CHANGED_MODE, VARIANT_FALSE, VARIANT_TRUE}, + System::{ + Com::{ + CLSCTX_INPROC_SERVER, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx, + CoUninitialize, + }, + Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, + TH32CS_SNAPPROCESS, + }, + JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, + SetInformationJobObject, + }, + TaskScheduler::{ + IDailyTrigger, IExecAction, ILogonTrigger, IRegisteredTask, IRegistrationTrigger, + ITaskFolder, ITaskService, TASK_ACTION_EXEC, TASK_CREATE_OR_UPDATE, + TASK_INSTANCES_IGNORE_NEW, TASK_LOGON_INTERACTIVE_TOKEN, TASK_RUNLEVEL_LUA, + TASK_STATE_DISABLED, TASK_STATE_QUEUED, TASK_STATE_READY, TASK_STATE_RUNNING, + TASK_TRIGGER_DAILY, TASK_TRIGGER_LOGON, TASK_TRIGGER_REGISTRATION, TaskScheduler, + }, + Threading::GetCurrentProcess, + Variant::VARIANT, + }, + }, + core::{BSTR, Interface, PCWSTR}, +}; + +const ROOT_ID_BYTES: usize = 64; +const MAX_COMMAND_ARGUMENTS: usize = 64; +const MAX_ARGUMENT_BYTES: usize = 4 * 1024; +const STOP_TIMEOUT: Duration = Duration::from_secs(30); + +static PROCESS_JOB: OnceLock>> = OnceLock::new(); + +#[derive(Clone, Copy)] +enum Target { + Host, + Reconciliation, +} + +#[napi(object)] +pub struct WindowsTaskStatus { + pub installed: bool, + pub enabled: bool, + pub state: String, + pub pid: Option, + pub last_exit_code: Option, +} + +#[napi] +pub fn windows_task_probe() -> Result<()> { + scheduler().map(|_| ()).map_err(native_error) +} + +#[napi] +pub fn windows_task_converge( + root_id: String, + target: String, + runner_path: String, + command: Vec, +) -> Result<()> { + let target = require_target(&root_id, &target)?; + validate_command(&command).map_err(|_| invalid("Windows lifecycle command is invalid"))?; + converge_task( + &scheduler().map_err(native_error)?, + &root_id, + target, + &runner_path, + &command, + ) + .map_err(native_error) +} + +#[napi] +pub fn windows_task_verify( + root_id: String, + target: String, + runner_path: String, + command: Vec, +) -> Result<()> { + let target = require_target(&root_id, &target)?; + validate_command(&command).map_err(|_| invalid("Windows lifecycle command is invalid"))?; + let context = scheduler().map_err(native_error)?; + let name = task_name(&root_id, target); + let task = + required_owned_task(&context.folder, &name, &root_id, target).map_err(native_error)?; + verify_registered_definition(&task, target, &runner_path, &command, &context.user) + .map_err(native_error) +} + +#[napi] +pub fn windows_task_status(root_id: String, target: String) -> Result { + let target = require_target(&root_id, &target)?; + let context = scheduler().map_err(native_error)?; + let name = task_name(&root_id, target); + read_status( + owned_task(&context.folder, &name, &root_id, target).map_err(native_error)?, + target, + ) + .map_err(native_error) +} + +#[napi] +pub fn windows_task_activate(root_id: String) -> Result<()> { + require_root_id(&root_id)?; + let target = Target::Host; + let context = scheduler().map_err(native_error)?; + let name = task_name(&root_id, target); + let task = + required_owned_task(&context.folder, &name, &root_id, target).map_err(native_error)?; + // SAFETY: task is a live thread-local COM interface and Run copies the empty argument. + unsafe { + if task.State().map_err(native_error)? != TASK_STATE_RUNNING { + task.Run(&VARIANT::default()).map_err(native_error)?; + } + } + Ok(()) +} + +#[napi] +pub fn windows_task_retire(root_id: String) -> Result<()> { + require_root_id(&root_id)?; + let target = Target::Host; + let context = scheduler().map_err(native_error)?; + let name = task_name(&root_id, target); + if let Some(task) = + owned_task(&context.folder, &name, &root_id, target).map_err(native_error)? + { + stop_task(&task).map_err(native_error)?; + } + Ok(()) +} + +#[napi] +pub fn windows_task_uninstall(root_id: String, target: String) -> Result<()> { + let target = require_target(&root_id, &target)?; + let context = scheduler().map_err(native_error)?; + let name = task_name(&root_id, target); + if let Some(task) = + owned_task(&context.folder, &name, &root_id, target).map_err(native_error)? + { + stop_task(&task).map_err(native_error)?; + // SAFETY: folder is a live thread-local COM interface and DeleteTask copies the BSTR. + unsafe { + context + .folder + .DeleteTask(&BSTR::from(&name), 0) + .map_err(native_error)?; + } + } + Ok(()) +} + +#[napi] +pub fn own_current_process_tree() -> Result<()> { + let slot = PROCESS_JOB.get_or_init(|| Mutex::new(None)); + let mut guard = slot + .lock() + .map_err(|_| NapiError::new(Status::GenericFailure, "Windows process job is poisoned"))?; + if guard.is_some() { + return Ok(()); + } + // SAFETY: the owned handle remains live in PROCESS_JOB until process exit. The initialized + // structure and information class have matching layouts, and GetCurrentProcess is valid here. + unsafe { + let created = CreateJobObjectW(None, PCWSTR::null()).map_err(native_error)?; + let owned = OwnedHandle::from_raw_handle(created.0); + let handle = HANDLE(owned.as_raw_handle()); + let mut information = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + information.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + SetInformationJobObject( + handle, + JobObjectExtendedLimitInformation, + (&raw const information).cast(), + size_of::() as u32, + ) + .map_err(native_error)?; + AssignProcessToJobObject(handle, GetCurrentProcess()).map_err(native_error)?; + *guard = Some(owned); + } + Ok(()) +} + +struct Scheduler { + service: ITaskService, + folder: ITaskFolder, + user: String, + _apartment: ComApartment, +} + +fn scheduler() -> windows::core::Result { + let apartment = ComApartment::initialize()?; + // SAFETY: COM is initialized for this thread and all interfaces remain thread-local. + unsafe { + let service: ITaskService = CoCreateInstance(&TaskScheduler, None, CLSCTX_INPROC_SERVER)?; + let empty = VARIANT::default(); + service.Connect(&empty, &empty, &empty, &empty)?; + let folder = service.GetFolder(&BSTR::from("\\"))?; + let user = service.ConnectedUser()?.to_string(); + Ok(Scheduler { + service, + folder, + user, + _apartment: apartment, + }) + } +} + +fn converge_task( + context: &Scheduler, + root_id: &str, + target: Target, + runner_path: &str, + command: &[String], +) -> windows::core::Result<()> { + let name = task_name(root_id, target); + if let Some(task) = owned_task(&context.folder, &name, root_id, target)? + && matches!(target, Target::Host) + { + stop_task(&task)?; + } + let desired = normalized_definition( + &context.service, + root_id, + target, + runner_path, + command, + &context.user, + )?; + let empty = VARIANT::default(); + let user_id = VARIANT::from(context.user.as_str()); + // SAFETY: all COM interfaces are live on this thread and registration copies its arguments. + unsafe { + context.folder.RegisterTaskDefinition( + &BSTR::from(&name), + &desired, + TASK_CREATE_OR_UPDATE.0, + &user_id, + &empty, + TASK_LOGON_INTERACTIVE_TOKEN, + &empty, + )?; + } + Ok(()) +} + +fn normalized_definition( + service: &ITaskService, + root_id: &str, + target: Target, + runner_path: &str, + command: &[String], + user: &str, +) -> windows::core::Result { + // SAFETY: service is a live thread-local COM interface and SetXmlText copies the BSTR. + unsafe { + let definition = service.NewTask(0)?; + definition.SetXmlText(&BSTR::from(render_task_xml( + root_id, + target, + runner_path, + command, + user, + )?))?; + Ok(definition) + } +} + +fn owned_task( + folder: &ITaskFolder, + name: &str, + root_id: &str, + target: Target, +) -> windows::core::Result> { + // SAFETY: folder is a live thread-local COM interface and GetTask copies the BSTR. + unsafe { + match folder.GetTask(&BSTR::from(name)) { + Ok(task) => { + assert_owned(&task, root_id, target)?; + Ok(Some(task)) + } + Err(error) if error.code().0 as u32 == 0x80070002 => Ok(None), + Err(error) => Err(error), + } + } +} + +fn verify_registered_definition( + task: &IRegisteredTask, + target: Target, + runner_path: &str, + command: &[String], + user: &str, +) -> windows::core::Result<()> { + // SAFETY: every interface is obtained from this thread's live registered-task definition; + // all out pointers refer to initialized local values for the duration of each call. + unsafe { + let expected_command = task_action_command(target, runner_path, command)?; + let definition = task.Definition()?; + + let actions = definition.Actions()?; + let mut action_count = 0; + actions.Count(&mut action_count)?; + let action = actions.get_Item(1)?; + let mut action_type = TASK_ACTION_EXEC; + action.Type(&mut action_type)?; + let executable: IExecAction = action.cast()?; + let mut path = BSTR::new(); + let mut arguments = BSTR::new(); + executable.Path(&mut path)?; + executable.Arguments(&mut arguments)?; + + let principal = definition.Principal()?; + let mut principal_user = BSTR::new(); + let mut logon_type = TASK_LOGON_INTERACTIVE_TOKEN; + let mut run_level = TASK_RUNLEVEL_LUA; + principal.UserId(&mut principal_user)?; + principal.LogonType(&mut logon_type)?; + principal.RunLevel(&mut run_level)?; + + let settings = definition.Settings()?; + let mut instances = TASK_INSTANCES_IGNORE_NEW; + let mut allow_demand = VARIANT_FALSE; + let mut allow_hard_terminate = VARIANT_FALSE; + let mut disallow_battery_start = VARIANT_TRUE; + let mut enabled = VARIANT_FALSE; + let mut network_required = VARIANT_TRUE; + let mut start_when_available = VARIANT_FALSE; + let mut stop_on_battery = VARIANT_TRUE; + let mut execution_limit = BSTR::new(); + let mut restart_interval = BSTR::new(); + let mut restart_count = 0; + settings.MultipleInstances(&mut instances)?; + settings.AllowDemandStart(&mut allow_demand)?; + settings.AllowHardTerminate(&mut allow_hard_terminate)?; + settings.DisallowStartIfOnBatteries(&mut disallow_battery_start)?; + settings.Enabled(&mut enabled)?; + settings.ExecutionTimeLimit(&mut execution_limit)?; + settings.RunOnlyIfNetworkAvailable(&mut network_required)?; + settings.StartWhenAvailable(&mut start_when_available)?; + settings.StopIfGoingOnBatteries(&mut stop_on_battery)?; + settings.RestartInterval(&mut restart_interval)?; + settings.RestartCount(&mut restart_count)?; + + let triggers = definition.Triggers()?; + let mut trigger_count = 0; + triggers.Count(&mut trigger_count)?; + let triggers_match = match target { + Target::Host if trigger_count == 1 => { + let trigger = triggers.get_Item(1)?; + let mut trigger_type = TASK_TRIGGER_LOGON; + let mut trigger_enabled = VARIANT_FALSE; + trigger.Type(&mut trigger_type)?; + trigger.Enabled(&mut trigger_enabled)?; + let logon: ILogonTrigger = trigger.cast()?; + let mut trigger_user = BSTR::new(); + logon.UserId(&mut trigger_user)?; + trigger_type == TASK_TRIGGER_LOGON + && trigger_enabled == VARIANT_TRUE + && same_windows_user(&trigger_user.to_string(), user) + } + Target::Reconciliation if trigger_count == 2 => { + let registration = triggers.get_Item(1)?; + let daily = triggers.get_Item(2)?; + let mut registration_type = TASK_TRIGGER_REGISTRATION; + let mut daily_type = TASK_TRIGGER_DAILY; + let mut registration_enabled = VARIANT_FALSE; + let mut daily_enabled = VARIANT_FALSE; + registration.Type(&mut registration_type)?; + registration.Enabled(&mut registration_enabled)?; + daily.Type(&mut daily_type)?; + daily.Enabled(&mut daily_enabled)?; + let registration: IRegistrationTrigger = registration.cast()?; + let daily: IDailyTrigger = daily.cast()?; + let mut delay = BSTR::new(); + let mut random_delay = BSTR::new(); + let mut start_boundary = BSTR::new(); + let mut days = 0; + registration.Delay(&mut delay)?; + daily.RandomDelay(&mut random_delay)?; + daily.StartBoundary(&mut start_boundary)?; + daily.DaysInterval(&mut days)?; + registration_type == TASK_TRIGGER_REGISTRATION + && daily_type == TASK_TRIGGER_DAILY + && registration_enabled == VARIANT_TRUE + && daily_enabled == VARIANT_TRUE + && delay == "PT15M" + && random_delay == "PT1H" + && start_boundary == "2000-01-01T03:00:00" + && days == 1 + } + _ => false, + }; + + if action_count != 1 + || action_type != TASK_ACTION_EXEC + || path != expected_command[0] + || arguments != command_line(&expected_command[1..]) + || !same_windows_user(&principal_user.to_string(), user) + || logon_type != TASK_LOGON_INTERACTIVE_TOKEN + || run_level != TASK_RUNLEVEL_LUA + || instances != TASK_INSTANCES_IGNORE_NEW + || allow_demand != VARIANT_TRUE + || allow_hard_terminate != VARIANT_TRUE + || disallow_battery_start != VARIANT_FALSE + || enabled != VARIANT_TRUE + || execution_limit != "PT0S" + || network_required != VARIANT_FALSE + || start_when_available != VARIANT_TRUE + || stop_on_battery != VARIANT_FALSE + || !restart_interval.to_string().is_empty() + || restart_count != 0 + || !triggers_match + { + return Err(invalid_task_definition()); + } + } + Ok(()) +} + +fn same_windows_user(observed: &str, connected: &str) -> bool { + let observed = observed + .rsplit_once('\\') + .map_or(observed, |(_, user)| user); + let connected = connected + .rsplit_once('\\') + .map_or(connected, |(_, user)| user); + !observed.is_empty() && observed.eq_ignore_ascii_case(connected) +} + +fn invalid_task_definition() -> windows::core::Error { + windows::core::Error::new( + windows::core::HRESULT(0x8007000D_u32 as i32), + "The Windows scheduled task does not match its managed deployment", + ) +} + +fn required_owned_task( + folder: &ITaskFolder, + name: &str, + root_id: &str, + target: Target, +) -> windows::core::Result { + owned_task(folder, name, root_id, target)?.ok_or_else(|| { + windows::core::Error::new( + windows::core::HRESULT(0x80070002_u32 as i32), + "The Windows scheduled task is not installed", + ) + }) +} + +fn assert_owned( + task: &IRegisteredTask, + root_id: &str, + target: Target, +) -> windows::core::Result<()> { + // SAFETY: every interface is obtained from this thread's live registered task. + let description = unsafe { + let definition = task.Definition()?; + let registration = definition.RegistrationInfo()?; + let mut description = BSTR::new(); + registration.Description(&mut description)?; + description + }; + if description != ownership_marker(root_id, target) { + return Err(windows::core::Error::new( + windows::core::HRESULT(0x80070005_u32 as i32), + "Refusing to modify a scheduled task not owned by Maka", + )); + } + Ok(()) +} + +fn read_status( + task: Option, + target: Target, +) -> windows::core::Result { + let Some(task) = task else { + return Ok(WindowsTaskStatus { + installed: false, + enabled: false, + state: "not_installed".to_owned(), + pid: None, + last_exit_code: None, + }); + }; + // SAFETY: task and instances are live thread-local COM interfaces. + let (state, instances) = unsafe { (task.State()?, task.GetInstances(0)?) }; + let count = unsafe { instances.Count()? }; + if count > 1 { + return Err(windows::core::Error::new( + windows::core::HRESULT(0x8007000D_u32 as i32), + "The Windows scheduled task has multiple running instances", + )); + } + let engine_pid = if count == 1 { + Some(unsafe { instances.get_Item(&VARIANT::from(1_i32))?.EnginePID()? }) + } else { + None + }; + let pid = match (target, engine_pid) { + (Target::Host, Some(wrapper_pid)) => direct_child_pid(wrapper_pid)?, + (_, pid) => pid, + }; + let enabled = unsafe { task.Enabled()? } != VARIANT_FALSE; + Ok(WindowsTaskStatus { + installed: true, + enabled, + state: if state == TASK_STATE_RUNNING && matches!(target, Target::Host) && pid.is_none() { + "starting" + } else if state == TASK_STATE_RUNNING { + "running" + } else if state == TASK_STATE_QUEUED { + "starting" + } else if state == TASK_STATE_READY || state == TASK_STATE_DISABLED { + "stopped" + } else { + "failed" + } + .to_owned(), + pid, + last_exit_code: Some(unsafe { task.LastTaskResult()? } as u32), + }) +} + +fn direct_child_pid(parent_pid: u32) -> windows::core::Result> { + direct_child_pid_in_snapshot(parent_pid, &process_snapshot()?) +} + +fn process_snapshot() -> windows::core::Result> { + // SAFETY: the snapshot handle is converted immediately to OwnedHandle, and the initialized + // PROCESSENTRY32W layout matches the ToolHelp API contract. + unsafe { + let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)?; + let snapshot = OwnedHandle::from_raw_handle(snapshot.0); + let handle = HANDLE(snapshot.as_raw_handle()); + let mut entry = PROCESSENTRY32W { + dwSize: size_of::() as u32, + ..Default::default() + }; + Process32FirstW(handle, &raw mut entry)?; + let mut processes = Vec::new(); + loop { + processes.push(entry); + match Process32NextW(handle, &raw mut entry) { + Ok(()) => {} + Err(error) if error.code().0 as u32 == 0x80070012 => break, + Err(error) => return Err(error), + } + } + Ok(processes) + } +} + +fn direct_child_pid_in_snapshot( + parent_pid: u32, + processes: &[PROCESSENTRY32W], +) -> windows::core::Result> { + let parent_name = processes + .iter() + .find(|process| process.th32ProcessID == parent_pid) + .map(|process| process_name(&process.szExeFile)) + .ok_or_else(|| { + windows::core::Error::new( + windows::core::HRESULT(0x80070002_u32 as i32), + "The Windows Runtime Host supervisor process is not available", + ) + })?; + let mut child = None; + for process in processes { + if process.th32ParentProcessID == parent_pid + && process_name(&process.szExeFile).eq_ignore_ascii_case(&parent_name) + && child.replace(process.th32ProcessID).is_some() + { + return Err(windows::core::Error::new( + windows::core::HRESULT(0x8007000D_u32 as i32), + "The Windows Runtime Host supervisor has multiple direct children", + )); + } + } + Ok(child) +} + +fn process_name(value: &[u16]) -> String { + String::from_utf16_lossy( + &value[..value + .iter() + .position(|part| *part == 0) + .unwrap_or(value.len())], + ) +} + +fn wait_until_task_stopped(task: &IRegisteredTask) -> windows::core::Result<()> { + let deadline = Instant::now() + STOP_TIMEOUT; + while Instant::now() < deadline { + // SAFETY: task and the returned collection are live thread-local COM interfaces. + if unsafe { task.GetInstances(0)?.Count()? } == 0 { + return Ok(()); + } + std::thread::sleep(Duration::from_millis(50)); + } + Err(windows::core::Error::new( + windows::core::HRESULT(0x800705B4_u32 as i32), + "The Windows scheduled task did not stop", + )) +} + +fn task_owned_process_ids(task: &IRegisteredTask) -> windows::core::Result> { + // SAFETY: task, instances, and the returned collection are live thread-local COM interfaces. + let instances = unsafe { task.GetInstances(0)? }; + let count = unsafe { instances.Count()? }; + let processes = process_snapshot()?; + let mut owned = HashSet::new(); + for index in 1..=count { + let wrapper_pid = unsafe { instances.get_Item(&VARIANT::from(index))?.EnginePID()? }; + if let Some(host_pid) = direct_child_pid_in_snapshot(wrapper_pid, &processes)? { + owned.insert(host_pid); + } + } + loop { + let before = owned.len(); + for process in &processes { + if owned.contains(&process.th32ParentProcessID) { + owned.insert(process.th32ProcessID); + } + } + if owned.len() == before { + return Ok(owned); + } + } +} + +fn wait_until_processes_exit(process_ids: &HashSet) -> windows::core::Result<()> { + let deadline = Instant::now() + STOP_TIMEOUT; + while Instant::now() < deadline { + if process_snapshot()? + .iter() + .all(|process| !process_ids.contains(&process.th32ProcessID)) + { + return Ok(()); + } + std::thread::sleep(Duration::from_millis(50)); + } + Err(windows::core::Error::new( + windows::core::HRESULT(0x800705B4_u32 as i32), + "The Windows Runtime Host process tree did not stop", + )) +} + +fn stop_task(task: &IRegisteredTask) -> windows::core::Result<()> { + // SAFETY: task and the returned collection are live thread-local COM interfaces. + if unsafe { task.GetInstances(0)?.Count()? } > 0 { + let process_ids = task_owned_process_ids(task)?; + unsafe { task.Stop(0)? }; + wait_until_task_stopped(task)?; + wait_until_processes_exit(&process_ids)?; + } + Ok(()) +} + +fn render_task_xml( + root_id: &str, + target: Target, + runner_path: &str, + command: &[String], + user: &str, +) -> windows::core::Result { + let marker = ownership_marker(root_id, target); + let trigger = match target { + Target::Host => format!( + "true{}", + xml_escape(user) + ), + Target::Reconciliation => concat!( + "truePT15M", + "2000-01-01T03:00:00", + "truePT1H", + "1" + ) + .to_owned(), + }; + let action_command = task_action_command(target, runner_path, command)?; + let arguments = command_line(&action_command[1..]); + Ok(format!( + concat!( + "", + "", + "{marker}", + "{trigger}", + "{user}InteractiveToken", + "LeastPrivilege", + "IgnoreNew", + "false", + "false", + "truetrue", + "false", + "falsefalse", + "truetruefalse", + "falsefalse", + "PT0S7", + "{executable}", + "{arguments}" + ), + marker = marker, + trigger = trigger, + executable = xml_escape(&action_command[0]), + arguments = xml_escape(&arguments), + user = xml_escape(user), + )) +} + +fn task_action_command( + target: Target, + runner_path: &str, + command: &[String], +) -> windows::core::Result> { + if !Path::new(runner_path).is_absolute() + || runner_path.contains('%') + || command[0].contains('%') + || (matches!(target, Target::Host) + && (command.len() < 4 || command[2] != "runtime-host" || command[3] != "serve")) + { + return Err(invalid_windows_request()); + } + let mode = match target { + Target::Host => "--supervise", + Target::Reconciliation => "--once", + }; + let mut projected = vec![command[0].clone(), runner_path.to_owned(), mode.to_owned()]; + projected.extend( + command + .iter() + .map(|argument| base64_url(argument.as_bytes())), + ); + if command_line(&projected[1..]).encode_utf16().count() >= 32_767 { + return Err(invalid_windows_request()); + } + Ok(projected) +} + +fn base64_url(bytes: &[u8]) -> String { + const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let mut encoded = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + let first = chunk[0]; + let second = chunk.get(1).copied().unwrap_or(0); + let third = chunk.get(2).copied().unwrap_or(0); + encoded.push(ALPHABET[(first >> 2) as usize] as char); + encoded.push(ALPHABET[(((first & 0x03) << 4) | (second >> 4)) as usize] as char); + if chunk.len() > 1 { + encoded.push(ALPHABET[(((second & 0x0f) << 2) | (third >> 6)) as usize] as char); + } + if chunk.len() > 2 { + encoded.push(ALPHABET[(third & 0x3f) as usize] as char); + } + } + encoded +} + +fn ownership_marker(root_id: &str, target: Target) -> String { + format!( + "maka-runtime-host/windows-task/v1/{root_id}/{}", + match target { + Target::Host => "host", + Target::Reconciliation => "reconciliation", + } + ) +} + +fn task_name(root_id: &str, target: Target) -> String { + format!( + "Maka-RuntimeHost-{root_id}{}", + match target { + Target::Host => "", + Target::Reconciliation => "-Reconciliation", + } + ) +} + +fn validate_root_id(root_id: &str) -> std::result::Result<(), ()> { + if root_id.len() == ROOT_ID_BYTES + && root_id + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + Ok(()) + } else { + Err(()) + } +} + +fn require_target(root_id: &str, target: &str) -> Result { + require_root_id(root_id)?; + match target { + "host" => Ok(Target::Host), + "reconciliation" => Ok(Target::Reconciliation), + _ => Err(invalid("Windows lifecycle task target is invalid")), + } +} + +fn require_root_id(root_id: &str) -> Result<()> { + validate_root_id(root_id).map_err(|_| invalid("Windows lifecycle Root ID is invalid")) +} + +fn validate_command(command: &[String]) -> std::result::Result<(), ()> { + if command.is_empty() + || command.len() > MAX_COMMAND_ARGUMENTS + || !Path::new(&command[0]).is_absolute() + || command.iter().any(|argument| { + argument.is_empty() + || argument.len() > MAX_ARGUMENT_BYTES + || argument.chars().any(char::is_control) + }) + { + return Err(()); + } + let utf16_length = command_line(command).encode_utf16().count(); + if utf16_length >= 32_767 { + return Err(()); + } + Ok(()) +} + +fn command_line(arguments: &[String]) -> String { + arguments + .iter() + .map(|argument| quote_windows_argument(argument)) + .collect::>() + .join(" ") +} + +fn quote_windows_argument(argument: &str) -> String { + if !argument.is_empty() + && !argument + .chars() + .any(|character| character.is_whitespace() || character == '"') + { + return argument.to_owned(); + } + let mut quoted = String::from("\""); + let mut backslashes = 0; + for character in argument.chars() { + if character == '\\' { + backslashes += 1; + } else if character == '"' { + quoted.push_str(&"\\".repeat(backslashes * 2 + 1)); + quoted.push('"'); + backslashes = 0; + } else { + quoted.push_str(&"\\".repeat(backslashes)); + quoted.push(character); + backslashes = 0; + } + } + quoted.push_str(&"\\".repeat(backslashes * 2)); + quoted.push('"'); + quoted +} + +fn xml_escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +struct ComApartment { + uninitialize: bool, +} + +impl ComApartment { + fn initialize() -> windows::core::Result { + // SAFETY: balances successful initialization on this thread in Drop. + let result = unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) }; + if result.is_ok() { + Ok(Self { uninitialize: true }) + } else if result == RPC_E_CHANGED_MODE { + Ok(Self { + uninitialize: false, + }) + } else { + result.ok()?; + unreachable!() + } + } +} + +impl Drop for ComApartment { + fn drop(&mut self) { + if self.uninitialize { + // SAFETY: paired with this thread's successful CoInitializeEx call. + unsafe { CoUninitialize() }; + } + } +} + +fn invalid(message: &str) -> NapiError { + NapiError::new(Status::InvalidArg, message) +} + +fn invalid_windows_request() -> windows::core::Error { + windows::core::Error::new( + windows::core::HRESULT(0x80070057_u32 as i32), + "Windows lifecycle request is invalid", + ) +} + +fn native_error(error: impl std::fmt::Display) -> NapiError { + NapiError::new(Status::GenericFailure, error.to_string()) +} diff --git a/packages/cli/RUNTIME_HOST_PEER_THIRD_PARTY_NOTICES.txt b/packages/cli/RUNTIME_HOST_PEER_THIRD_PARTY_NOTICES.txt index 26fc6d3d4c..d1c0b43e42 100644 --- a/packages/cli/RUNTIME_HOST_PEER_THIRD_PARTY_NOTICES.txt +++ b/packages/cli/RUNTIME_HOST_PEER_THIRD_PARTY_NOTICES.txt @@ -5,7 +5,7 @@ Generated by scripts/generate-runtime-host-peer-notices.mjs from the exact four-target production dependency inventory. Do not edit this file by hand. Manifest: native/runtime-host-peer/Cargo.toml -Cargo.lock SHA-256: e7d745e06c3d2d65d7a01e15075001e2f5fc95e127b77918ddb0d23c024984cd +Cargo.lock SHA-256: b1a7cdd67fee2bbde5bd8d91c6773f97e809515898604bde7ee0dbf4e04cbbd8 Inventory SHA-256: c28a17749fa5f9af237a49dd2b4ad9149b6c4224d383c4a5050eeac3be14073c Packages diff --git a/packages/cli/src/__tests__/runtime-host-lifecycle-transaction.test.ts b/packages/cli/src/__tests__/runtime-host-lifecycle-transaction.test.ts index ce68751979..be78154c77 100644 --- a/packages/cli/src/__tests__/runtime-host-lifecycle-transaction.test.ts +++ b/packages/cli/src/__tests__/runtime-host-lifecycle-transaction.test.ts @@ -97,7 +97,10 @@ test('one authority record recovers provider cutover failures without a journal' verifyOperator: async (expected: RuntimeHostManagedDeploymentConfig) => { assert.deepEqual(operatorProjection.launch, expected.launch); }, - resolveProvider: (provider: RuntimeHostSupervisorProvider) => providers.get(provider)!, + resolveProvider: (deployment: RuntimeHostManagedDeploymentConfig) => { + assert.equal(deployment.lifecycle.mode, 'supervised'); + return providers.get(deployment.lifecycle.provider)!; + }, }; const firstOwner = await tryAcquireStateRootOwner(capability); assert.ok(firstOwner); diff --git a/packages/cli/src/__tests__/runtime-host-selected-update.test.ts b/packages/cli/src/__tests__/runtime-host-selected-update.test.ts index 2f3e59ce54..09564bc2ec 100644 --- a/packages/cli/src/__tests__/runtime-host-selected-update.test.ts +++ b/packages/cli/src/__tests__/runtime-host-selected-update.test.ts @@ -110,6 +110,7 @@ describe('managed Runtime Host selected update', () => { assertOperatorConfig: () => {}, manageLifecycle: async () => { managedReads += 1; + if (managedReads === 2) assert.equal(pruned, false); return status as never; }, replaceLifecycle: async (input) => { diff --git a/packages/cli/src/__tests__/runtime-host-setup.test.ts b/packages/cli/src/__tests__/runtime-host-setup.test.ts index 6a0c46df41..a8818d2783 100644 --- a/packages/cli/src/__tests__/runtime-host-setup.test.ts +++ b/packages/cli/src/__tests__/runtime-host-setup.test.ts @@ -64,6 +64,7 @@ import { runRuntimeHostSetupCli } from '../runtime-host-setup-command.js'; import { RuntimeHostAccessUnavailableError } from '../runtime-host-access-command.js'; import { replaceRuntimeHostLifecycle } from '../runtime-host-lifecycle-transaction.js'; import { manageRuntimeHostManagedLifecycle } from '../runtime-host-managed-lifecycle-manager.js'; +import { createOpenRcRuntimeHostLifecycleProvider } from '../runtime-host-openrc-service.js'; import { resolveRuntimeHostLifecycleProvider } from '../runtime-host-service-management-command.js'; import { resolveRuntimeHostManagedServiceId, @@ -383,7 +384,7 @@ test('fresh supervised setup discovers its provider before constructing a legacy discoverLifecycleProvider: async (discoveredRootId) => { rootId = discoveredRootId; return { - provider: resolveRuntimeHostLifecycleProvider(rootId, 'openrc_user'), + provider: createOpenRcRuntimeHostLifecycleProvider(rootId, 'openrc_user'), availability: 'session', }; }, @@ -487,7 +488,24 @@ test('managed setup frames reject malformed machine output', () => { }); test('persisted OpenRC providers resolve without reselecting the platform default', () => { - const openRc = resolveRuntimeHostLifecycleProvider('a'.repeat(64), 'openrc_user'); + const rootId = 'a'.repeat(64); + const openRc = resolveRuntimeHostLifecycleProvider({ + schemaVersion: 1, + state: 'active', + deploymentId: '00000000-0000-4000-8000-000000000001', + configRevision: 1, + deploymentRoot: '/opt/maka', + root: { id: rootId, path: '/srv/maka' }, + projectDirectoryRoots: [], + launch: { + kind: 'exact_package', + nodePath: '/usr/bin/node', + package: { kind: 'npm_registry', version: '0.2.0', integrity: PACKAGE_INTEGRITY }, + }, + listeners: { localIpc: true }, + lifecycle: { mode: 'supervised', provider: 'openrc_user', availability: 'session' }, + reconciliation: { trigger: 'scheduled', provider: 'openrc_supervised_loop' }, + }); assert.equal(openRc.supervisor.provider, 'openrc_user'); assert.equal(openRc.reconciliationTrigger.provider, 'openrc_supervised_loop'); }); diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts index 4348e8eb6d..b7cceffd30 100644 --- a/packages/cli/src/cli-core.ts +++ b/packages/cli/src/cli-core.ts @@ -23,7 +23,7 @@ import { fileURLToPath } from 'node:url'; import { deriveMakaDataRoots, resolveMakaDataRoots } from './workspace-root.js'; import { configureRuntimeHostPeerClient, - resolveRuntimeHostPeerNativePath, + resolveRuntimeHostNativePath, } from './runtime-host-peer-artifact.js'; import { parseRuntimeHostCommand, @@ -323,6 +323,12 @@ export async function runMakaCli( config.deploymentRoot, config.launch.package.integrity, ); + if (process.platform === 'win32') { + const { ownWindowsRuntimeHostProcessTree } = await import( + './runtime-host-windows-service.js' + ); + await ownWindowsRuntimeHostProcessTree(packageLayout.cliPath); + } return runRuntimeHostServiceCli({ rootPath: config.root.path, json: command.json, @@ -335,7 +341,7 @@ export async function runMakaCli( ...(peer ? { peer: { - nativePath: await resolveRuntimeHostPeerNativePath(packageLayout.cliPath), + nativePath: await resolveRuntimeHostNativePath(packageLayout.cliPath), keyPath: peer.keyPath, expectedPeerId: peer.peerId, listenAddresses: peer.listenAddresses, diff --git a/packages/cli/src/runtime-host-lifecycle-provider.ts b/packages/cli/src/runtime-host-lifecycle-provider.ts index 0cff190d13..252f99b579 100644 --- a/packages/cli/src/runtime-host-lifecycle-provider.ts +++ b/packages/cli/src/runtime-host-lifecycle-provider.ts @@ -41,6 +41,7 @@ export interface RuntimeHostSupervisorStatus { readonly enabled: boolean; readonly active: boolean; readonly state: RuntimeHostSupervisorState; + /** The supervised Runtime Host/State Root owner, never an intermediate launcher. */ readonly pid: number | null; readonly lastExitCode: number | null; } diff --git a/packages/cli/src/runtime-host-lifecycle-transaction.ts b/packages/cli/src/runtime-host-lifecycle-transaction.ts index cd30881c89..48b3247cdf 100644 --- a/packages/cli/src/runtime-host-lifecycle-transaction.ts +++ b/packages/cli/src/runtime-host-lifecycle-transaction.ts @@ -49,7 +49,6 @@ import { type RuntimeHostManagedDeploymentTransition, type RuntimeHostManagedDeploymentTransitionOperation, type RuntimeHostManagedDeploymentTransitionRecovery, - type RuntimeHostSupervisorProvider, } from '@maka/runtime-host/operator'; import type { RuntimeHostLifecycleProvider, @@ -61,7 +60,7 @@ export const RUNTIME_HOST_READY_TIMEOUT_MS = 45_000; export interface RuntimeHostLifecycleTransactionDeps { readonly resolveProvider: ( - provider: RuntimeHostSupervisorProvider, + config: RuntimeHostManagedDeploymentConfig, ) => RuntimeHostLifecycleProvider; readonly convergeOperator: ( current: RuntimeHostManagedDeploymentConfig | undefined, @@ -519,7 +518,7 @@ export async function replaceRuntimeHostLifecycle(input: { const desired = decodeRuntimeHostManagedDeploymentConfig(input.desired); const currentProvider = supervisedProvider(current ?? null, input.deps); if (desired.lifecycle.mode === 'supervised') { - await input.deps.resolveProvider(desired.lifecycle.provider).supervisor.preflight(); + await input.deps.resolveProvider(desired).supervisor.preflight(); } const retirement = await retireRuntimeHostLifecycleOwner({ rootPath: desired.root.path, @@ -748,7 +747,7 @@ export async function activateRuntimeHostLifecycle( ): Promise { const canonical = decodeRuntimeHostManagedDeploymentConfig(config); if (canonical.lifecycle.mode !== 'supervised') return; - const provider = deps.resolveProvider(canonical.lifecycle.provider); + const provider = deps.resolveProvider(canonical); await provider.supervisor.activate(); if (canonical.reconciliation.trigger === 'scheduled') { await provider.reconciliationTrigger.activate(); @@ -763,7 +762,7 @@ export async function verifyRuntimeHostLifecycleReady( const canonical = decodeRuntimeHostManagedDeploymentConfig(config); await verifyRuntimeHostLifecycleProjection(canonical, deps); if (canonical.lifecycle.mode !== 'supervised') return; - const provider = deps.resolveProvider(canonical.lifecycle.provider); + const provider = deps.resolveProvider(canonical); const deadline = Date.now() + timeoutMs; let lastFailure: unknown = new Error('Runtime Host is not ready'); while (Date.now() < deadline) { @@ -815,7 +814,7 @@ export async function verifyRuntimeHostLifecycleProjection( const canonical = decodeRuntimeHostManagedDeploymentConfig(config); await deps.verifyOperator(canonical); if (canonical.lifecycle.mode !== 'supervised') return; - const provider = deps.resolveProvider(canonical.lifecycle.provider); + const provider = deps.resolveProvider(canonical); await provider.supervisor.verify(runtimeHostSupervisorDefinition(canonical)); if (canonical.reconciliation.trigger !== 'scheduled') return; await provider.reconciliationTrigger.verify( @@ -932,7 +931,5 @@ function supervisedProvider( config: RuntimeHostManagedDeploymentConfig | null, deps: RuntimeHostLifecycleTransactionDeps, ): RuntimeHostLifecycleProvider | undefined { - return config?.lifecycle.mode === 'supervised' - ? deps.resolveProvider(config.lifecycle.provider) - : undefined; + return config?.lifecycle.mode === 'supervised' ? deps.resolveProvider(config) : undefined; } diff --git a/packages/cli/src/runtime-host-managed-deployment.ts b/packages/cli/src/runtime-host-managed-deployment.ts index 46fbf42571..30e46b997a 100644 --- a/packages/cli/src/runtime-host-managed-deployment.ts +++ b/packages/cli/src/runtime-host-managed-deployment.ts @@ -18,7 +18,6 @@ */ import { randomUUID } from 'node:crypto'; -import { spawn } from 'node:child_process'; import { constants } from 'node:fs'; import { access, lstat, mkdir, open, readdir, realpath, rename, rm } from 'node:fs/promises'; import { homedir } from 'node:os'; @@ -27,6 +26,7 @@ import { openRuntimeHostPackageDeployment, prepareRuntimeHostPackageDeployment, pruneRuntimeHostPackageDeployments, + removeDeploymentDirectory, resolveRuntimeHostPackageCliPath, RuntimeHostPackageDeploymentError as RuntimeHostManagedDeploymentError, type RuntimeHostPackageDeployment, @@ -594,37 +594,8 @@ export async function removeRuntimeHostManagedDeployment( // recognized as already complete and reclaimed by the next deployment. await syncDirectory(parent); } - try { - await rm(retiredRoot, { recursive: true, force: true }); - await syncDirectory(parent); - } catch (error) { - if (process.platform !== 'win32') throw error; - scheduleWindowsDeploymentCleanup(retiredRoot); - } -} - -function scheduleWindowsDeploymentCleanup(path: string): void { - // Windows keeps loaded native addons locked until this operator exits. The - // deployment was already atomically renamed out of service, so a detached - // Node process can finish physical reclamation without owning lifecycle state. - const script = `const { rm } = require('node:fs/promises'); -const path = process.argv[1]; -const parent = Number(process.argv[2]); -const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); -(async () => { - for (let attempt = 0; attempt < 3000; attempt += 1) { - try { process.kill(parent, 0); } catch { break; } - await wait(100); - } - await rm(path, { recursive: true, force: true, maxRetries: 100, retryDelay: 100 }); -})().catch(() => { process.exitCode = 1; });`; - const cleanup = spawn(process.execPath, ['-e', script, path, String(process.pid)], { - detached: true, - stdio: 'ignore', - windowsHide: true, - }); - cleanup.on('error', () => undefined); - cleanup.unref(); + await removeDeploymentDirectory(retiredRoot); + await syncDirectory(parent); } function managedDeployment( diff --git a/packages/cli/src/runtime-host-managed-lifecycle-manager.ts b/packages/cli/src/runtime-host-managed-lifecycle-manager.ts index 419a40cdee..1a35cda4ea 100644 --- a/packages/cli/src/runtime-host-managed-lifecycle-manager.ts +++ b/packages/cli/src/runtime-host-managed-lifecycle-manager.ts @@ -20,7 +20,6 @@ import { resolveRuntimeHostNpmDeploymentLayout, type RuntimeHostManagedDeploymentConfig, - type RuntimeHostSupervisorProvider, } from '@maka/runtime-host/operator'; import { connectExistingRuntimeHost } from '@maka/runtime-host/client'; import { RUNTIME_HOST_PROTOCOL_VERSION } from '@maka/runtime-host/protocol'; @@ -56,8 +55,7 @@ import { export interface RuntimeHostManagedLifecycleManagerDeps { readonly resolveProvider: ( - rootId: string, - provider: RuntimeHostSupervisorProvider, + config: RuntimeHostManagedDeploymentConfig, ) => RuntimeHostLifecycleProvider; readonly operatorClaim?: { readonly deploymentId?: string; @@ -74,7 +72,7 @@ export async function manageRuntimeHostManagedLifecycle( convergeOperator: (currentConfig, desiredConfig) => convergeRuntimeHostManagedOperator(currentConfig, desiredConfig), verifyOperator: verifyRuntimeHostManagedOperator, - resolveProvider: (requested) => dependencies.resolveProvider(rootId, requested), + resolveProvider: dependencies.resolveProvider, }; const resolved = await resolveRecoverableRuntimeHostManagedDeployment(rootId, lifecycleDeps, { ...(input.expectedTarget ? { expectedTarget: input.expectedTarget } : {}), @@ -100,10 +98,8 @@ export async function manageRuntimeHostManagedLifecycle( dependencies.operatorClaim.cliPath, ); } - const supervisedLifecycle = config.lifecycle.mode === 'supervised' ? config.lifecycle : undefined; - const provider = supervisedLifecycle - ? dependencies.resolveProvider(rootId, supervisedLifecycle.provider) - : undefined; + const provider = + config.lifecycle.mode === 'supervised' ? dependencies.resolveProvider(config) : undefined; if (input.action === 'install') { throw new RuntimeHostServiceManagerError( 'target_mismatch', diff --git a/packages/cli/src/runtime-host-package-deployment.ts b/packages/cli/src/runtime-host-package-deployment.ts index 4fd2a097b5..18d1968b41 100644 --- a/packages/cli/src/runtime-host-package-deployment.ts +++ b/packages/cli/src/runtime-host-package-deployment.ts @@ -18,6 +18,7 @@ */ import { randomUUID } from 'node:crypto'; +import { spawn } from 'node:child_process'; import { cp, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat } from 'node:fs/promises'; import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from 'node:path'; import { resolveRuntimeHostNpmDeploymentLayout } from '@maka/runtime-host/operator'; @@ -328,12 +329,12 @@ async function removePackageAtomically(versionsRoot: string, packageName: string const packageRoot = join(versionsRoot, packageName); try { if (packageName.startsWith('.') && packageName.endsWith('.deleted')) { - await rm(packageRoot, { recursive: true, force: true }); + await removeDeploymentDirectory(packageRoot); return; } const tombstone = join(versionsRoot, `.${packageName}.${randomUUID()}.deleted`); await rename(packageRoot, tombstone); - await rm(tombstone, { recursive: true, force: true }); + await removeDeploymentDirectory(tombstone); } catch (error) { if (isNodeError(error, 'ENOENT')) return; throw new RuntimeHostPackageDeploymentError( @@ -344,6 +345,38 @@ async function removePackageAtomically(versionsRoot: string, packageName: string } } +export async function removeDeploymentDirectory(path: string): Promise { + try { + await rm(path, { recursive: true, force: true }); + } catch (error) { + if (process.platform !== 'win32') throw error; + deferWindowsDirectoryRemovalUntilExit(path); + } +} + +function deferWindowsDirectoryRemovalUntilExit(path: string): void { + // Loaded native addons remain locked until this operator exits. The caller + // has already renamed the directory out of authority before reaching here. + const script = `const { rm } = require('node:fs/promises'); +const path = process.argv[1]; +const parent = Number(process.argv[2]); +const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +(async () => { + for (let attempt = 0; attempt < 3000; attempt += 1) { + try { process.kill(parent, 0); } catch { break; } + await wait(100); + } + await rm(path, { recursive: true, force: true, maxRetries: 100, retryDelay: 100 }); +})().catch(() => { process.exitCode = 1; });`; + const cleanup = spawn(process.execPath, ['-e', script, path, String(process.pid)], { + detached: true, + stdio: 'ignore', + windowsHide: true, + }); + cleanup.on('error', () => undefined); + cleanup.unref(); +} + function registryPackageLayout(deploymentRoot: string, integrity: string) { try { return resolveRuntimeHostNpmDeploymentLayout(deploymentRoot, integrity); diff --git a/packages/cli/src/runtime-host-peer-artifact.ts b/packages/cli/src/runtime-host-peer-artifact.ts index a7fd7f5a8a..65c86b2dbd 100644 --- a/packages/cli/src/runtime-host-peer-artifact.ts +++ b/packages/cli/src/runtime-host-peer-artifact.ts @@ -23,7 +23,7 @@ import { basename, dirname, join } from 'node:path'; const PEER_NATIVE_FILE = 'maka_runtime_host_peer.node'; const SUPPORTED_TARGETS = new Set(['darwin-arm64', 'linux-arm64', 'linux-x64', 'win32-x64']); -export async function resolveRuntimeHostPeerNativePath(cliPath: string): Promise { +export async function resolveRuntimeHostNativePath(cliPath: string): Promise { const packageRoot = dirname(dirname(await realpath(cliPath))); const target = runtimeHostPeerTarget(); const packaged = join( @@ -50,7 +50,7 @@ export async function resolveRuntimeHostPeerNativePath(cliPath: string): Promise if (await isReadable(development)) return realpath(development); } - throw new Error(`Maka does not include a direct-peer native artifact for ${target}`); + throw new Error(`Maka does not include a Runtime Host native artifact for ${target}`); } export function runtimeHostPeerTarget( @@ -86,7 +86,7 @@ export async function configureRuntimeHostPeerClient(input: { const explicitKeyPath = environment.MAKA_RUNTIME_HOST_PEER_KEY_PATH?.trim(); if (explicitNativePath || explicitKeyPath) return Boolean(explicitNativePath && explicitKeyPath); try { - environment.MAKA_RUNTIME_HOST_PEER_NATIVE_PATH = await resolveRuntimeHostPeerNativePath( + environment.MAKA_RUNTIME_HOST_PEER_NATIVE_PATH = await resolveRuntimeHostNativePath( input.cliPath, ); environment.MAKA_RUNTIME_HOST_PEER_KEY_PATH = resolveRuntimeHostClientPeerKeyPath( diff --git a/packages/cli/src/runtime-host-peer-management-command.ts b/packages/cli/src/runtime-host-peer-management-command.ts index b0396f6ebe..39b980a8d6 100644 --- a/packages/cli/src/runtime-host-peer-management-command.ts +++ b/packages/cli/src/runtime-host-peer-management-command.ts @@ -51,7 +51,7 @@ import { import { manageRuntimeHostManagedLifecycle } from './runtime-host-managed-lifecycle-manager.js'; import { resolveRuntimeHostManagedPeerKeyPath, - resolveRuntimeHostPeerNativePath, + resolveRuntimeHostNativePath, } from './runtime-host-peer-artifact.js'; import { assertRuntimeHostManagedOperatorConfig, @@ -122,7 +122,7 @@ async function runCanonicalRuntimeHostPeerManagementLocked( convergeOperator: (currentConfig, desiredConfig) => convergeRuntimeHostManagedOperator(currentConfig, desiredConfig), verifyOperator: verifyRuntimeHostManagedOperator, - resolveProvider: (requested) => resolveRuntimeHostLifecycleProvider(rootId, requested), + resolveProvider: resolveRuntimeHostLifecycleProvider, }; const resolved = await resolveRecoverableRuntimeHostManagedDeployment(rootId, lifecycleDeps, { ...(options.expectedTarget ? { expectedTarget: options.expectedTarget } : {}), @@ -195,7 +195,7 @@ async function runCanonicalRuntimeHostPeerManagementLocked( config.launch.package.integrity, ); const peerId = await ensureRuntimeHostPeerIdentity({ - nativePath: await resolveRuntimeHostPeerNativePath(layout.cliPath), + nativePath: await resolveRuntimeHostNativePath(layout.cliPath), keyPath: stagedKeyPath, }); desired = { @@ -311,7 +311,7 @@ async function prepareCanonicalPeer( ); const keyPath = current?.keyPath ?? resolveRuntimeHostManagedPeerKeyPath(config.deploymentRoot); const peerId = await ensureRuntimeHostPeerIdentity({ - nativePath: await resolveRuntimeHostPeerNativePath(layout.cliPath), + nativePath: await resolveRuntimeHostNativePath(layout.cliPath), keyPath, }); if (current && current.peerId !== peerId) { diff --git a/packages/cli/src/runtime-host-peer-mesh-management-command.ts b/packages/cli/src/runtime-host-peer-mesh-management-command.ts index 7ff9776126..8c91c2a512 100644 --- a/packages/cli/src/runtime-host-peer-mesh-management-command.ts +++ b/packages/cli/src/runtime-host-peer-mesh-management-command.ts @@ -100,8 +100,7 @@ export async function runRuntimeHostPeerMeshManagementCli( { convergeOperator: convergeRuntimeHostManagedOperator, verifyOperator: verifyRuntimeHostManagedOperator, - resolveProvider: (requested) => - resolveRuntimeHostLifecycleProvider(options.managedRootId, requested), + resolveProvider: resolveRuntimeHostLifecycleProvider, }, { expectedTarget: options.expectedTarget }, ); diff --git a/packages/cli/src/runtime-host-service-management-command.ts b/packages/cli/src/runtime-host-service-management-command.ts index 140dcc695c..d6937a4818 100644 --- a/packages/cli/src/runtime-host-service-management-command.ts +++ b/packages/cli/src/runtime-host-service-management-command.ts @@ -21,6 +21,7 @@ import { truncateUtf8 } from '@maka/core/diagnostic-log'; import { release } from 'node:os'; import { assertRuntimeHostManagedDeploymentAuthorityDurablyAbsent, + decodeRuntimeHostManagedDeploymentConfig, encodeRuntimeHostServiceManagementFrame, RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY, RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV, @@ -31,10 +32,11 @@ import { RUNTIME_HOST_OPERATOR_PROCESS_LIFETIME_LOCK_CAPABILITY, RUNTIME_HOST_SERVICE_ERROR_CODE_MAX_BYTES, RUNTIME_HOST_SERVICE_ERROR_MESSAGE_MAX_BYTES, + resolveRuntimeHostNpmDeploymentLayout, + type RuntimeHostManagedDeploymentConfig, type RuntimeHostOperatorCapability, type RuntimeHostServiceManagementFrame, type RuntimeHostServiceSummary, - type RuntimeHostSupervisorProvider, } from '@maka/runtime-host/operator'; import { resolveExistingStorageRoot, tryAcquireStateRootOwner } from '@maka/storage/root-authority'; import { @@ -60,6 +62,7 @@ import { createSystemdUserRuntimeHostService, } from './runtime-host-systemd-service.js'; import { createOpenRcRuntimeHostLifecycleProvider } from './runtime-host-openrc-service.js'; +import { createWindowsRuntimeHostLifecycleProvider } from './runtime-host-windows-service.js'; import type { RuntimeHostLifecycleProvider, RuntimeHostLifecycleProviderOffer, @@ -498,10 +501,22 @@ export async function discoverRuntimeHostLifecycleProvider( await provider.supervisor.preflight(); return { provider, availability: 'session' }; } + if (platform === 'win32') { + const cliPath = process.argv[1]; + if (!cliPath) { + throw new RuntimeHostServiceManagerError( + 'invalid_launch', + 'The Windows lifecycle probe requires the current CLI path', + ); + } + const provider = createWindowsRuntimeHostLifecycleProvider(rootId, { cliPath }); + await provider.supervisor.preflight(); + return { provider, availability: 'session' }; + } if (platform !== 'linux') { throw new RuntimeHostServiceManagerError( 'unsupported_platform', - 'Supervised Runtime Host deployments currently require Linux or macOS', + 'Supervised Runtime Host deployments currently require Linux, macOS, or Windows', ); } @@ -550,11 +565,27 @@ export async function discoverRuntimeHostLifecycleProvider( /** Resolves only the provider identity already persisted by the deployment authority. */ export function resolveRuntimeHostLifecycleProvider( - rootId: string, - provider: RuntimeHostSupervisorProvider, + config: RuntimeHostManagedDeploymentConfig, ): RuntimeHostLifecycleProvider { + const canonical = decodeRuntimeHostManagedDeploymentConfig(config); + if (canonical.lifecycle.mode !== 'supervised') { + throw new RuntimeHostServiceManagerError( + 'invalid_config', + 'An on-demand Runtime Host has no lifecycle provider', + ); + } + const rootId = canonical.root.id; + const provider = canonical.lifecycle.provider; if (provider === 'systemd_user') return createSystemdUserRuntimeHostLifecycleProvider(rootId, {}); if (provider === 'launch_agent') return createLaunchAgentRuntimeHostLifecycleProvider(rootId); + if (provider === 'windows_task') { + return createWindowsRuntimeHostLifecycleProvider(rootId, { + cliPath: resolveRuntimeHostNpmDeploymentLayout( + canonical.deploymentRoot, + canonical.launch.package.integrity, + ).cliPath, + }); + } return createOpenRcRuntimeHostLifecycleProvider(rootId, provider); } diff --git a/packages/cli/src/runtime-host-service-manager.ts b/packages/cli/src/runtime-host-service-manager.ts index 124770222c..3c1ec26500 100644 --- a/packages/cli/src/runtime-host-service-manager.ts +++ b/packages/cli/src/runtime-host-service-manager.ts @@ -49,6 +49,8 @@ import { import { resolveRuntimeHostManagedServiceId, RUNTIME_HOST_SERVICE_LOG_MAX_BYTES, + type RuntimeHostReconciliationProvider, + type RuntimeHostSupervisorProvider, } from '@maka/runtime-host/operator'; import { withLegacyFileUpdateLockLease, @@ -154,6 +156,7 @@ export interface RuntimeHostManagedServiceStatus extends RuntimeHostServiceObser | 'launch_agent' | 'openrc_user' | 'openrc_system' + | 'windows_task' | 'on_demand' | 'none'; readonly config: RuntimeHostManagedServiceConfig | null; @@ -161,11 +164,11 @@ export interface RuntimeHostManagedServiceStatus extends RuntimeHostServiceObser readonly lifecycle?: { readonly mode: 'on_demand' | 'supervised'; readonly availability: 'activation' | 'session' | 'environment' | 'machine'; - readonly provider?: 'systemd_user' | 'launch_agent' | 'openrc_user' | 'openrc_system'; + readonly provider?: RuntimeHostSupervisorProvider; }; readonly reconciliation?: { readonly trigger: 'manual' | 'activation' | 'scheduled'; - readonly provider?: 'systemd_timer' | 'launch_agent_timer' | 'openrc_supervised_loop'; + readonly provider?: RuntimeHostReconciliationProvider; }; } diff --git a/packages/cli/src/runtime-host-setup-command.ts b/packages/cli/src/runtime-host-setup-command.ts index a4739616f4..c21f34e96c 100644 --- a/packages/cli/src/runtime-host-setup-command.ts +++ b/packages/cli/src/runtime-host-setup-command.ts @@ -41,7 +41,6 @@ import { type RuntimeHostNodeOperatorCommand, type RuntimeHostSetupFrame, type RuntimeHostSetupPhase, - type RuntimeHostSupervisorProvider, } from '@maka/runtime-host/operator'; import { INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, @@ -120,7 +119,7 @@ import type { } from './runtime-host-lifecycle-provider.js'; import { resolveRuntimeHostManagedPeerKeyPath, - resolveRuntimeHostPeerNativePath, + resolveRuntimeHostNativePath, } from './runtime-host-peer-artifact.js'; import { activateRuntimeHostManagedDeploymentWithReconciliation } from './runtime-host-activation-command.js'; @@ -161,8 +160,7 @@ interface RuntimeHostSetupDeps { rootId: string, ) => Promise; readonly resolveLifecycleProvider: ( - rootId: string, - provider: RuntimeHostSupervisorProvider, + config: RuntimeHostManagedDeploymentConfig, ) => RuntimeHostLifecycleProvider; readonly replaceLifecycle: typeof replaceRuntimeHostLifecycle; readonly openDeployment: typeof openRuntimeHostManagedPackageDeployment; @@ -179,7 +177,7 @@ interface RuntimeHostSetupDeps { readonly resolveRegistryCandidate: typeof resolveRuntimeHostRegistryUpdateCandidate; readonly withRegistryPackage: typeof withRuntimeHostRegistryUpdatePackage; readonly ensurePeerIdentity: typeof ensureRuntimeHostPeerIdentity; - readonly resolvePeerNativePath: typeof resolveRuntimeHostPeerNativePath; + readonly resolvePeerNativePath: typeof resolveRuntimeHostNativePath; readonly allocateLoopbackPort: typeof allocateRuntimeHostLoopbackPort; readonly allocatePeerPort: typeof allocateRuntimeHostPeerPort; readonly writeOutput: (value: string) => unknown; @@ -263,7 +261,7 @@ export async function runRuntimeHostSetupCli( resolveRegistryCandidate: resolveRuntimeHostRegistryUpdateCandidate, withRegistryPackage: withRuntimeHostRegistryUpdatePackage, ensurePeerIdentity: ensureRuntimeHostPeerIdentity, - resolvePeerNativePath: resolveRuntimeHostPeerNativePath, + resolvePeerNativePath: resolveRuntimeHostNativePath, allocateLoopbackPort: allocateRuntimeHostLoopbackPort, allocatePeerPort: allocateRuntimeHostPeerPort, writeOutput: (value) => process.stdout.write(value), @@ -392,7 +390,7 @@ async function runRuntimeHostSupervisedSetupLocked( convergeOperator: (currentConfig, desiredConfig) => deps.convergeOperator(currentConfig, desiredConfig), verifyOperator: deps.verifyOperator, - resolveProvider: (provider) => deps.resolveLifecycleProvider(capability.rootId, provider), + resolveProvider: deps.resolveLifecycleProvider, ...(legacyConfig && legacyBackend ? legacyMigrationDeps(legacyConfig, legacyBackend, legacyServiceId, options.clientDataRoot) : {}), @@ -439,7 +437,7 @@ async function runRuntimeHostSupervisedSetupLocked( const lifecycleOffer: RuntimeHostLifecycleProviderOffer = current?.lifecycle.mode === 'supervised' ? { - provider: deps.resolveLifecycleProvider(capability.rootId, current.lifecycle.provider), + provider: deps.resolveLifecycleProvider(current), availability: current.lifecycle.availability, } : await deps.discoverLifecycleProvider(capability.rootId); @@ -742,7 +740,7 @@ async function runRuntimeHostOnDemandSetupLocked( convergeOperator: (currentConfig, desiredConfig) => deps.convergeOperator(currentConfig, desiredConfig), verifyOperator: deps.verifyOperator, - resolveProvider: (requested) => deps.resolveLifecycleProvider(capability.rootId, requested), + resolveProvider: deps.resolveLifecycleProvider, ...(legacyConfig && legacyBackend ? legacyMigrationDeps(legacyConfig, legacyBackend, legacyServiceId, options.clientDataRoot) : {}), @@ -818,7 +816,7 @@ async function runRuntimeHostOnDemandSetupLocked( convergeOperator: (currentConfig, desiredConfig) => deps.convergeOperator(currentConfig, desiredConfig), verifyOperator: deps.verifyOperator, - resolveProvider: (requested) => deps.resolveLifecycleProvider(serviceId, requested), + resolveProvider: deps.resolveLifecycleProvider, ...(legacyToMigrate && legacyBackend ? legacyMigrationDeps(legacyToMigrate, legacyBackend, legacyServiceId, options.clientDataRoot) : {}), diff --git a/packages/cli/src/runtime-host-update-command.ts b/packages/cli/src/runtime-host-update-command.ts index 3f815971f7..dc8d755767 100644 --- a/packages/cli/src/runtime-host-update-command.ts +++ b/packages/cli/src/runtime-host-update-command.ts @@ -233,7 +233,7 @@ export async function runManagedRuntimeHostUpdateCli( convergeOperator: (currentConfig, desiredConfig) => convergeRuntimeHostManagedOperator(currentConfig, desiredConfig), verifyOperator: verifyRuntimeHostManagedOperator, - resolveProvider: (requested) => resolveRuntimeHostLifecycleProvider(rootId, requested), + resolveProvider: resolveRuntimeHostLifecycleProvider, }), assertOperatorDeployment: assertRuntimeHostManagedOperatorDeployment, recoverDeployment: resolveRecoverableRuntimeHostManagedDeployment, @@ -784,7 +784,6 @@ async function runCanonicalRuntimeHostUpdate( return 1; } staged = undefined; - await deps.prunePackages(desired); const updated = await deps.canonical.manageLifecycle( options.managedRootId, { @@ -797,6 +796,7 @@ async function runCanonicalRuntimeHostUpdate( }, { resolveProvider: resolveRuntimeHostLifecycleProvider }, ); + await deps.prunePackages(desired); emit({ schemaVersion: 1, kind: 'result', diff --git a/packages/cli/src/runtime-host-update-reconciliation.ts b/packages/cli/src/runtime-host-update-reconciliation.ts index a52b90a05e..0d0880f160 100644 --- a/packages/cli/src/runtime-host-update-reconciliation.ts +++ b/packages/cli/src/runtime-host-update-reconciliation.ts @@ -26,6 +26,7 @@ import { RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY, RUNTIME_HOST_SERVICE_ERROR_CODE_MAX_BYTES, RUNTIME_HOST_SERVICE_ERROR_MESSAGE_MAX_BYTES, + resolveRuntimeHostManagedDeployment, type RuntimeHostManagedUpdatePolicy, type RuntimeHostManagedDeploymentConfig, type RuntimeHostServiceManagementFrame, @@ -428,14 +429,16 @@ async function inspectUpdateScheduler( const config = status.service.config; if (!status.service.installed || !config?.managedDeploymentRoot) return 'needs_repair'; if (options.managedRootId) { - if (status.service.reconciliation?.trigger === 'activation') return 'ready'; - if (status.service.reconciliation?.trigger !== 'scheduled') return 'needs_repair'; - const provider = status.service.lifecycle?.provider; - if (status.service.lifecycle?.mode !== 'supervised' || !provider) return 'needs_repair'; - const trigger = await resolveRuntimeHostLifecycleProvider( - options.managedRootId, - provider, - ).reconciliationTrigger.status(); + const { config: deployment } = await resolveRuntimeHostManagedDeployment(options.managedRootId); + if (deployment.reconciliation.trigger === 'activation') return 'ready'; + if ( + deployment.lifecycle.mode !== 'supervised' || + deployment.reconciliation.trigger !== 'scheduled' + ) { + return 'needs_repair'; + } + const trigger = + await resolveRuntimeHostLifecycleProvider(deployment).reconciliationTrigger.status(); return trigger.installed ? (trigger.active ? 'ready' : 'inactive') : 'needs_repair'; } const backend = deps.createBackend( diff --git a/packages/cli/src/runtime-host-windows-service.ts b/packages/cli/src/runtime-host-windows-service.ts new file mode 100644 index 0000000000..e41d4c1859 --- /dev/null +++ b/packages/cli/src/runtime-host-windows-service.ts @@ -0,0 +1,215 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { + assertRuntimeHostProviderDefinition, + type RuntimeHostLifecycleProvider, + type RuntimeHostProviderDefinition, + type RuntimeHostSupervisorStatus, +} from './runtime-host-lifecycle-provider.js'; +import { resolveRuntimeHostNativePath } from './runtime-host-peer-artifact.js'; +import { RuntimeHostServiceManagerError } from './runtime-host-service-manager.js'; + +const require = createRequire(import.meta.url); +const ROOT_ID_PATTERN = /^[a-f0-9]{64}$/u; + +type WindowsTaskTarget = 'host' | 'reconciliation'; + +interface WindowsTaskStatus { + readonly installed: boolean; + readonly enabled: boolean; + readonly state: RuntimeHostSupervisorStatus['state']; + readonly pid: number | null; + readonly lastExitCode: number | null; +} + +interface WindowsTaskNativeStatus { + readonly installed: boolean; + readonly enabled: boolean; + readonly state: RuntimeHostSupervisorStatus['state']; + readonly pid?: number; + readonly lastExitCode?: number; +} + +interface WindowsLifecycleNative { + readonly windowsTaskProbe: () => void; + readonly windowsTaskConverge: ( + rootId: string, + target: WindowsTaskTarget, + runnerPath: string, + command: string[], + ) => void; + readonly windowsTaskVerify: ( + rootId: string, + target: WindowsTaskTarget, + runnerPath: string, + command: string[], + ) => void; + readonly windowsTaskStatus: (rootId: string, target: WindowsTaskTarget) => unknown; + readonly windowsTaskActivate: (rootId: string) => void; + readonly windowsTaskRetire: (rootId: string) => void; + readonly windowsTaskUninstall: (rootId: string, target: WindowsTaskTarget) => void; + readonly ownCurrentProcessTree: () => void; +} + +export interface WindowsRuntimeHostLifecycleProviderOptions { + readonly cliPath: string; +} + +export function createWindowsRuntimeHostLifecycleProvider( + rootId: string, + options: WindowsRuntimeHostLifecycleProviderOptions, +): RuntimeHostLifecycleProvider { + if (!ROOT_ID_PATTERN.test(rootId)) { + throw new RuntimeHostServiceManagerError( + 'invalid_config', + 'The Runtime Host Root ID is invalid', + ); + } + const native = createWindowsLifecycleNativeLoader(options.cliPath); + const runnerPath = join(dirname(options.cliPath), 'runtime-host-windows-task-runner.js'); + const converge = async ( + target: WindowsTaskTarget, + definition: RuntimeHostProviderDefinition, + ): Promise => { + assertRuntimeHostProviderDefinition(definition); + (await native()).windowsTaskConverge(rootId, target, runnerPath, [...definition.command]); + }; + const verify = async ( + target: WindowsTaskTarget, + definition: RuntimeHostProviderDefinition, + ): Promise => { + assertRuntimeHostProviderDefinition(definition); + (await native()).windowsTaskVerify(rootId, target, runnerPath, [...definition.command]); + }; + const status = async (target: WindowsTaskTarget): Promise => + decodeStatus((await native()).windowsTaskStatus(rootId, target)); + + return { + supervisor: { + provider: 'windows_task', + preflight: async () => (await native()).windowsTaskProbe(), + converge: (definition) => converge('host', definition), + verify: (definition) => verify('host', definition), + status: async () => { + const observed = await status('host'); + return { + provider: 'windows_task', + ...observed, + active: observed.state === 'running' && observed.pid !== null, + }; + }, + activate: async () => (await native()).windowsTaskActivate(rootId), + retire: async () => (await native()).windowsTaskRetire(rootId), + logs: async () => formatTaskStatus('host', await status('host')), + uninstall: async () => (await native()).windowsTaskUninstall(rootId, 'host'), + }, + reconciliationTrigger: { + provider: 'windows_task_timer', + converge: (definition) => converge('reconciliation', definition), + verify: (definition) => verify('reconciliation', definition), + status: async () => { + const observed = await status('reconciliation'); + return { installed: observed.installed, active: observed.installed && observed.enabled }; + }, + activate: async () => { + const observed = await status('reconciliation'); + if (!observed.installed || !observed.enabled) { + throw new RuntimeHostServiceManagerError( + 'target_mismatch', + 'The Windows reconciliation task is not enabled', + ); + } + }, + logs: async () => formatTaskStatus('reconciliation', await status('reconciliation')), + uninstall: async () => (await native()).windowsTaskUninstall(rootId, 'reconciliation'), + }, + }; +} + +export async function ownWindowsRuntimeHostProcessTree(cliPath: string): Promise { + const nativePath = await resolveRuntimeHostNativePath(cliPath); + loadWindowsLifecycleNative(nativePath).ownCurrentProcessTree(); +} + +function createWindowsLifecycleNativeLoader( + cliPath: string, +): () => Promise { + let native: Promise | undefined; + return () => { + native ??= resolveRuntimeHostNativePath(cliPath).then(loadWindowsLifecycleNative); + return native; + }; +} + +function loadWindowsLifecycleNative(path: string): WindowsLifecycleNative { + const loaded = require(path) as Partial; + const methods = [ + 'windowsTaskProbe', + 'windowsTaskConverge', + 'windowsTaskVerify', + 'windowsTaskStatus', + 'windowsTaskActivate', + 'windowsTaskRetire', + 'windowsTaskUninstall', + 'ownCurrentProcessTree', + ] as const satisfies readonly (keyof WindowsLifecycleNative)[]; + if (methods.some((method) => typeof loaded[method] !== 'function')) { + throw unavailable( + 'The Runtime Host native artifact does not support Windows lifecycle control', + ); + } + return loaded as WindowsLifecycleNative; +} + +function decodeStatus(value: unknown): WindowsTaskStatus { + if ( + !isRecord(value) || + typeof value.installed !== 'boolean' || + typeof value.enabled !== 'boolean' || + !['not_installed', 'stopped', 'starting', 'running', 'failed'].includes(String(value.state)) || + !(value.pid === undefined || (Number.isSafeInteger(value.pid) && Number(value.pid) > 0)) || + !( + value.lastExitCode === undefined || + (Number.isSafeInteger(value.lastExitCode) && Number(value.lastExitCode) >= 0) + ) + ) { + throw unavailable('The Windows lifecycle status response is invalid'); + } + const status = value as unknown as WindowsTaskNativeStatus; + return { + ...status, + pid: status.pid ?? null, + lastExitCode: status.lastExitCode ?? null, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function formatTaskStatus(target: WindowsTaskTarget, status: WindowsTaskStatus): string { + return `Windows Task Scheduler ${target}: ${JSON.stringify(status)}\n`; +} + +function unavailable(message: string, cause?: unknown): RuntimeHostServiceManagerError { + return new RuntimeHostServiceManagerError('service_manager_unavailable', message, { cause }); +} diff --git a/packages/cli/src/runtime-host-windows-task-runner.ts b/packages/cli/src/runtime-host-windows-task-runner.ts new file mode 100644 index 0000000000..7287322ebb --- /dev/null +++ b/packages/cli/src/runtime-host-windows-task-runner.ts @@ -0,0 +1,98 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { assertRuntimeHostProviderDefinition } from './runtime-host-lifecycle-provider.js'; +import { ownWindowsRuntimeHostProcessTree } from './runtime-host-windows-service.js'; + +const RESTART_DELAY_MS = 2_000; +const [mode, ...encodedCommand] = process.argv.slice(2); + +try { + const command = decodeCommand(encodedCommand); + assertRuntimeHostProviderDefinition({ command }); + if (process.platform !== 'win32' || (mode !== '--once' && mode !== '--supervise')) { + throw new Error('The Windows Runtime Host task command is invalid'); + } + if ( + mode === '--supervise' && + (command.length < 4 || command[2] !== 'runtime-host' || command[3] !== 'serve') + ) { + throw new Error('The Windows Runtime Host supervisor command is invalid'); + } + await ownWindowsRuntimeHostProcessTree(fileURLToPath(import.meta.url)); + if (mode === '--once') { + const result = await runChild(command); + process.exitCode = result.signal === null && result.code !== null ? result.code : 1; + } else { + for (;;) { + const result = await runChild(command); + if (result.code === 0 && result.signal === null) break; + console.error( + `[runtime-host] Host exited unexpectedly (${result.signal ?? result.code ?? 'launch failed'}); restarting`, + ); + await new Promise((resolve) => setTimeout(resolve, RESTART_DELAY_MS)); + } + } +} catch (error) { + console.error(error instanceof Error ? (error.stack ?? error.message) : String(error)); + process.exitCode = 1; +} + +function decodeCommand(encoded: readonly string[]): [string, ...string[]] { + const command = encoded.map((argument) => { + if (!/^[A-Za-z0-9_-]+$/u.test(argument)) { + throw new Error('The Windows Runtime Host task argument is invalid'); + } + const bytes = Buffer.from(argument, 'base64url'); + if (bytes.toString('base64url') !== argument) { + throw new Error('The Windows Runtime Host task argument is invalid'); + } + return new TextDecoder('utf-8', { fatal: true }).decode(bytes); + }); + if (command.length === 0) { + throw new Error('The Windows Runtime Host task command is empty'); + } + return command as [string, ...string[]]; +} + +function runChild( + childCommand: readonly [string, ...string[]], +): Promise<{ readonly code: number | null; readonly signal: NodeJS.Signals | null }> { + return new Promise((resolve) => { + const child = spawn(childCommand[0], childCommand.slice(1), { + stdio: 'inherit', + windowsHide: true, + }); + let settled = false; + child.once('error', () => { + if (!settled) { + settled = true; + resolve({ code: null, signal: null }); + } + }); + child.once('exit', (code, signal) => { + if (!settled) { + settled = true; + resolve({ code, signal }); + } + }); + }); +} diff --git a/packages/runtime-host/src/operator/managed-deployment.ts b/packages/runtime-host/src/operator/managed-deployment.ts index 7c990f8301..6645222b54 100644 --- a/packages/runtime-host/src/operator/managed-deployment.ts +++ b/packages/runtime-host/src/operator/managed-deployment.ts @@ -63,11 +63,18 @@ const boundedText = (maximumBytes: number) => const absolutePathSchema = boundedText(4_096).refine(isAbsolute); const deploymentIdSchema = z.string().regex(UUID_PATTERN); const configRevisionSchema = z.number().int().positive().safe(); -const providerSchema = z.enum(['systemd_user', 'launch_agent', 'openrc_user', 'openrc_system']); -const reconciliationProviderSchema = z.enum([ +export const runtimeHostSupervisorProviderSchema = z.enum([ + 'systemd_user', + 'launch_agent', + 'openrc_user', + 'openrc_system', + 'windows_task', +]); +export const runtimeHostReconciliationProviderSchema = z.enum([ 'systemd_timer', 'launch_agent_timer', 'openrc_supervised_loop', + 'windows_task_timer', ]); const packageIdentitySchema = z .object({ @@ -87,7 +94,7 @@ const lifecycleSchema = z.discriminatedUnion('mode', [ z .object({ mode: z.literal('supervised'), - provider: providerSchema, + provider: runtimeHostSupervisorProviderSchema, availability: z.enum(['session', 'environment', 'machine']), }) .strict(), @@ -99,7 +106,7 @@ const reconciliationSchema = z.discriminatedUnion('trigger', [ z .object({ trigger: z.literal('scheduled'), - provider: reconciliationProviderSchema, + provider: runtimeHostReconciliationProviderSchema, }) .strict(), ]); @@ -216,7 +223,9 @@ const managedDeploymentConfigSchema = z ? 'systemd_timer' : value.lifecycle.provider === 'launch_agent' ? 'launch_agent_timer' - : 'openrc_supervised_loop'; + : value.lifecycle.provider === 'windows_task' + ? 'windows_task_timer' + : 'openrc_supervised_loop'; if (value.reconciliation.provider !== expected) { context.addIssue({ code: 'custom', @@ -297,8 +306,10 @@ function validateDeploymentTransitionEndpoints( } } -export type RuntimeHostSupervisorProvider = z.infer; -export type RuntimeHostReconciliationProvider = z.infer; +export type RuntimeHostSupervisorProvider = z.infer; +export type RuntimeHostReconciliationProvider = z.infer< + typeof runtimeHostReconciliationProviderSchema +>; export type RuntimeHostManagedDeploymentConfig = z.infer; export type RuntimeHostManagedLaunchClaim = z.infer; export type RuntimeHostManagedDeploymentTransitionOperation = z.infer< diff --git a/packages/runtime-host/src/operator/service-management-frame.ts b/packages/runtime-host/src/operator/service-management-frame.ts index 3c25323d93..1c78513238 100644 --- a/packages/runtime-host/src/operator/service-management-frame.ts +++ b/packages/runtime-host/src/operator/service-management-frame.ts @@ -19,6 +19,10 @@ import { z } from 'zod'; import { PROJECT_DIRECTORY_MAX_ROOTS } from '../protocol/project-catalog.js'; +import { + runtimeHostReconciliationProviderSchema, + runtimeHostSupervisorProviderSchema, +} from './managed-deployment.js'; import { compareProductReleaseVersions, isProductReleaseVersion, @@ -241,18 +245,14 @@ const SERVICE_SUMMARY_SCHEMA = z .object({ mode: z.enum(['on_demand', 'supervised']), availability: z.enum(['activation', 'session', 'environment', 'machine']), - provider: z - .enum(['systemd_user', 'launch_agent', 'openrc_user', 'openrc_system']) - .optional(), + provider: runtimeHostSupervisorProviderSchema.optional(), }) .strict() .optional(), reconciliation: z .object({ trigger: z.enum(['manual', 'activation', 'scheduled']), - provider: z - .enum(['systemd_timer', 'launch_agent_timer', 'openrc_supervised_loop']) - .optional(), + provider: runtimeHostReconciliationProviderSchema.optional(), }) .strict() .optional(), diff --git a/scripts/smoke-release-cli-package.mjs b/scripts/smoke-release-cli-package.mjs index 7b23c89af8..b8307b1bac 100644 --- a/scripts/smoke-release-cli-package.mjs +++ b/scripts/smoke-release-cli-package.mjs @@ -23,18 +23,20 @@ import { createServer } from 'node:http'; import { createRequire } from 'node:module'; import { closeSync, + cpSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, + renameSync, rmSync, statSync, writeFileSync, writeSync, } from 'node:fs'; import { tmpdir } from 'node:os'; -import { basename, join, resolve } from 'node:path'; +import { basename, dirname, join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import { validateCliReleaseArtifactMetrics } from './release-cli-artifact-policy.mjs'; import { findReleaseTarball } from './release-cli-eval-support.mjs'; @@ -230,6 +232,10 @@ async function smokeRuntimeHostPeerProtocol({ packageRoot, cliEntrypoint, root } 'node_modules/@maka/runtime-host/dist/peer-reachability/index.js', ); const access = await importInstalled(packageRoot, 'dist/runtime-host-access-command.js'); + const windowsLifecycle = await importInstalled( + packageRoot, + 'dist/runtime-host-windows-service.js', + ); const clientDataRoot = join(root, 'peer-client'); const hostRoot = join(root, 'peer-host'); const hostKeyPath = join(root, 'peer-host.key'); @@ -256,6 +262,11 @@ async function smokeRuntimeHostPeerProtocol({ packageRoot, cliEntrypoint, root } const nativePath = process.env.MAKA_RUNTIME_HOST_PEER_NATIVE_PATH; if (!nativePath) throw new Error('Installed CLI did not configure its direct-peer artifact'); const addon = require(nativePath); + await smokeWindowsTaskScheduler( + windowsLifecycle.createWindowsRuntimeHostLifecycleProvider, + cliEntrypoint, + join(root, 'windows task & % 生命周期'), + ); const peerId = await addon.ensurePeerIdentity(hostKeyPath); const unrelatedPeerId = await addon.ensurePeerIdentity(join(root, 'unrelated-peer.key')); try { @@ -402,6 +413,148 @@ async function smokeRuntimeHostPeerProtocol({ packageRoot, cliEntrypoint, root } } } +async function smokeWindowsTaskScheduler(createProvider, cliEntrypoint, root) { + if (process.platform !== 'win32') return; + mkdirSync(root, { recursive: true }); + const rootId = createHash('sha256').update(root).digest('hex'); + const controllerPackageRoot = dirname(dirname(cliEntrypoint)); + const managedPackageRoot = join(controllerPackageRoot, '.windows-lifecycle-smoke-package'); + cpSync(join(controllerPackageRoot, 'dist'), join(managedPackageRoot, 'dist'), { + recursive: true, + }); + cpSync(join(controllerPackageRoot, 'native'), join(managedPackageRoot, 'native'), { + recursive: true, + }); + const managedCliEntrypoint = join(managedPackageRoot, 'dist', basename(cliEntrypoint)); + const scriptPath = join( + dirname(managedCliEntrypoint), + 'runtime-host-windows-supervisor-smoke.mjs', + ); + const controllerRunnerPath = join(dirname(cliEntrypoint), 'runtime-host-windows-task-runner.js'); + const disabledControllerRunnerPath = `${controllerRunnerPath}.disabled`; + const readyPath = join(root, 'ready.json'); + const replacementReadyPath = join(root, 'replacement-ready.json'); + const hostileArgument = '空 格 &|^<>%PATH% " \\'; + writeFileSync( + scriptPath, + [ + "import { spawn } from 'node:child_process';", + "import { writeFileSync } from 'node:fs';", + "import { fileURLToPath } from 'node:url';", + 'const [runtimeHost, serve, expected, readyPath] = process.argv.slice(2);', + 'try {', + " const { ownWindowsRuntimeHostProcessTree } = await import('./runtime-host-windows-service.js');", + ' await ownWindowsRuntimeHostProcessTree(fileURLToPath(import.meta.url));', + " if (runtimeHost !== 'runtime-host' || serve !== 'serve') process.exit(90);", + ` if (expected !== ${JSON.stringify(hostileArgument)}) process.exit(91);`, + " const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { detached: true, stdio: 'ignore' });", + ' child.unref();', + ' writeFileSync(readyPath, JSON.stringify({ pid: process.pid, childPid: child.pid }));', + ' setInterval(() => {}, 1000);', + '} catch (error) {', + ' writeFileSync(readyPath, JSON.stringify({ error: error instanceof Error ? (error.stack ?? error.message) : String(error) }));', + ' process.exit(92);', + '}', + '', + ].join('\n'), + 'utf8', + ); + const provider = createProvider(rootId, { cliPath: managedCliEntrypoint }); + const hostCommand = [ + process.execPath, + scriptPath, + 'runtime-host', + 'serve', + hostileArgument, + readyPath, + ]; + const replacementHostCommand = [...hostCommand.slice(0, -1), replacementReadyPath]; + const reconciliationCommand = [process.execPath, '-e', 'process.exit(0)']; + renameSync(controllerRunnerPath, disabledControllerRunnerPath); + try { + await provider.supervisor.preflight(); + await provider.supervisor.converge({ command: hostCommand }); + await provider.supervisor.verify({ command: hostCommand }); + await provider.reconciliationTrigger.converge({ command: reconciliationCommand }); + await provider.reconciliationTrigger.verify({ command: reconciliationCommand }); + const reconciliation = await provider.reconciliationTrigger.status(); + if (!reconciliation.installed || !reconciliation.active) { + throw new Error('Windows reconciliation task is not ready'); + } + await provider.supervisor.activate(); + await provider.supervisor.activate(); + let deadline = Date.now() + 15_000; + while (!existsSync(readyPath) && Date.now() < deadline) await delay(100); + if (!existsSync(readyPath)) { + const status = await provider.supervisor.status(); + throw new Error(`Windows scheduled task did not start: ${JSON.stringify(status)}`); + } + const first = JSON.parse(readFileSync(readyPath, 'utf8')); + if (typeof first.error === 'string') { + throw new Error(`Windows scheduled task Host failed to start: ${first.error}`); + } + const firstStatus = await provider.supervisor.status(); + if ( + firstStatus.state !== 'running' || + firstStatus.pid !== first.pid || + !processExists(first.pid) || + !processExists(first.childPid) + ) { + throw new Error('Windows scheduled task PID does not match its process tree owner'); + } + rmSync(readyPath); + process.kill(first.pid, 'SIGKILL'); + deadline = Date.now() + 90_000; + while (!existsSync(readyPath) && Date.now() < deadline) await delay(100); + if (!existsSync(readyPath)) + throw new Error('Windows scheduled task did not restart after crash'); + const ready = JSON.parse(readFileSync(readyPath, 'utf8')); + const status = await provider.supervisor.status(); + if ( + ready.pid === first.pid || + status.state !== 'running' || + status.pid !== ready.pid || + !processExists(ready.pid) || + !processExists(ready.childPid) || + processExists(first.childPid) + ) { + throw new Error('Windows scheduled task did not recover with one fresh process tree'); + } + await provider.supervisor.converge({ command: replacementHostCommand }); + await provider.supervisor.verify({ command: replacementHostCommand }); + await provider.supervisor.activate(); + deadline = Date.now() + 15_000; + while (!existsSync(replacementReadyPath) && Date.now() < deadline) await delay(100); + if (!existsSync(replacementReadyPath)) { + throw new Error('Windows scheduled task did not activate its replacement definition'); + } + const replacement = JSON.parse(readFileSync(replacementReadyPath, 'utf8')); + if ( + processExists(ready.pid) || + processExists(ready.childPid) || + !processExists(replacement.pid) || + !processExists(replacement.childPid) + ) { + throw new Error('Windows scheduled task replacement retained the previous process tree'); + } + await provider.supervisor.retire(); + const stopDeadline = Date.now() + 10_000; + while ( + (processExists(replacement.pid) || processExists(replacement.childPid)) && + Date.now() < stopDeadline + ) { + await delay(100); + } + if (processExists(replacement.pid) || processExists(replacement.childPid)) { + throw new Error('Windows scheduled task retirement left an owned process alive'); + } + } finally { + await provider.supervisor.uninstall().catch(() => undefined); + await provider.reconciliationTrigger.uninstall().catch(() => undefined); + renameSync(disabledControllerRunnerPath, controllerRunnerPath); + } +} + function restoreEnvironment(name, value) { if (value === undefined) delete process.env[name]; else process.env[name] = value;