Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions nebula_app/i18n/de-DE.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
5 changes: 4 additions & 1 deletion nebula_app/i18n/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
3 changes: 3 additions & 0 deletions nebula_app/i18n/es-ES.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
3 changes: 3 additions & 0 deletions nebula_app/i18n/fr-FR.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
3 changes: 3 additions & 0 deletions nebula_app/i18n/it-IT.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
3 changes: 3 additions & 0 deletions nebula_app/i18n/ja-JP.json
Original file line number Diff line number Diff line change
Expand Up @@ -1605,5 +1605,8 @@
"save_failed": "効果リストを保存できませんでした。設定の保存先に書き込めるか確認してから、もう一度お試しください。",
"advanced": "高度な効果",
"advanced_active": "高度な効果 · ターミナル効果が有効"
},
"startup": {
"handover_unconfirmed": "起動中の Pebrel から、開く要求の確認がありませんでした。タブまたはウィンドウは既に開いている可能性があります。再試行する前に既存のウィンドウを確認してください。今回の起動では要求を再送信しません。"
}
}
3 changes: 3 additions & 0 deletions nebula_app/i18n/ko-KR.json
Original file line number Diff line number Diff line change
Expand Up @@ -198,5 +198,8 @@
"move_left": "왼쪽으로 이동",
"move_right": "오른쪽으로 이동",
"tab_color": "탭 색상"
},
"startup": {
"handover_unconfirmed": "실행 중인 Pebrel이 열기 요청을 확인하지 않았습니다. 탭이나 창이 이미 열렸을 수 있습니다. 다시 시도하기 전에 기존 창을 확인하세요. 이번 실행에서는 요청을 반복하지 않습니다."
}
}
3 changes: 3 additions & 0 deletions nebula_app/i18n/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
3 changes: 3 additions & 0 deletions nebula_app/i18n/ru-RU.json
Original file line number Diff line number Diff line change
Expand Up @@ -191,5 +191,8 @@
"move_left": "Переместить влево",
"move_right": "Переместить вправо",
"tab_color": "Цвет вкладки"
},
"startup": {
"handover_unconfirmed": "Запущенный экземпляр Pebrel не подтвердил запрос на открытие. Вкладка или окно уже могли быть созданы. Проверьте существующее окно перед повторной попыткой; этот запуск не будет повторять запрос."
}
}
5 changes: 4 additions & 1 deletion nebula_app/i18n/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 未确认打开请求,标签页或窗口可能已经创建。请先检查已有窗口,再决定是否重试;本次启动不会重复发送请求。"
}
}
5 changes: 4 additions & 1 deletion nebula_app/i18n/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -1019,5 +1019,8 @@
"busy_other": "另一主機正在傳輸:{destination}"
},
"terminal.ssh_disconnected": "連線已中斷,按 R 重新連線。",
"terminal.ssh_reconnect": "重新連線 (R)"
"terminal.ssh_reconnect": "重新連線 (R)",
"startup": {
"handover_unconfirmed": "執行中的 Pebrel 未確認開啟請求,分頁或視窗可能已經建立。請先檢查現有視窗,再決定是否重試;本次啟動不會重複傳送請求。"
}
}
1 change: 1 addition & 0 deletions nebula_app/src/runtime_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion nebula_app/src/runtime_api/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ApiResponse, Box<dyn Error>> {
Expand Down
72 changes: 72 additions & 0 deletions nebula_app/src/runtime_api/handover.rs
Original file line number Diff line number Diff line change
@@ -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;
107 changes: 107 additions & 0 deletions nebula_app/src/runtime_api/handover_tests.rs
Original file line number Diff line number Diff line change
@@ -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
));
}
Loading
Loading