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 a557002d1..7f3382d18 100644 --- a/nebula_app/i18n/de-DE.json +++ b/nebula_app/i18n/de-DE.json @@ -205,5 +205,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 bf3bd01e5..b78ab010f 100644 --- a/nebula_app/i18n/en-US.json +++ b/nebula_app/i18n/en-US.json @@ -1989,5 +1989,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 6cc70dcc8..3bcb02740 100644 --- a/nebula_app/i18n/es-ES.json +++ b/nebula_app/i18n/es-ES.json @@ -205,5 +205,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 7d70fbe1f..e529c40b9 100644 --- a/nebula_app/i18n/fr-FR.json +++ b/nebula_app/i18n/fr-FR.json @@ -276,5 +276,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 78ed492dc..0465eee86 100644 --- a/nebula_app/i18n/it-IT.json +++ b/nebula_app/i18n/it-IT.json @@ -205,5 +205,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 aacbe79d8..4ef282f12 100644 --- a/nebula_app/i18n/ja-JP.json +++ b/nebula_app/i18n/ja-JP.json @@ -1619,5 +1619,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 a13df936d..e89182cdf 100644 --- a/nebula_app/i18n/ko-KR.json +++ b/nebula_app/i18n/ko-KR.json @@ -212,5 +212,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 4e5180f6e..cf7a27423 100644 --- a/nebula_app/i18n/pt-BR.json +++ b/nebula_app/i18n/pt-BR.json @@ -205,5 +205,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 faadc56a0..c059b0cfa 100644 --- a/nebula_app/i18n/ru-RU.json +++ b/nebula_app/i18n/ru-RU.json @@ -205,5 +205,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 3755cd2e7..6f4a0d51b 100644 --- a/nebula_app/i18n/zh-CN.json +++ b/nebula_app/i18n/zh-CN.json @@ -1989,5 +1989,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 c9fe5061a..acb6bebe2 100644 --- a/nebula_app/i18n/zh-TW.json +++ b/nebula_app/i18n/zh-TW.json @@ -1033,5 +1033,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 b3dd7b7f6..8db66a9aa 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 52fc11a2d..462fbcbd4 100644 --- a/nebula_app/src/runtime_api/server.rs +++ b/nebula_app/src/runtime_api/server.rs @@ -148,12 +148,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 都可缺省。 @@ -172,34 +167,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<()> {