Preserve --user-data-dir across restarts (#62022)

# Objective

Fixes #57701.

When Zed is launched with `--user-data-dir`, a Zed-initiated
restart—such as `workspace: clear trusted worktrees` or Restart to
Update—can relaunch Zed without that argument. The restarted process
then uses the default data directory instead of the directory selected
by the user.

## Solution

- Store the resolved custom data directory as GPUI restart arguments
alongside the existing optional restart executable path.
- Forward those arguments when relaunching on Linux, macOS, and Windows.
- Preserve argument boundaries and native `OsString` values, including
paths containing spaces.
- On Windows updates, carry the arguments through
`auto_update_helper.exe` when it launches the updated `Zed.exe`.

Normal launches without `--user-data-dir` continue to restart without
additional arguments. This does not attempt to preserve unrelated CLI
arguments or general launch state.

## Testing

- `cargo fmt --all -- --check`
- `cargo test -p gpui`
- `cargo test -p auto_update --features workspace/test-support`
- `cargo check -p auto_update_helper --tests --target
x86_64-pc-windows-msvc`
- `./script/clippy -p gpui -p gpui_macos -p auto_update -p zed`
- `./script/clippy -p auto_update_helper --target
x86_64-pc-windows-msvc`

The regression tests cover simultaneous restart executable and argument
forwarding, Windows paths containing spaces and trailing backslashes,
updater-helper argument parsing, and the final `Zed.exe` command.

Compilation and unit tests were run on macOS. Linux and Windows runtime
behavior was reviewed but not run locally. A full `gpui_windows`
cross-target check was attempted, but its `psm` dependency requires the
MSVC `lib.exe` tool, which is not available on this macOS host; the
standalone Windows updater helper and its tests compile successfully for
the Windows target.

## Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content adheres to Zed's UI standards (no UI changes)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

---

Release Notes:

- Fixed Zed forgetting an explicitly passed `--user-data-dir` argument
after restarting (e.g. clicking "Restart to Update")

---------

Co-authored-by: Tom Houlé <tom@tomhoule.com>
This commit is contained in:
Kevin BravoandTom Houlé authored and GitHub committed 2026-08-19 14:32:19 +00:00
1 parent 2dd39da2b0
commit d7f85eed4e
13 files changed
+243 -62

No files matched your search

+3 -1
View File
@@ -1492,7 +1492,9 @@ mod tests {
);
let will_restart = cx.expect_restart();
cx.update(|cx| cx.restart());
let path = will_restart.await.unwrap().unwrap();
let (path, arguments) = will_restart.await.unwrap();
assert!(arguments.is_empty());
let path = path.unwrap();
assert_eq!(path, tmp_dir.path().join("zed"));
assert_eq!(std::fs::read_to_string(path).unwrap(), "<fake-zed-update>");
}
@@ -18,7 +18,10 @@ fn main() {}
#[cfg(target_os = "windows")]
mod windows_impl {
use std::{borrow::Cow, path::Path};
use std::{
ffi::{OsStr, OsString},
path::Path,
};
use super::dialog::create_dialog_window;
use super::updater::perform_update;
@@ -37,9 +40,10 @@ mod windows_impl {
pub(crate) const WM_JOB_UPDATED: u32 = WM_USER + 1;
pub(crate) const WM_TERMINATE: u32 = WM_USER + 2;
#[derive(Debug, Default)]
#[derive(Debug, PartialEq, Eq)]
struct Args {
launch: bool,
launch_arguments: Vec<OsString>,
}
pub(crate) fn run() -> Result<()> {
@@ -56,9 +60,14 @@ mod windows_impl {
log::info!("======= Starting Zed update =======");
let (tx, rx) = std::sync::mpsc::channel();
let hwnd = create_dialog_window(rx)?.0 as isize;
let args = parse_args(std::env::args().skip(1));
let args = parse_args(std::env::args_os().skip(1));
std::thread::spawn(move || {
let result = perform_update(app_dir.as_path(), Some(hwnd), args.launch);
let result = perform_update(
app_dir.as_path(),
Some(hwnd),
args.launch,
&args.launch_arguments,
);
tx.send(result).ok();
unsafe { PostMessageW(Some(HWND(hwnd as _)), WM_TERMINATE, WPARAM(0), LPARAM(0)) }.ok();
});
@@ -83,24 +92,23 @@ mod windows_impl {
Ok(())
}
fn parse_args(input: impl IntoIterator<Item = String>) -> Args {
let mut args: Args = Args { launch: true };
fn parse_args(input: impl IntoIterator<Item = OsString>) -> Args {
let mut args = Args {
launch: true,
launch_arguments: Vec::new(),
};
let mut input = input.into_iter();
if let Some(arg) = input.next() {
let launch_arg;
if arg == "--launch" {
launch_arg = input.next().map(Cow::Owned);
} else if let Some(rest) = arg.strip_prefix("--launch=") {
launch_arg = Some(Cow::Borrowed(rest));
if arg == OsStr::new("--launch") {
args.launch = input.next().as_deref() != Some(OsStr::new("false"));
} else if let Some(launch) = arg.to_str().and_then(|arg| arg.strip_prefix("--launch="))
{
args.launch = launch != "false";
} else {
launch_arg = None;
}
if launch_arg.as_deref() == Some("false") {
args.launch = false;
args.launch_arguments.push(arg);
}
args.launch_arguments.extend(input);
}
args
@@ -123,6 +131,8 @@ mod windows_impl {
#[cfg(test)]
mod tests {
use std::ffi::OsString;
use crate::windows_impl::parse_args;
#[test]
@@ -143,5 +153,17 @@ mod windows_impl {
assert!(parse_args(["--launch=".into()]).launch);
assert!(parse_args(["--launch=invalid".into()]).launch);
}
#[test]
fn test_parse_args_preserves_launch_arguments() {
let launch_arguments = vec![
OsString::from("--user-data-dir"),
OsString::from(r"C:\Zed Data"),
];
assert_eq!(
parse_args(launch_arguments.clone()).launch_arguments,
launch_arguments
);
}
}
}
+44 -7
View File
@@ -1,5 +1,5 @@
use std::{
ffi::OsStr,
ffi::{OsStr, OsString},
os::windows::ffi::OsStrExt,
path::Path,
sync::LazyLock,
@@ -363,7 +363,19 @@ fn release_file_handles(app_dir: &Path) -> Result<()> {
Ok(())
}
pub(crate) fn perform_update(app_dir: &Path, hwnd: Option<isize>, launch: bool) -> Result<()> {
#[allow(clippy::disallowed_methods, reason = "doesn't run in the main binary")]
fn zed_launch_command(app_dir: &Path, launch_arguments: &[OsString]) -> std::process::Command {
let mut command = std::process::Command::new(app_dir.join("Zed.exe"));
command.args(launch_arguments);
command
}
pub(crate) fn perform_update(
app_dir: &Path,
hwnd: Option<isize>,
launch: bool,
launch_arguments: &[OsString],
) -> Result<()> {
let hwnd = hwnd.map(|ptr| HWND(ptr as _));
// Try to release file handles before starting the update
@@ -428,7 +440,9 @@ pub(crate) fn perform_update(app_dir: &Path, hwnd: Option<isize>, launch: bool)
if launch {
#[allow(clippy::disallowed_methods, reason = "doesn't run in the main binary")]
let _ = std::process::Command::new(app_dir.join("Zed.exe")).spawn();
let _child = zed_launch_command(app_dir, launch_arguments)
.spawn()
.context("Failed to launch Zed after update")?;
}
log::info!("Update completed successfully");
Ok(())
@@ -436,19 +450,42 @@ pub(crate) fn perform_update(app_dir: &Path, hwnd: Option<isize>, launch: bool)
#[cfg(test)]
mod test {
use super::perform_update;
use std::{ffi::OsString, path::Path};
use super::{perform_update, zed_launch_command};
#[test]
fn test_zed_launch_command_preserves_arguments() {
let arguments = vec![
OsString::from("--user-data-dir"),
OsString::from(r"C:\Zed Data"),
];
let command = zed_launch_command(Path::new(r"C:\Program Files\Zed"), &arguments);
assert_eq!(
command.get_program(),
Path::new(r"C:\Program Files\Zed\Zed.exe").as_os_str()
);
assert_eq!(
command.get_args().collect::<Vec<_>>(),
arguments
.iter()
.map(OsString::as_os_str)
.collect::<Vec<_>>()
);
}
#[test]
fn test_perform_update() {
let app_dir = tempfile::tempdir().unwrap();
let app_dir = app_dir.path();
assert!(perform_update(app_dir, None, false).is_ok());
assert!(perform_update(app_dir, None, false, &[]).is_ok());
let app_dir = tempfile::tempdir().unwrap();
let app_dir = app_dir.path();
// Simulate a timeout
unsafe { std::env::set_var("ZED_AUTO_UPDATE", "err1") };
let ret = perform_update(app_dir, None, false);
let ret = perform_update(app_dir, None, false, &[]);
assert!(
ret.is_err_and(|e| e.to_string().as_str() == "Autoupdate failed, nothing to rollback")
);
@@ -457,7 +494,7 @@ mod test {
let app_dir = app_dir.path();
// Simulate a timeout
unsafe { std::env::set_var("ZED_AUTO_UPDATE", "err2") };
let ret = perform_update(app_dir, None, false);
let ret = perform_update(app_dir, None, false, &[]);
assert!(
ret.is_err_and(|e| e.to_string().as_str() == "Autoupdate failed, rollback successful")
);
+39 -2
View File
@@ -2,6 +2,7 @@ use scheduler::Instant;
use std::{
any::{TypeId, type_name},
cell::{BorrowMutError, Cell, Ref, RefCell, RefMut},
ffi::OsString,
marker::PhantomData,
mem,
ops::{Deref, DerefMut},
@@ -206,6 +207,12 @@ impl Application {
self
}
/// Configures arguments to pass when restarting the application.
pub fn with_restart_arguments(self, arguments: Vec<OsString>) -> Self {
self.0.borrow_mut().restart_arguments = arguments;
self
}
/// Sets the HTTP client for the application.
pub fn with_http_client(self, http_client: Arc<dyn HttpClient>) -> Self {
let mut context_lock = self.0.borrow_mut();
@@ -729,6 +736,7 @@ pub struct App {
pub(crate) pending_notifications: FxHashSet<EntityId>,
pub(crate) pending_global_notifications: TypeIdHashSet,
pub(crate) restart_path: Option<PathBuf>,
pub(crate) restart_arguments: Vec<OsString>,
pub(crate) layout_id_buffer: Vec<LayoutId>, // We recycle this memory across layout requests.
pub(crate) propagate_event: bool,
pub(crate) prompt_builder: Option<PromptBuilder>,
@@ -834,6 +842,7 @@ impl App {
quit_observers: SubscriberSet::new(),
restart_observers: SubscriberSet::new(),
restart_path: None,
restart_arguments: Vec::new(),
window_closed_observers: SubscriberSet::new(),
layout_id_buffer: Default::default(),
propagate_event: true,
@@ -1587,7 +1596,10 @@ impl App {
self.restart_observers
.clone()
.retain(&(), |observer| observer(self));
self.platform.restart(self.restart_path.take())
self.platform.restart(
self.restart_path.take(),
std::mem::take(&mut self.restart_arguments),
)
}
/// Sets the path to use when restarting the application.
@@ -3050,7 +3062,10 @@ impl<'a, T> Drop for GpuiBorrow<'a, T> {
#[cfg(test)]
mod test {
use std::{cell::RefCell, rc::Rc};
use std::{cell::RefCell, ffi::OsString, path::PathBuf, rc::Rc};
#[cfg(unix)]
use std::os::unix::ffi::OsStringExt;
use crate::{AppContext, TestAppContext};
@@ -3084,4 +3099,26 @@ mod test {
assert_eq!(*observation_count.borrow(), 2);
}
#[gpui::test]
async fn test_restart_preserves_path_and_arguments(cx: &mut TestAppContext) {
#[cfg(unix)]
let user_data_dir = OsString::from_vec(b"/tmp/zed data/\xff".to_vec());
#[cfg(not(unix))]
let user_data_dir = OsString::from("C:\\zed data");
let arguments = vec![OsString::from("--user-data-dir"), user_data_dir];
let restart_path = PathBuf::from("updated-zed");
let _application =
super::Application(cx.app.clone()).with_restart_arguments(arguments.clone());
let restart = cx.expect_restart();
cx.update(|cx| {
cx.set_restart_path(restart_path.clone());
cx.restart();
});
let (path, restart_arguments) = restart.await.expect("restart was not requested");
assert_eq!(path, Some(restart_path));
assert_eq!(restart_arguments, arguments);
}
}
+1 -1
View File
@@ -404,7 +404,7 @@ impl TestAppContext {
}
/// Returns true if there's an alert dialog open.
pub fn expect_restart(&self) -> oneshot::Receiver<Option<PathBuf>> {
pub fn expect_restart(&self) -> oneshot::Receiver<(Option<PathBuf>, Vec<std::ffi::OsString>)> {
let (tx, rx) = futures::channel::oneshot::channel();
self.test_platform.expect_restart.borrow_mut().replace(tx);
rx
+2 -1
View File
@@ -64,6 +64,7 @@ use std::io::Cursor;
use std::ops;
use std::time::Duration;
use std::{
ffi::OsString,
fmt::{self, Debug},
ops::Range,
path::{Path, PathBuf},
@@ -129,7 +130,7 @@ pub trait Platform: 'static {
fn run(&self, on_finish_launching: Box<dyn 'static + FnOnce()>);
fn quit(&self);
fn restart(&self, binary_path: Option<PathBuf>);
fn restart(&self, binary_path: Option<PathBuf>, arguments: Vec<OsString>);
fn activate(&self, ignoring_other_apps: bool);
fn hide(&self);
fn hide_other_apps(&self);
+4 -3
View File
@@ -35,7 +35,8 @@ pub(crate) struct TestPlatform {
pub opened_url: RefCell<Option<String>>,
pub(crate) system_notifications: RefCell<TestSystemNotifications>,
pub text_system: Arc<dyn PlatformTextSystem>,
pub expect_restart: RefCell<Option<oneshot::Sender<Option<PathBuf>>>>,
pub expect_restart:
RefCell<Option<oneshot::Sender<(Option<PathBuf>, Vec<std::ffi::OsString>)>>>,
headless_renderer_factory: Option<Box<dyn Fn() -> Option<Box<dyn PlatformHeadlessRenderer>>>>,
weak: Weak<Self>,
}
@@ -340,9 +341,9 @@ impl Platform for TestPlatform {
fn quit(&self) {}
fn restart(&self, path: Option<PathBuf>) {
fn restart(&self, path: Option<PathBuf>, arguments: Vec<std::ffi::OsString>) {
if let Some(tx) = self.expect_restart.take() {
tx.send(path).unwrap();
tx.send((path, arguments)).unwrap();
}
}
+1 -1
View File
@@ -83,7 +83,7 @@ impl Platform for VisualTestPlatform {
fn quit(&self) {}
fn restart(&self, _binary_path: Option<PathBuf>) {}
fn restart(&self, _binary_path: Option<PathBuf>, _arguments: Vec<std::ffi::OsString>) {}
fn activate(&self, _ignoring_other_apps: bool) {}
+5 -2
View File
@@ -285,7 +285,7 @@ impl<P: LinuxClient + 'static> Platform for LinuxPlatform<P> {
self.inner.compositor_name()
}
fn restart(&self, binary_path: Option<PathBuf>) {
fn restart(&self, binary_path: Option<PathBuf>, arguments: Vec<std::ffi::OsString>) {
use std::os::unix::process::CommandExt as _;
// get the process id of the current process
@@ -312,7 +312,9 @@ impl<P: LinuxClient + 'static> Platform for LinuxPlatform<P> {
sleep 0.1
done
"$1"
app_path="$1"
shift
"$app_path" "$@"
"#;
#[allow(
@@ -325,6 +327,7 @@ impl<P: LinuxClient + 'static> Platform for LinuxPlatform<P> {
.arg(script)
.arg(&app_pid)
.arg(&app_path)
.args(arguments)
.process_group(0)
.spawn();
+9 -2
View File
@@ -537,7 +537,7 @@ impl Platform for MacPlatform {
}
}
fn restart(&self, binary_path: Option<PathBuf>) {
fn restart(&self, binary_path: Option<PathBuf>, arguments: Vec<std::ffi::OsString>) {
use std::os::unix::process::CommandExt as _;
let app_pid = std::process::id().to_string();
@@ -557,7 +557,13 @@ impl Platform for MacPlatform {
while kill -0 $0 2> /dev/null; do
sleep 0.1
done
open "$1"
app_path="$1"
shift
if (($# > 0)); then
open "$app_path" --args "$@"
else
open "$app_path"
fi
"#;
#[allow(
@@ -569,6 +575,7 @@ impl Platform for MacPlatform {
.arg(script)
.arg(app_pid)
.arg(app_path)
.args(arguments)
.process_group(0)
.spawn();
+1 -1
View File
@@ -307,7 +307,7 @@ impl Platform for WebPlatform {
log::warn!("WebPlatform::quit called, but quitting is not supported in the browser .");
}
fn restart(&self, _binary_path: Option<PathBuf>) {}
fn restart(&self, _binary_path: Option<PathBuf>, _arguments: Vec<std::ffi::OsString>) {}
fn activate(&self, _ignoring_other_apps: bool) {}
+83 -19
View File
@@ -1,6 +1,7 @@
use std::{
cell::{Cell, RefCell},
ffi::OsStr,
ffi::{OsStr, OsString},
os::windows::ffi::{OsStrExt as _, OsStringExt as _},
path::{Path, PathBuf},
rc::{Rc, Weak},
sync::{
@@ -371,6 +372,39 @@ fn translate_accelerator(msg: &MSG) -> Option<()> {
(result.0 == 0).then_some(())
}
fn encode_restart_arguments(arguments: &[OsString]) -> OsString {
// `Start-Process` accepts a single native command line, so quote each argument according to
// the Windows argv parsing rules before passing the complete string through the environment.
let mut encoded = Vec::new();
for (index, argument) in arguments.iter().enumerate() {
if index > 0 {
encoded.push(b' ' as u16);
}
encoded.push(b'"' as u16);
let mut backslash_count = 0;
for code_unit in argument.encode_wide() {
if code_unit == b'\\' as u16 {
backslash_count += 1;
} else {
if code_unit == b'"' as u16 {
encoded.extend(std::iter::repeat_n(b'\\' as u16, backslash_count * 2 + 1));
} else {
encoded.extend(std::iter::repeat_n(b'\\' as u16, backslash_count));
}
backslash_count = 0;
encoded.push(code_unit);
}
}
encoded.extend(std::iter::repeat_n(b'\\' as u16, backslash_count * 2));
encoded.push(b'"' as u16);
}
OsString::from_wide(&encoded)
}
impl Platform for WindowsPlatform {
fn background_executor(&self) -> BackgroundExecutor {
self.background_executor.clone()
@@ -436,28 +470,33 @@ impl Platform for WindowsPlatform {
.detach();
}
fn restart(&self, binary_path: Option<PathBuf>) {
fn restart(&self, binary_path: Option<PathBuf>, arguments: Vec<OsString>) {
let pid = std::process::id();
let Some(app_path) = binary_path.or(self.app_path().log_err()) else {
return;
};
let script = format!(
r#"
$pidToWaitFor = {}
$exePath = "{}"
let script = r#"
$pidToWaitFor = $env:ZED_RESTART_PID
$exePath = $env:ZED_RESTART_EXECUTABLE
$argumentList = $env:ZED_RESTART_ARGUMENTS
while ($true) {{
[Environment]::SetEnvironmentVariable("ZED_RESTART_PID", $null)
[Environment]::SetEnvironmentVariable("ZED_RESTART_EXECUTABLE", $null)
[Environment]::SetEnvironmentVariable("ZED_RESTART_ARGUMENTS", $null)
while ($true) {
$process = Get-Process -Id $pidToWaitFor -ErrorAction SilentlyContinue
if (-not $process) {{
Start-Process -FilePath $exePath
if (-not $process) {
if ([string]::IsNullOrEmpty($argumentList)) {
Start-Process -FilePath $exePath
} else {
Start-Process -FilePath $exePath -ArgumentList $argumentList
}
break
}}
}
Start-Sleep -Seconds 0.1
}}
"#,
pid,
app_path.display(),
);
}
"#;
// Defer spawning to the foreground executor so it runs after the
// current `AppCell` borrow is released. On Windows, `Command::spawn()`
@@ -466,14 +505,19 @@ impl Platform for WindowsPlatform {
// borrow of the `AppCell` ending up with a double borrow panic
self.foreground_executor
.spawn(async move {
let mut command = new_std_command(get_windows_system_shell());
let arguments = encode_restart_arguments(&arguments);
command
.arg("-command")
.arg(script)
.env("ZED_RESTART_PID", pid.to_string())
.env("ZED_RESTART_EXECUTABLE", app_path)
.env("ZED_RESTART_ARGUMENTS", arguments);
#[allow(
clippy::disallowed_methods,
reason = "We are restarting ourselves, using std command thus is fine"
)]
let restart_process = new_std_command(get_windows_system_shell())
.arg("-command")
.arg(script)
.spawn();
let restart_process = command.spawn();
match restart_process {
Ok(_) => unsafe { PostQuitMessage(0) },
@@ -1493,9 +1537,29 @@ unsafe extern "system" fn window_procedure(
#[cfg(test)]
mod tests {
use std::ffi::{OsStr, OsString};
use crate::{read_from_clipboard, write_to_clipboard};
use gpui::ClipboardItem;
use super::encode_restart_arguments;
#[test]
fn test_encode_restart_arguments() {
assert_eq!(encode_restart_arguments(&[]), OsStr::new(""));
assert_eq!(
encode_restart_arguments(&[
OsString::from("--user-data-dir"),
OsString::from(r"C:\Zed Data"),
]),
OsStr::new(r#""--user-data-dir" "C:\Zed Data""#)
);
assert_eq!(
encode_restart_arguments(&[OsString::from(r"C:\")]),
OsStr::new(r#""C:\\""#)
);
}
#[test]
fn test_clipboard() {
let item = ClipboardItem::new_string("你好,我是张小白".to_string());
+12 -5
View File
@@ -268,10 +268,15 @@ fn main() {
return;
}
// Set custom data directory.
if let Some(dir) = &args.user_data_dir {
paths::set_custom_data_dir(dir);
}
let restart_arguments = if let Some(directory) = args.user_data_dir.as_deref() {
let directory = paths::set_custom_data_dir(directory);
vec![
std::ffi::OsString::from("--user-data-dir"),
directory.as_os_str().to_owned(),
]
} else {
Vec::new()
};
#[cfg(target_os = "windows")]
match util::get_zed_cli_path() {
@@ -340,7 +345,9 @@ fn main() {
#[cfg(windows)]
check_for_conpty_dll();
let app = build_application().with_assets(Assets);
let app = build_application()
.with_assets(Assets)
.with_restart_arguments(restart_arguments);
let app_db = db::AppDatabase::new();
let system_id = app.background_executor().spawn(system_id());