Skip to content

Commit 609f75a

Browse files
Fix hang on second oauth login attempt (#4568)
This PR fixes a bug that results in a hang in the oauth login flow if a user logs in, then logs out, then logs in again without first closing the browser window. Root cause of problem: We use a local web server for the oauth flow, and it's implemented using the `tiny_http` rust crate. During the first login, a socket is created between the browser and the server. The `tiny_http` library creates worker threads that persist for as long as this socket remains open. Currently, there's no way to close the connection on the server side — the library provides no API to do this. The library also filters all "Connect: close" headers, which makes it difficult to tell the client browser to close the connection. On the second login attempt, the browser uses the existing connection rather than creating a new one. Since that connection is associated with a server instance that no longer exists, it is effectively ignored. I considered switching from `tiny_http` to a different web server library, but that would have been a big change with significant regression risk. This PR includes a more surgical fix that works around the limitation of `tiny_http` and sends a "Connect: close" header on the last "success" page of the oauth flow.
1 parent eabe187 commit 609f75a

File tree

1 file changed

+66
-12
lines changed

1 file changed

+66
-12
lines changed

codex-rs/login/src/server.rs

Lines changed: 66 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ use tiny_http::Header;
2525
use tiny_http::Request;
2626
use tiny_http::Response;
2727
use tiny_http::Server;
28+
use tiny_http::StatusCode;
2829

2930
const DEFAULT_ISSUER: &str = "https://auth.openai.com";
3031
const DEFAULT_PORT: u16 = 1455;
@@ -148,8 +149,15 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result<LoginServer> {
148149
let _ = tokio::task::spawn_blocking(move || req.respond(response)).await;
149150
None
150151
}
151-
HandledRequest::ResponseAndExit { response, result } => {
152-
let _ = tokio::task::spawn_blocking(move || req.respond(response)).await;
152+
HandledRequest::ResponseAndExit {
153+
headers,
154+
body,
155+
result,
156+
} => {
157+
let _ = tokio::task::spawn_blocking(move || {
158+
send_response_with_disconnect(req, headers, body)
159+
})
160+
.await;
153161
Some(result)
154162
}
155163
HandledRequest::RedirectWithHeader(header) => {
@@ -185,7 +193,8 @@ enum HandledRequest {
185193
Response(Response<Cursor<Vec<u8>>>),
186194
RedirectWithHeader(Header),
187195
ResponseAndExit {
188-
response: Response<Cursor<Vec<u8>>>,
196+
headers: Vec<Header>,
197+
body: Vec<u8>,
189198
result: io::Result<()>,
190199
},
191200
}
@@ -275,20 +284,21 @@ async fn process_request(
275284
}
276285
"/success" => {
277286
let body = include_str!("assets/success.html");
278-
let mut resp = Response::from_data(body.as_bytes());
279-
if let Ok(h) = tiny_http::Header::from_bytes(
280-
&b"Content-Type"[..],
281-
&b"text/html; charset=utf-8"[..],
282-
) {
283-
resp.add_header(h);
284-
}
285287
HandledRequest::ResponseAndExit {
286-
response: resp,
288+
headers: match Header::from_bytes(
289+
&b"Content-Type"[..],
290+
&b"text/html; charset=utf-8"[..],
291+
) {
292+
Ok(header) => vec![header],
293+
Err(_) => Vec::new(),
294+
},
295+
body: body.as_bytes().to_vec(),
287296
result: Ok(()),
288297
}
289298
}
290299
"/cancel" => HandledRequest::ResponseAndExit {
291-
response: Response::from_string("Login cancelled"),
300+
headers: Vec::new(),
301+
body: b"Login cancelled".to_vec(),
292302
result: Err(io::Error::new(
293303
io::ErrorKind::Interrupted,
294304
"Login cancelled",
@@ -298,6 +308,50 @@ async fn process_request(
298308
}
299309
}
300310

311+
/// tiny_http filters `Connection` headers out of `Response` objects, so using
312+
/// `req.respond` never informs the client (or the library) that a keep-alive
313+
/// socket should be closed. That leaves the per-connection worker parked in a
314+
/// loop waiting for more requests, which in turn causes the next login attempt
315+
/// to hang on the old connection. This helper bypasses tiny_http’s response
316+
/// machinery: it extracts the raw writer, prints the HTTP response manually,
317+
/// and always appends `Connection: close`, ensuring the socket is closed from
318+
/// the server side. Ideally, tiny_http would provide an API to control
319+
/// server-side connection persistence, but it does not.
320+
fn send_response_with_disconnect(
321+
req: Request,
322+
mut headers: Vec<Header>,
323+
body: Vec<u8>,
324+
) -> io::Result<()> {
325+
let status = StatusCode(200);
326+
let mut writer = req.into_writer();
327+
let reason = status.default_reason_phrase();
328+
write!(writer, "HTTP/1.1 {} {}\r\n", status.0, reason)?;
329+
headers.retain(|h| !h.field.equiv("Connection"));
330+
if let Ok(close_header) = Header::from_bytes(&b"Connection"[..], &b"close"[..]) {
331+
headers.push(close_header);
332+
}
333+
334+
let content_length_value = format!("{}", body.len());
335+
if let Ok(content_length_header) =
336+
Header::from_bytes(&b"Content-Length"[..], content_length_value.as_bytes())
337+
{
338+
headers.push(content_length_header);
339+
}
340+
341+
for header in headers {
342+
write!(
343+
writer,
344+
"{}: {}\r\n",
345+
header.field.as_str(),
346+
header.value.as_str()
347+
)?;
348+
}
349+
350+
writer.write_all(b"\r\n")?;
351+
writer.write_all(&body)?;
352+
writer.flush()
353+
}
354+
301355
fn build_authorize_url(
302356
issuer: &str,
303357
client_id: &str,

0 commit comments

Comments
 (0)