From d0554f3146042952a25c94defb53717bc935af91 Mon Sep 17 00:00:00 2001 From: Kuddev <292709043+Kuddev@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:10:53 +0800 Subject: [PATCH] fix(startup): stop replaying unconfirmed window handovers --- ...10-29-consume-ambiguous-window-handover.md | 73 ++++++++++++ nebula_app/i18n/de-DE.json | 3 + nebula_app/i18n/en-US.json | 5 +- nebula_app/i18n/es-ES.json | 3 + nebula_app/i18n/fr-FR.json | 3 + nebula_app/i18n/it-IT.json | 3 + nebula_app/i18n/ja-JP.json | 3 + nebula_app/i18n/ko-KR.json | 3 + nebula_app/i18n/pt-BR.json | 3 + nebula_app/i18n/ru-RU.json | 3 + nebula_app/i18n/zh-CN.json | 5 +- nebula_app/i18n/zh-TW.json | 5 +- nebula_app/src/runtime_api.rs | 1 + nebula_app/src/runtime_api/cli.rs | 2 +- nebula_app/src/runtime_api/handover.rs | 72 ++++++++++++ nebula_app/src/runtime_api/handover_tests.rs | 107 ++++++++++++++++++ nebula_app/src/runtime_api/server.rs | 30 ++--- 17 files changed, 298 insertions(+), 26 deletions(-) create mode 100644 architecture/notes/nebula_app/runtime_api/2026-10-29-consume-ambiguous-window-handover.md create mode 100644 nebula_app/src/runtime_api/handover.rs create mode 100644 nebula_app/src/runtime_api/handover_tests.rs diff --git a/architecture/notes/nebula_app/runtime_api/2026-10-29-consume-ambiguous-window-handover.md b/architecture/notes/nebula_app/runtime_api/2026-10-29-consume-ambiguous-window-handover.md new file mode 100644 index 000000000..1b9154736 --- /dev/null +++ b/architecture/notes/nebula_app/runtime_api/2026-10-29-consume-ambiguous-window-handover.md @@ -0,0 +1,73 @@ +# Consume an ambiguously delivered GUI handover + +## Status + +Implemented; validation is recorded below. + +## Context + +Issue [#797](https://github.com/Kuddev/pebrel/issues/797) reports an Explorer +"Open in Pebrel" launch creating about 42 tabs and then another process. The +startup fallback retries up to 40 times. Previously each attempt sent `tab.new` +or `window.create`; a missing reply was treated as if no mutation had happened. +The initial attempt and final local window account for the additional launches. + +## Evidence + +- `runtime_api/server.rs` previously mapped every request error to `false`. +- `gpui_shell/mod.rs` retries that `false` result 40 times, then opens locally. +- A successful `ATTACH` establishes only focus/reveal, not creation completion. +- A timeout, peer close or response decoding failure after sending JSONL cannot + prove that the resident did not already create the tab/window. +- The report does not include a transport trace explaining its original missing + acknowledgment. The fix addresses the reproducible amplification mechanism. + +## Decision + +The startup handover pins the `ATTACH` and creation request to one endpoint. +Discovery or connection failure before writing remains retryable. After writing +may have begun, the launch is consumed whether the reply confirms completion or +leaves it unknown. Unknown outcomes never repeat creation or open a fallback. +A bounded, identity-checked JSONL reply is required to report confirmation. +The framing does not require a TCP write-half shutdown. + +The existing boolean startup API now means "launch consumed", not "creation +confirmed". An unconfirmed result logs a localized diagnostic and writes stderr; +Windows additionally uses the existing startup error dialog. Other platforms' +startup reporter has no GUI implementation, so this change does not claim a +cross-platform native error dialog. + +## Rejected alternatives + +- Retrying every failure: creation is not idempotent and can execute after a timeout. +- Increasing the timeout: delays failure without establishing delivery semantics. +- Returning success silently: conceals an outcome the client cannot confirm. +- Adding deduplication only to the new server: an old resident would still receive + repeated mutations from a new launcher during an upgrade. + +## Consequences + +A lost confirmation may require a manual retry after checking the resident. +The existing foreground process, saved sessions and other runtime commands keep +their ownership. No persistent request log or new background worker is added. + +## Validation + +Real TCP regressions cover complete, lost, delayed, truncated, mismatched and +rejected replies, plus connection refusal and the 40-attempt startup retry +contract. The positive path preserves the requested working directory. + +The supplied Linux host built the actual GPUI product test binary and passed all +99 Runtime tests, including the three new regressions. Its translation contract +also passed. Local architecture validation, all 55 checker regression tests, +formatting and whitespace checks passed. Windows Explorer reproduction itself +was not run locally; platform CI is required before merge. + +## Supersedes + +None. + +## Revisit when + +A versioned end-to-end idempotency contract exists for GUI creation, or native +startup error presentation is extended to other platforms. diff --git a/nebula_app/i18n/de-DE.json b/nebula_app/i18n/de-DE.json index b370b9e0d..20d744b93 100644 --- a/nebula_app/i18n/de-DE.json +++ b/nebula_app/i18n/de-DE.json @@ -191,5 +191,8 @@ "move_left": "Nach links verschieben", "move_right": "Nach rechts verschieben", "tab_color": "Tab-Farbe" + }, + "startup": { + "handover_unconfirmed": "Die laufende Pebrel-Instanz hat die Öffnungsanfrage nicht bestätigt. Der Tab oder das Fenster wurde möglicherweise bereits geöffnet. Prüfen Sie das vorhandene Fenster, bevor Sie es erneut versuchen. Dieser Start wiederholt die Anfrage nicht." } } diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json index 3317da804..98c344333 100644 --- a/nebula_app/i18n/en-US.json +++ b/nebula_app/i18n/en-US.json @@ -1975,5 +1975,8 @@ "session.tabs_reopened_after_crash": "After an unexpected exit, reopened {count} tabs. Sessions are starting; any startup errors remain in their tabs.", "terminal.ssh_session_stopped": "Session stopped.", "terminal.ssh_session_keys": "Press R to reconnect. Press Enter to close this terminal.", - "terminal.ssh_reconnecting": "Reconnecting…" + "terminal.ssh_reconnecting": "Reconnecting…", + "startup": { + "handover_unconfirmed": "The running Pebrel instance did not confirm the open request. It may already have opened the tab or window. Check the existing window before trying again; this launch will not retry." + } } diff --git a/nebula_app/i18n/es-ES.json b/nebula_app/i18n/es-ES.json index e73f3190a..42428aaa7 100644 --- a/nebula_app/i18n/es-ES.json +++ b/nebula_app/i18n/es-ES.json @@ -191,5 +191,8 @@ "move_left": "Mover a la izquierda", "move_right": "Mover a la derecha", "tab_color": "Color de la pestaña" + }, + "startup": { + "handover_unconfirmed": "La instancia de Pebrel en ejecución no confirmó la solicitud de apertura. Es posible que la pestaña o ventana ya se haya abierto. Compruebe la ventana existente antes de intentarlo de nuevo; este inicio no repetirá la solicitud." } } diff --git a/nebula_app/i18n/fr-FR.json b/nebula_app/i18n/fr-FR.json index 5584b4298..fd760b000 100644 --- a/nebula_app/i18n/fr-FR.json +++ b/nebula_app/i18n/fr-FR.json @@ -262,5 +262,8 @@ "move_left": "Déplacer vers la gauche", "move_right": "Déplacer vers la droite", "tab_color": "Couleur de l’onglet" + }, + "startup": { + "handover_unconfirmed": "L’instance Pebrel en cours n’a pas confirmé la demande d’ouverture. L’onglet ou la fenêtre existe peut-être déjà. Vérifiez la fenêtre existante avant de réessayer ; cette ouverture ne sera pas répétée automatiquement." } } diff --git a/nebula_app/i18n/it-IT.json b/nebula_app/i18n/it-IT.json index 20b91ae84..aee214494 100644 --- a/nebula_app/i18n/it-IT.json +++ b/nebula_app/i18n/it-IT.json @@ -191,5 +191,8 @@ "move_left": "Sposta a sinistra", "move_right": "Sposta a destra", "tab_color": "Colore della scheda" + }, + "startup": { + "handover_unconfirmed": "L’istanza di Pebrel in esecuzione non ha confermato la richiesta di apertura. La scheda o la finestra potrebbe essere già stata aperta. Controlla la finestra esistente prima di riprovare; questo avvio non ripeterà la richiesta." } } diff --git a/nebula_app/i18n/ja-JP.json b/nebula_app/i18n/ja-JP.json index 1e0a3f67d..923643545 100644 --- a/nebula_app/i18n/ja-JP.json +++ b/nebula_app/i18n/ja-JP.json @@ -1605,5 +1605,8 @@ "save_failed": "効果リストを保存できませんでした。設定の保存先に書き込めるか確認してから、もう一度お試しください。", "advanced": "高度な効果", "advanced_active": "高度な効果 · ターミナル効果が有効" + }, + "startup": { + "handover_unconfirmed": "起動中の Pebrel から、開く要求の確認がありませんでした。タブまたはウィンドウは既に開いている可能性があります。再試行する前に既存のウィンドウを確認してください。今回の起動では要求を再送信しません。" } } diff --git a/nebula_app/i18n/ko-KR.json b/nebula_app/i18n/ko-KR.json index 19f058664..da1631540 100644 --- a/nebula_app/i18n/ko-KR.json +++ b/nebula_app/i18n/ko-KR.json @@ -198,5 +198,8 @@ "move_left": "왼쪽으로 이동", "move_right": "오른쪽으로 이동", "tab_color": "탭 색상" + }, + "startup": { + "handover_unconfirmed": "실행 중인 Pebrel이 열기 요청을 확인하지 않았습니다. 탭이나 창이 이미 열렸을 수 있습니다. 다시 시도하기 전에 기존 창을 확인하세요. 이번 실행에서는 요청을 반복하지 않습니다." } } diff --git a/nebula_app/i18n/pt-BR.json b/nebula_app/i18n/pt-BR.json index f48ba32d0..81db5ff8e 100644 --- a/nebula_app/i18n/pt-BR.json +++ b/nebula_app/i18n/pt-BR.json @@ -191,5 +191,8 @@ "move_left": "Mover para a esquerda", "move_right": "Mover para a direita", "tab_color": "Cor da aba" + }, + "startup": { + "handover_unconfirmed": "A instância do Pebrel em execução não confirmou a solicitação de abertura. A aba ou janela pode já ter sido aberta. Verifique a janela existente antes de tentar novamente; esta inicialização não repetirá a solicitação." } } diff --git a/nebula_app/i18n/ru-RU.json b/nebula_app/i18n/ru-RU.json index e05e8a267..9691ee4ab 100644 --- a/nebula_app/i18n/ru-RU.json +++ b/nebula_app/i18n/ru-RU.json @@ -191,5 +191,8 @@ "move_left": "Переместить влево", "move_right": "Переместить вправо", "tab_color": "Цвет вкладки" + }, + "startup": { + "handover_unconfirmed": "Запущенный экземпляр Pebrel не подтвердил запрос на открытие. Вкладка или окно уже могли быть созданы. Проверьте существующее окно перед повторной попыткой; этот запуск не будет повторять запрос." } } diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json index ce2e47645..b52e7ffe6 100644 --- a/nebula_app/i18n/zh-CN.json +++ b/nebula_app/i18n/zh-CN.json @@ -1975,5 +1975,8 @@ "session.tabs_reopened_after_crash": "上次未正常退出,已重新打开 {count} 个标签。会话正在启动,启动错误会保留在对应标签中。", "terminal.ssh_session_stopped": "会话已结束。", "terminal.ssh_session_keys": "按 R 重新连接,按 Enter 关闭此终端。", - "terminal.ssh_reconnecting": "正在重新连接…" + "terminal.ssh_reconnecting": "正在重新连接…", + "startup": { + "handover_unconfirmed": "运行中的 Pebrel 未确认打开请求,标签页或窗口可能已经创建。请先检查已有窗口,再决定是否重试;本次启动不会重复发送请求。" + } } diff --git a/nebula_app/i18n/zh-TW.json b/nebula_app/i18n/zh-TW.json index f7103ada5..d0b06d726 100644 --- a/nebula_app/i18n/zh-TW.json +++ b/nebula_app/i18n/zh-TW.json @@ -1019,5 +1019,8 @@ "busy_other": "另一主機正在傳輸:{destination}" }, "terminal.ssh_disconnected": "連線已中斷,按 R 重新連線。", - "terminal.ssh_reconnect": "重新連線 (R)" + "terminal.ssh_reconnect": "重新連線 (R)", + "startup": { + "handover_unconfirmed": "執行中的 Pebrel 未確認開啟請求,分頁或視窗可能已經建立。請先檢查現有視窗,再決定是否重試;本次啟動不會重複傳送請求。" + } } diff --git a/nebula_app/src/runtime_api.rs b/nebula_app/src/runtime_api.rs index e126c82b0..ea8819447 100644 --- a/nebula_app/src/runtime_api.rs +++ b/nebula_app/src/runtime_api.rs @@ -10,6 +10,7 @@ mod cli; mod command; pub(crate) mod conversation; pub(crate) mod git; +mod handover; pub(crate) mod mobile_bridge; mod mobile_screen; mod orchestrate; diff --git a/nebula_app/src/runtime_api/cli.rs b/nebula_app/src/runtime_api/cli.rs index 33c6a8571..4cd71b0bc 100644 --- a/nebula_app/src/runtime_api/cli.rs +++ b/nebula_app/src/runtime_api/cli.rs @@ -56,7 +56,7 @@ pub(crate) fn request_once_bounded( }) } -fn decode_plugin_response( +pub(super) fn decode_plugin_response( request: &ApiRequest, bytes: &[u8], ) -> Result> { diff --git a/nebula_app/src/runtime_api/handover.rs b/nebula_app/src/runtime_api/handover.rs new file mode 100644 index 000000000..4c4353138 --- /dev/null +++ b/nebula_app/src/runtime_api/handover.rs @@ -0,0 +1,72 @@ +//! A GUI launch may retry discovery, but never an ambiguously delivered mutation. +use super::*; + +enum Outcome { + Unavailable, + Confirmed, + Unconfirmed, +} + +/// `true` means this launch is consumed, including a reported unknown outcome. +/// It does not claim that the resident finished creating the requested window. +pub(super) fn open(method: &str, params: Value) -> bool { + // Pin both operations to one resident; rediscovery between ATTACH and create + // could otherwise send the mutation to a different instance. + let Some(endpoint) = server::attach_endpoint() else { return false }; + match exchange(&endpoint, method, params, IO_TIMEOUT) { + Outcome::Unavailable => false, + Outcome::Confirmed => true, + Outcome::Unconfirmed => { + let language = crate::i18n::LanguagePreference::from( + nebula_settings::RuntimeSettings::load().language, + ) + .resolved(); + let message = language.text(crate::i18n::Message::StartupHandoverUnconfirmed); + log::warn!("{message}"); + eprintln!("{message}"); + crate::platform::startup::report_error(&message, true); + true + }, + } +} + +fn exchange(endpoint: &Endpoint, method: &str, params: Value, timeout: Duration) -> Outcome { + let request = ApiRequest::new(endpoint.token.clone(), method, params); + let Ok(mut bytes) = serde_json::to_vec(&request) else { return Outcome::Unconfirmed }; + bytes.push(b'\n'); + let Ok(mut stream) = TcpStream::connect_timeout(&endpoint_addr(endpoint), CONNECT_TIMEOUT) + else { + return Outcome::Unavailable; + }; + // All failures before the first write are safe to retry. After writing may + // have started, neither a timeout, EOF, rejection nor malformed reply proves + // that tab.new/window.create had no effect. Never replay or open a fallback. + if stream.set_read_timeout(Some(timeout)).is_err() + || stream.set_write_timeout(Some(timeout)).is_err() + { + return Outcome::Unavailable; + } + if stream.write_all(&bytes).is_err() { + return Outcome::Unconfirmed; + } + // JSONL is already framed. A half-close is unnecessary and can fail after + // a fast resident has replied, before we read its buffered response. + let mut line = Vec::new(); + if BufReader::new(stream) + .take((MAX_REQUEST_BYTES + 1) as u64) + .read_until(b'\n', &mut line) + .is_err() + || line.len() > MAX_REQUEST_BYTES + || line.last() != Some(&b'\n') + { + return Outcome::Unconfirmed; + } + match cli::decode_plugin_response(&request, &line) { + Ok(response) if response.ok => Outcome::Confirmed, + _ => Outcome::Unconfirmed, + } +} + +#[cfg(test)] +#[path = "handover_tests.rs"] +mod tests; diff --git a/nebula_app/src/runtime_api/handover_tests.rs b/nebula_app/src/runtime_api/handover_tests.rs new file mode 100644 index 000000000..0308e9f1e --- /dev/null +++ b/nebula_app/src/runtime_api/handover_tests.rs @@ -0,0 +1,107 @@ +use super::*; + +fn resident( + respond: impl FnOnce(&mut TcpStream, ApiRequest) + Send + 'static, +) -> (Endpoint, std::thread::JoinHandle<()>) { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let endpoint = + Endpoint { port: listener.local_addr().unwrap().port(), token: "fixture".into() }; + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + let mut line = String::new(); + BufReader::new(stream.try_clone().unwrap()).read_line(&mut line).unwrap(); + let request: ApiRequest = serde_json::from_str(&line).unwrap(); + assert!(matches!(request.method.as_str(), "tab.new" | "window.create")); + assert_eq!(request.params["cwd"], "/fixture/Chinese 空格"); + respond(&mut stream, request); + // No connection for a replay may be queued, even after a lost response. + listener.set_nonblocking(true).unwrap(); + assert!( + matches!(listener.accept(), Err(error) if error.kind() == std::io::ErrorKind::WouldBlock) + ); + }); + (endpoint, server) +} + +#[test] +fn lost_reply_does_not_repeat_the_creation_or_allow_a_fallback() { + for method in ["tab.new", "window.create"] { + let (endpoint, server) = resident(|_, _| {}); + let mut attempts = 0; + // Same contract as startup: only unavailable discovery/connect is retried. + for _ in 0..40 { + attempts += 1; + if !matches!( + exchange( + &endpoint, + method, + json!({"cwd":"/fixture/Chinese 空格"}), + Duration::from_secs(1) + ), + Outcome::Unavailable + ) { + break; + } + } + assert_eq!(attempts, 1); + server.join().unwrap(); + } +} + +#[test] +fn slow_resident_and_mismatched_replies_are_not_retry_signals() { + for mode in ["timeout", "wrong-id", "rejected", "truncated"] { + let (endpoint, server) = resident(move |stream, request| { + let mut reply = ApiResponse::success(request.id, json!({"tab_id": 7})); + match mode { + "timeout" => { + std::thread::sleep(Duration::from_millis(150)); + return; + }, + "wrong-id" => reply.id = "different-request".into(), + "rejected" => { + reply = + ApiResponse::failure(reply.id, ApiError::new("timeout", "may finish later")) + }, + "truncated" => { + stream.write_all(b"{\"ok\":true}").unwrap(); + return; + }, + _ => unreachable!(), + } + write_response(stream, &reply).unwrap(); + }); + assert!(matches!( + exchange( + &endpoint, + "tab.new", + json!({"cwd":"/fixture/Chinese 空格"}), + Duration::from_millis(40) + ), + Outcome::Unconfirmed + )); + server.join().unwrap(); + } +} + +#[test] +fn confirmed_reply_and_unavailable_resident_stay_distinct() { + let (endpoint, server) = resident(|stream, request| { + write_response(stream, &ApiResponse::success(request.id, json!({"tab_id": 7}))).unwrap(); + }); + assert!(matches!( + exchange( + &endpoint, + "tab.new", + json!({"cwd":"/fixture/Chinese 空格"}), + Duration::from_secs(1) + ), + Outcome::Confirmed + )); + server.join().unwrap(); + assert!(matches!( + exchange(&endpoint, "tab.new", json!({}), Duration::from_secs(1)), + Outcome::Unavailable + )); +} diff --git a/nebula_app/src/runtime_api/server.rs b/nebula_app/src/runtime_api/server.rs index af66f2e24..7bdd2ae18 100644 --- a/nebula_app/src/runtime_api/server.rs +++ b/nebula_app/src/runtime_api/server.rs @@ -136,12 +136,7 @@ pub fn try_open_directory_existing(dir: &std::path::Path, shell: Option<&str>) - /// 仍先发送 ATTACH,保证隐藏驻留进程被唤醒;真正的窗口由同一 GPUI App /// 创建,避免第二个进程争抢 runtime.port 和托盘所有权。 pub fn try_open_window_existing(dir: Option<&std::path::Path>, shell: Option<&str>) -> bool { - if legacy_request("ATTACH").is_none() { - return false; - } - cli::request_once("window.create", handover_params(dir, shell), IO_TIMEOUT) - .map(|response| response.ok) - .unwrap_or(false) + handover::open("window.create", handover_params(dir, shell)) } /// 交接请求的参数:目录与 shell 都可缺省。 @@ -160,34 +155,25 @@ fn handover_params(dir: Option<&std::path::Path>, shell: Option<&str>) -> serde_ } fn try_open_tab_existing(dir: Option<&std::path::Path>, shell: Option<&str>) -> bool { - if legacy_request("ATTACH").is_none() { - return false; - } - // ATTACH 与 tab.new 落到同一事件队列,窗口先恢复,新标签随后创建。 - cli::request_once("tab.new", handover_params(dir, shell), IO_TIMEOUT) - .map(|response| response.ok) - .unwrap_or(false) + handover::open("tab.new", handover_params(dir, shell)) } -fn legacy_request(verb: &str) -> Option<()> { +pub(super) fn attach_endpoint() -> Option { + let attach = |endpoint: Endpoint| legacy_request_to("ATTACH", &endpoint).map(|_| endpoint); let local = CHILD_ENDPOINT .lock() .unwrap_or_else(|error| error.into_inner()) .as_ref() .map(|hosted| hosted.endpoint.clone()); if let Some(endpoint) = local { - return legacy_request_to(verb, &endpoint); + return attach(endpoint); } if std::env::var_os(ENDPOINT_ENV).is_some() { - return read_endpoint().and_then(|endpoint| legacy_request_to(verb, &endpoint)); + return read_endpoint().and_then(attach); } read_endpoint() - .and_then(|endpoint| legacy_request_to(verb, &endpoint)) - // 已运行的 pre-v1 版本只发布 mux.port;升级期间仍允许普通启动交接。 - .or_else(|| { - read_endpoint_from(legacy_port_file()) - .and_then(|endpoint| legacy_request_to(verb, &endpoint)) - }) + .and_then(attach) + .or_else(|| read_endpoint_from(legacy_port_file()).and_then(attach)) } fn legacy_request_to(verb: &str, endpoint: &Endpoint) -> Option<()> {