From 22d36b119199609a0543ad8381bc0cd3e2c482f9 Mon Sep 17 00:00:00 2001 From: kuddev Date: Thu, 24 Sep 2026 12:09:20 +0800 Subject: [PATCH] fix(updater): keep native helper dispatch and output in platform adapter --- .../2026-09-23-macos-bundle-handoff.md | 8 ++++ .../src/platform/update_installation.rs | 13 ++++++ .../src/platform/update_installation/macos.rs | 45 +++++++++++++++++-- nebula_app/src/update_download/handoff.rs | 12 +---- .../src/update_download/handoff/macos.rs | 14 ------ 5 files changed, 64 insertions(+), 28 deletions(-) diff --git a/architecture/notes/nebula_app/update_download/2026-09-23-macos-bundle-handoff.md b/architecture/notes/nebula_app/update_download/2026-09-23-macos-bundle-handoff.md index 1393b0c..f80b236 100644 --- a/architecture/notes/nebula_app/update_download/2026-09-23-macos-bundle-handoff.md +++ b/architecture/notes/nebula_app/update_download/2026-09-23-macos-bundle-handoff.md @@ -58,6 +58,14 @@ this bounded launch check; the retained backup and durable workspace remain. Cancelled or rejected transactions keep the original application running. Helpers have bounded preparation, commit and exit waits and never kill the user's process. +The platform adapter also owns helper materialization and launch selection. The +shared transaction requests a helper through one platform-independent entry, +without adding operating-system branches to prepare/commit orchestration. +Native command output is spooled to temporary files while enforcing the existing +process deadline, then read with a 2 MiB limit per stream. Polling a child before +reading piped output can otherwise block both processes once a pipe fills. A +native regression produces 256 KiB on each stream and checks command failure. + ## Rejected alternatives - Replacing a running bundle: risks mixed resources and incomplete session saves. diff --git a/nebula_app/src/platform/update_installation.rs b/nebula_app/src/platform/update_installation.rs index f9bf5ed..c8e26c0 100644 --- a/nebula_app/src/platform/update_installation.rs +++ b/nebula_app/src/platform/update_installation.rs @@ -105,3 +105,16 @@ pub(crate) fn guard_base(executable: &Path) -> PathBuf { } executable.parent().expect("canonical executable parent").join(".pebrel-update") } + +/// Select and materialize the native helper; transaction authority stays with the caller. +pub(crate) fn spawn_prepared_helper(directory: &Path, plan: &Path) -> Result { + #[cfg(target_os = "macos")] + return macos::spawn(directory, plan); + #[cfg(not(target_os = "macos"))] + { + let helper = directory.join("handoff.ps1"); + crate::atomic_file::write(&helper, include_bytes!("../update_download/handoff.ps1")) + .map_err(|error| error.to_string())?; + spawn_helper(&helper, plan).map_err(|error| error.to_string()) + } +} diff --git a/nebula_app/src/platform/update_installation/macos.rs b/nebula_app/src/platform/update_installation/macos.rs index debaa98..a150d68 100644 --- a/nebula_app/src/platform/update_installation/macos.rs +++ b/nebula_app/src/platform/update_installation/macos.rs @@ -3,14 +3,31 @@ use std::ffi::{CStr, CString}; use std::io; use std::os::unix::ffi::OsStrExt as _; use std::path::{Path, PathBuf}; -use std::process::{Command, Output, Stdio}; +use std::process::{Child, Command, Output, Stdio}; use std::time::{Duration, Instant}; +pub(crate) fn spawn(directory: &Path, plan: &Path) -> Result { + let helper = directory.join("handoff"); + std::fs::copy(std::env::current_exe().map_err(|e| e.to_string())?, &helper) + .map_err(|e| e.to_string())?; + Command::new(helper) + .arg("--internal-macos-update") + .arg(plan) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| e.to_string()) +} + pub(crate) fn run(command: &mut Command) -> Result { + // Regular files cannot fill a pipe while we poll for process completion. + let stdout = tempfile::NamedTempFile::new().map_err(|e| e.to_string())?; + let stderr = tempfile::NamedTempFile::new().map_err(|e| e.to_string())?; let mut child = command .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) + .stdout(stdout.reopen().map_err(|e| e.to_string())?) + .stderr(stderr.reopen().map_err(|e| e.to_string())?) .spawn() .map_err(|e| e.to_string())?; let deadline = Instant::now() + Duration::from_secs(120); @@ -22,7 +39,17 @@ pub(crate) fn run(command: &mut Command) -> Result { } std::thread::sleep(Duration::from_millis(25)); } - let output = child.wait_with_output().map_err(|e| e.to_string())?; + let read_output = |file: &tempfile::NamedTempFile| -> Result, String> { + if file.as_file().metadata().map_err(|e| e.to_string())?.len() > 2 * 1024 * 1024 { + return Err("macOS update command output exceeded its limit".into()); + } + std::fs::read(file.path()).map_err(|e| e.to_string()) + }; + let output = Output { + status: child.wait().map_err(|e| e.to_string())?, + stdout: read_output(&stdout)?, + stderr: read_output(&stderr)?, + }; if !output.status.success() { return Err(format!( "macOS update command failed ({}): {}", @@ -223,6 +250,16 @@ pub(crate) fn stage( #[cfg(test)] mod tests { use super::*; + #[test] + fn command_output_larger_than_a_pipe_is_drained() { + let output = run(Command::new("/bin/sh") + .args(["-c", "head -c 262144 /dev/zero; head -c 262144 /dev/zero >&2"])) + .unwrap(); + assert_eq!(output.stdout.len(), 262144); + assert_eq!(output.stderr.len(), 262144); + assert!(run(Command::new("/bin/sh").args(["-c", "exit 7"])).is_err()); + } + #[test] fn atomic_exchange_keeps_a_complete_original_and_can_roll_back() { let dir = tempfile::tempdir().unwrap(); diff --git a/nebula_app/src/update_download/handoff.rs b/nebula_app/src/update_download/handoff.rs index d457f7e..ba6497c 100644 --- a/nebula_app/src/update_download/handoff.rs +++ b/nebula_app/src/update_download/handoff.rs @@ -162,16 +162,8 @@ pub(crate) fn prepare(asset: &UpdateAsset) -> Result { &serde_json::to_vec(&plan_path).map_err(|error| error.to_string())?, ) .map_err(|error| error.to_string())?; - #[cfg(target_os = "macos")] - let child = macos::spawn(&directory, &plan_path)?; - #[cfg(not(target_os = "macos"))] - let child = { - let helper = directory.join("handoff.ps1"); - crate::atomic_file::write(&helper, include_bytes!("handoff.ps1")) - .map_err(|error| error.to_string())?; - crate::platform::update_installation::spawn_helper(&helper, &plan_path) - .map_err(|error| error.to_string())? - }; + let child = + crate::platform::update_installation::spawn_prepared_helper(&directory, &plan_path)?; let mut prepared = PreparedUpdate { directory, transaction, child, committed: false }; let timeout = if crate::platform::Platform::current() == crate::platform::Platform::MacOS { 600 diff --git a/nebula_app/src/update_download/handoff/macos.rs b/nebula_app/src/update_download/handoff/macos.rs index be1d7b8..939bcc3 100644 --- a/nebula_app/src/update_download/handoff/macos.rs +++ b/nebula_app/src/update_download/handoff/macos.rs @@ -20,20 +20,6 @@ pub(crate) fn run_helper_if_requested() -> Option { }) } -pub(crate) fn spawn(directory: &Path, plan: &Path) -> Result { - let helper = directory.join("handoff"); - std::fs::copy(std::env::current_exe().map_err(|e| e.to_string())?, &helper) - .map_err(|e| e.to_string())?; - Command::new(helper) - .arg("--internal-macos-update") - .arg(plan) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .map_err(|e| e.to_string()) -} - fn run(path: &Path) -> Result<(), String> { let path = canonical(path).map_err(|e| e.to_string())?; let directory = path.parent().ok_or("Missing update directory")?;