Skip to main content

max / balanced_breakfast

Handle the dropped loopback writes and error-body read The OAuth loopback discarded every write_all and flush, so a browser that never received its confirmation page looked identical to one that did. All four sites go through one helper now, and whether the page reached the tab rides through to the /result payload, so the app can say the tab is safe to close. The handshake itself still succeeds: the code reaches the app through /result, not through that write. format_status_error dropped the error-body read, leaving a plugin author with a status code and an empty string. Partial bodies are kept and an unreadable one says so. The preview also truncates on a char boundary, which the old slice did not.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-27 23:29 UTC
Signed with PGP, not checked
Commit: 881cd658eee1e9dd59ccf2fcfcda246d484a361c
Parent: 800be18
3 files changed, +121 insertions, -55 deletions
@@ -183,6 +183,13 @@
183 183 clearInterval(pollInterval);
184 184
185 185 if (data.status === 'success' && data.code) {
186 + // The confirmation page never reached the browser, so
187 + // that tab is sitting on a request that will never
188 + // finish. Auth itself is fine; say so before it reads
189 + // as a failure.
190 + if (data.page_delivered === false) {
191 + BB.ui.showToast('Signed in. The browser tab did not load its confirmation page and can be closed.', 'success');
192 + }
186 193 await completeAuth(data.code, data.state, expectedState, codeVerifier, port);
187 194 } else if (data.status === 'error') {
188 195 BB.ui.showToast('Auth error: ' + (data.error || 'Unknown error'), 'error');
@@ -101,11 +101,71 @@
101 101 static CALLBACK_CANCEL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
102 102
103 103 /// Stored callback state for the /result polling endpoint.
104 + ///
105 + /// `page_delivered` records whether the browser tab actually received its
106 + /// confirmation page. The handshake itself does not depend on that write (the
107 + /// code reaches the app through `/result`), so a failed write must not fail the
108 + /// auth, but it does leave the user staring at a tab that never finishes
109 + /// loading. Carrying the flag through to the poll result is what lets the app
110 + /// say so instead of leaving the two cases indistinguishable.
104 111 #[derive(Clone)]
105 112 enum StoredCallback {
106 113 Pending,
107 - Success { code: String, state: String },
108 - Error { error: String },
114 + Success {
115 + code: String,
116 + state: String,
117 + page_delivered: bool,
118 + },
119 + Error {
120 + error: String,
121 + page_delivered: bool,
122 + },
123 + }
124 +
125 + /// Render the `/result` polling payload for the current callback state.
126 + fn result_json(stored: &StoredCallback) -> String {
127 + match stored {
128 + StoredCallback::Pending => r#"{"status":"pending"}"#.to_string(),
129 + StoredCallback::Success {
130 + code,
131 + state,
132 + page_delivered,
133 + } => format!(
134 + r#"{{"status":"success","code":"{}","state":"{}","page_delivered":{}}}"#,
135 + code.replace('"', "\\\""),
136 + state.replace('"', "\\\""),
137 + page_delivered
138 + ),
139 + StoredCallback::Error {
140 + error,
141 + page_delivered,
142 + } => format!(
143 + r#"{{"status":"error","error":"{}","page_delivered":{}}}"#,
144 + error.replace('"', "\\\""),
145 + page_delivered
146 + ),
147 + }
148 + }
149 +
150 + /// Write one loopback response and flush it, naming the site in the log if the
151 + /// socket is already gone.
152 + ///
153 + /// Every caller is writing to a browser tab that may have been closed, so the
154 + /// failure is not recoverable here; what it must not be is invisible. `site`
155 + /// says which response was lost, which is the difference between "the app never
156 + /// picked up the code" and "the user's tab looks stuck".
157 + fn write_loopback_response(stream: &mut std::net::TcpStream, site: &str, response: &str) -> bool {
158 + use std::io::Write;
159 + match stream
160 + .write_all(response.as_bytes())
161 + .and_then(|()| stream.flush())
162 + {
163 + Ok(()) => true,
164 + Err(e) => {
165 + tracing::warn!(site, error = %e, "OAuth loopback response not delivered");
166 + false
167 + }
168 + }
109 169 }
110 170
111 171 /// Start a minimal HTTP server on a random port that waits for the OAuth redirect.
@@ -129,7 +189,7 @@
129 189 let generation = CALLBACK_CANCEL.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
130 190
131 191 std::thread::spawn(move || {
132 - use std::io::{Read, Write};
192 + use std::io::Read;
133 193 use std::sync::{Arc, Mutex};
134 194
135 195 let stored = Arc::new(Mutex::new(StoredCallback::Pending));
@@ -156,29 +216,16 @@
156 216
157 217 // Handle /result polling endpoint
158 218 if path_only == "/result" {
159 - let json = match &*stored.lock().unwrap() {
160 - StoredCallback::Pending => r#"{"status":"pending"}"#.to_string(),
161 - StoredCallback::Success { code, state } => {
162 - format!(
163 - r#"{{"status":"success","code":"{}","state":"{}"}}"#,
164 - code.replace('"', "\\\""),
165 - state.replace('"', "\\\"")
166 - )
167 - }
168 - StoredCallback::Error { error } => {
169 - format!(
170 - r#"{{"status":"error","error":"{}"}}"#,
171 - error.replace('"', "\\\"")
172 - )
173 - }
174 - };
219 + let json = result_json(&stored.lock().unwrap());
175 220 let response = format!(
176 221 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nAccess-Control-Allow-Origin: *\r\nConnection: close\r\n\r\n{}",
177 222 json.len(),
178 223 json
179 224 );
180 - let _ = stream.write_all(response.as_bytes());
181 - let _ = stream.flush();
225 + // A dropped poll response is recoverable on its own: the
226 + // app polls once a second and the stored state is
227 + // unchanged, so the next one carries the same answer.
228 + write_loopback_response(&mut stream, "/result", &response);
182 229 continue;
183 230 }
184 231
@@ -200,29 +247,33 @@
200 247 }
201 248
202 249 if let Some(err) = error {
203 - *stored.lock().unwrap() = StoredCallback::Error { error: err };
204 250 let body = "<html><body><h1>Authentication failed</h1><p>You can close this tab.</p></body></html>";
205 251 let response = format!(
206 252 "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nAccess-Control-Allow-Origin: *\r\nConnection: close\r\n\r\n{}",
207 253 body.len(),
208 254 body
209 255 );
210 - let _ = stream.write_all(response.as_bytes());
211 - let _ = stream.flush();
256 + let page_delivered =
257 + write_loopback_response(&mut stream, "callback/error", &response);
258 + *stored.lock().unwrap() = StoredCallback::Error {
259 + error: err,
260 + page_delivered,
261 + };
212 262 callback_received = true;
213 263 } else if let (Some(code), Some(state)) = (code, cb_state) {
214 - *stored.lock().unwrap() = StoredCallback::Success {
215 - code: code.clone(),
216 - state: state.clone(),
217 - };
218 264 let body = "<html><body><h1>Authenticated</h1><p>You can close this tab and return to Balanced Breakfast.</p></body></html>";
219 265 let response = format!(
220 266 "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nAccess-Control-Allow-Origin: *\r\nConnection: close\r\n\r\n{}",
221 267 body.len(),
222 268 body
223 269 );
224 - let _ = stream.write_all(response.as_bytes());
225 - let _ = stream.flush();
270 + let page_delivered =
271 + write_loopback_response(&mut stream, "callback/success", &response);
272 + *stored.lock().unwrap() = StoredCallback::Success {
273 + code: code.clone(),
274 + state: state.clone(),
275 + page_delivered,
276 + };
226 277 callback_received = true;
227 278 }
228 279 }
@@ -252,31 +303,13 @@
252 303 .unwrap_or("/");
253 304
254 305 if path.starts_with("/result") {
255 - let json = match &*stored.lock().unwrap() {
256 - StoredCallback::Pending => {
257 - r#"{"status":"pending"}"#.to_string()
258 - }
259 - StoredCallback::Success { code, state } => {
260 - format!(
261 - r#"{{"status":"success","code":"{}","state":"{}"}}"#,
262 - code.replace('"', "\\\""),
263 - state.replace('"', "\\\"")
264 - )
265 - }
266 - StoredCallback::Error { error } => {
267 - format!(
268 - r#"{{"status":"error","error":"{}"}}"#,
269 - error.replace('"', "\\\"")
270 - )
271 - }
272 - };
306 + let json = result_json(&stored.lock().unwrap());
273 307 let response = format!(
274 308 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nAccess-Control-Allow-Origin: *\r\nConnection: close\r\n\r\n{}",
275 309 json.len(),
276 310 json
277 311 );
278 - let _ = stream.write_all(response.as_bytes());
279 - let _ = stream.flush();
312 + write_loopback_response(&mut stream, "/result", &response);
280 313 }
281 314 }
282 315 Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
@@ -203,6 +203,24 @@
203 203 /// The 400 cutoff mirrors ureq 2, which only raised `Error::Status` at 400 and
204 204 /// above. A 3xx therefore stays a normal response, as it was under
205 205 /// `redirects(0)`.
206 + /// How much of a failing response body to quote back to the plugin author.
207 + const BODY_PREVIEW_BYTES: usize = 200;
208 +
209 + /// Truncate to at most `max` bytes without splitting a UTF-8 character. A
210 + /// lossy-decoded error body is arbitrary bytes, so a plain `&s[..max]` is a
211 + /// panic waiting for a response that happens to put a multi-byte character on
212 + /// the boundary.
213 + fn truncate_preview(body: &str, max: usize) -> &str {
214 + if body.len() <= max {
215 + return body;
216 + }
217 + let mut end = max;
218 + while end > 0 && !body.is_char_boundary(end) {
219 + end -= 1;
220 + }
221 + &body[..end]
222 + }
223 +
206 224 fn format_status_error(resp: &mut ureq::http::Response<ureq::Body>) -> Option<String> {
207 225 let status = resp.status().as_u16();
208 226 if status < 400 {
@@ -213,16 +231,24 @@
213 231
214 232 // Cap error body read to prevent OOM on malicious large error responses
215 233 let mut bytes = Vec::new();
216 - let _ = resp
234 + let read = resp
217 235 .body_mut()
218 236 .as_reader()
219 237 .take(MAX_RESPONSE_BYTES)
220 238 .read_to_end(&mut bytes);
221 239 let body = String::from_utf8_lossy(&bytes);
222 - let body_preview = if body.len() > 200 {
223 - &body[..200]
224 - } else {
225 - &body
240 + // A plugin author debugging a failing feed has the status code and this
241 + // preview and nothing else, so a body that could not be read says so rather
242 + // than arriving as an empty string that reads like an empty response.
243 + // Whatever was read before the failure is kept: a partial body is usually
244 + // the part that names the problem.
245 + let body_preview = match read {
246 + Ok(_) => truncate_preview(&body, BODY_PREVIEW_BYTES).to_string(),
247 + Err(e) if bytes.is_empty() => format!("<error body unreadable: {e}>"),
248 + Err(e) => format!(
249 + "{} <error body truncated, read failed: {e}>",
250 + truncate_preview(&body, BODY_PREVIEW_BYTES)
251 + ),
226 252 };
227 253
228 254 Some(match status {