@@ -140,9 +140,11 @@ pub enum ConnStatus {
140140 Online,
141141}
142142
143−#[derive(Clone, Copy, PartialEq, Eq, Debug)]
143+#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
144144pub enum Page {
145− Setup,
145+ /// The shell renders a spinner until `IdentityLoaded` arrives, so there is
146+ /// no distinct pre-identity page — conversations is the landing page.
147+ #[default]
146148 Conversations,
147149 Chat,
148150 AddContact,
@@ -162,6 +164,9 @@ pub struct Model {
162164 token: Option<String>,
163165 conn: ConnStatus,
164166 poll_retries: u32,
167+ /// A poll request is in flight. Guards against stacking concurrent long-poll
168+ /// loops (every extra loop re-polls itself forever and never terminates).
169+ polling: bool,
165170 flushing: bool,
166171 page: Page,
167172 active_peer: Option<String>,
@@ -183,8 +188,9 @@ impl Default for Model {
183188 token: None,
184189 conn: ConnStatus::Offline,
185190 poll_retries: 0,
191+ polling: false,
186192 flushing: false,
187− page: Page::Setup,
193+ page: Page::default(),
188194 active_peer: None,
189195 compose: String::new(),
190196 error: None,
@@ -220,6 +226,30 @@ fn server_host(url: &str) -> String {
220226 host.trim_end_matches('.').to_lowercase()
221227}
222228
229+/// Validate and canonicalize a user-entered relay URL.
230+///
231+/// The shell hands us raw text-field contents, and `Http::post` panics on a URL
232+/// with no scheme — so a schemeless entry must be rejected here, in the core,
233+/// before it can ever reach an effect. Returns the normalized URL (lowercased
234+/// scheme, no trailing slash) or `None` if it isn't an absolute http(s) URL.
235+fn normalize_server_url(input: &str) -> Option<String> {
236+ let (scheme, rest) = input.trim().split_once("://")?;
237+ if !scheme.eq_ignore_ascii_case("http") && !scheme.eq_ignore_ascii_case("https") {
238+ return None;
239+ }
240+ let rest = rest.trim_end_matches('/');
241+ if rest.chars().any(char::is_whitespace) {
242+ return None;
243+ }
244+ // Require a non-empty authority: reject "http://", "http:///x", "http://:80".
245+ let authority = rest.split('/').next().unwrap_or("");
246+ let host = authority.split('@').next_back().unwrap_or(authority);
247+ if host.split(':').next().unwrap_or("").is_empty() {
248+ return None;
249+ }
250+ Some(format!("{}://{}", scheme.to_lowercase(), rest))
251+}
252+
223253fn json_bytes<T: Serialize>(value: &T) -> Vec<u8> {
224254 serde_json::to_vec(value).unwrap_or_default()
225255}
@@ -391,6 +421,11 @@ impl App for Skrepka {
391421 render()
392422 }
393423 Event::SetServerUrl(url) => {
424+ let Some(url) = normalize_server_url(&url) else {
425+ model.error = Some("server URL must start with http:// or https://".into());
426+ return render();
427+ };
428+ model.error = None;
394429 model.settings.server_url = url;
395430 model.token = None;
396431 model.conn = ConnStatus::Offline;
@@ -484,7 +519,11 @@ impl App for Skrepka {
484519 };
485520 match Http::post(url).body_json(&body) {
486521 Ok(req) => req.build().then_send(Event::ChallengeResult).and(render()),
487− Err(_) => render(),
522+ Err(_) => {
523+ model.conn = ConnStatus::Offline;
524+ model.error = Some("could not build auth request".into());
525+ self.schedule_reconnect(model)
526+ }
488527 }
489528 }
490529 Event::ChallengeResult(Ok(mut resp)) => {
@@ -505,7 +544,11 @@ impl App for Skrepka {
505544 };
506545 match Http::post(url).body_json(&body) {
507546 Ok(req) => req.build().then_send(Event::VerifyResult),
508− Err(_) => render(),
547+ Err(_) => {
548+ model.conn = ConnStatus::Offline;
549+ model.error = Some("could not build auth request".into());
550+ self.schedule_reconnect(model)
551+ }
509552 }
510553 }
511554 Err(_) => {
@@ -543,6 +586,11 @@ impl App for Skrepka {
543586
544587 // ---------------- poll ----------------
545588 Event::Poll => {
589+ // Exactly one poll loop at a time: each PollResult re-issues Poll,
590+ // so a second concurrent loop would double forever.
591+ if model.polling {
592+ return Command::done();
593+ }
546594 let Some(token) = model.token.clone() else {
547595 return Command::event(Event::Authenticate);
548596 };
@@ -554,11 +602,18 @@ impl App for Skrepka {
554602 .header("authorization", format!("Bearer {token}"))
555603 .body_json(&body)
556604 {
557− Ok(req) => req.build().then_send(Event::PollResult),
558− Err(_) => render(),
605+ Ok(req) => {
606+ model.polling = true;
607+ req.build().then_send(Event::PollResult)
608+ }
609+ Err(_) => {
610+ model.error = Some("could not build poll request".into());
611+ self.backoff_poll(model)
612+ }
559613 }
560614 }
561615 Event::PollResult(Ok(mut resp)) => {
616+ model.polling = false;
562617 let status = u16::from(resp.status());
563618 if status == 401 {
564619 model.token = None;
@@ -576,15 +631,20 @@ impl App for Skrepka {
576631 .and(Command::event(Event::Poll))
577632 .and(render())
578633 }
579− Event::PollResult(Err(_)) => self.backoff_poll(model),
634+ Event::PollResult(Err(_)) => {
635+ model.polling = false;
636+ self.backoff_poll(model)
637+ }
580638
581639 // ---------------- outbox ----------------
582640 Event::StartFlush => self.flush_next(model),
583641 Event::SendResult(Ok(resp)) => {
642+ // The in-flight send is over; `flush_next` refuses to run while
643+ // this is set, so it must be cleared before *any* branch below.
644+ model.flushing = false;
584645 let status = u16::from(resp.status());
585646 if status == 401 {
586647 model.token = None;
587− model.flushing = false;
588648 return Command::event(Event::Authenticate);
589649 }
590650 if (200..300).contains(&status) || status == 400 {
@@ -593,7 +653,6 @@ impl App for Skrepka {
593653 return self.persist_outbox(model).and(self.flush_next(model));
594654 }
595655 // transient error — stop; will resume on next connect/poll.
596− model.flushing = false;
597656 render()
598657 }
599658 Event::SendResult(Err(_)) => {
@@ -605,7 +664,6 @@ impl App for Skrepka {
605664
606665 fn view(&self, model: &Model) -> ViewModel {
607666 let page = match model.page {
608− Page::Setup => "setup",
609667 Page::Conversations => "conversations",
610668 Page::Chat => "chat",
611669 Page::AddContact => "add_contact",
@@ -817,7 +875,10 @@ impl Skrepka {
817875 {
818876 Ok(req) => req.build().then_send(Event::SendResult),
819877 Err(_) => {
878+ // Not a dead loop: the next StartFlush (from a poll page or a
879+ // fresh send) retries the head of the outbox.
820880 model.flushing = false;
881+ model.error = Some("could not build send request".into());
821882 render()
822883 }
823884 }
@@ -950,10 +1011,11 @@ mod tests {
9501011
9511012 fn with_identity() -> Model {
9521013 let id = Identity::from_seed(&[3u8; 32]);
953− let mut m = Model::default();
954− m.secret_key = Some(id.secret_key.to_vec());
955− m.my_pubkey = id.public_key_hex();
956− m
1014+ Model {
1015+ secret_key: Some(id.secret_key.to_vec()),
1016+ my_pubkey: id.public_key_hex(),
1017+ ..Default::default()
1018+ }
9571019 }
9581020
9591021 fn peer_hex(seed: u8) -> String {
@@ -1058,6 +1120,112 @@ mod tests {
10581120 assert!(vm.contacts.iter().any(|c| c.pubkey == peer && c.blocked));
10591121 }
10601122
1123+ #[test]
1124+ fn normalize_server_url_requires_an_http_scheme() {
1125+ assert_eq!(
1126+ normalize_server_url(" https://Relay.Example.com/ "),
1127+ Some("https://Relay.Example.com".to_string())
1128+ );
1129+ assert_eq!(
1130+ normalize_server_url("HTTP://localhost:8080///"),
1131+ Some("http://localhost:8080".to_string())
1132+ );
1133+ // The cases that used to panic Http::post or produce a dead URL.
1134+ assert_eq!(normalize_server_url("relay.example.com"), None);
1135+ assert_eq!(normalize_server_url("localhost:8080"), None);
1136+ assert_eq!(normalize_server_url("ftp://relay.example.com"), None);
1137+ assert_eq!(normalize_server_url("http://"), None);
1138+ assert_eq!(normalize_server_url("http://:8080"), None);
1139+ assert_eq!(normalize_server_url("https://relay example.com"), None);
1140+ assert_eq!(normalize_server_url(""), None);
1141+ }
1142+
1143+ #[test]
1144+ fn set_server_url_rejects_schemeless_and_keeps_the_old_url() {
1145+ let app = Skrepka;
1146+ let mut m = with_identity();
1147+ let original = m.settings.server_url.clone();
1148+
1149+ let _ = app.update(Event::SetServerUrl("relay.example.com".into()), &mut m);
1150+ assert_eq!(m.settings.server_url, original, "bad URL must not persist");
1151+ assert!(m.error.is_some());
1152+
1153+ let _ = app.update(
1154+ Event::SetServerUrl(" https://relay.example.com/ ".into()),
1155+ &mut m,
1156+ );
1157+ assert_eq!(m.settings.server_url, "https://relay.example.com");
1158+ assert!(m.error.is_none());
1159+ assert!(m.token.is_none());
1160+ assert_eq!(m.conn, ConnStatus::Offline);
1161+ }
1162+
1163+ /// The regression that stalled the outbox: `flushing` stayed `true` forever
1164+ /// after the first successful send, so nothing was ever sent again.
1165+ #[test]
1166+ fn send_result_ok_clears_flushing() {
1167+ let app = Skrepka;
1168+ let mut m = with_identity();
1169+ m.token = Some("t".into());
1170+ m.flushing = true;
1171+ m.outbox.push_back(OutboxItem {
1172+ recipient: peer_hex(9),
1173+ envelope_json: "{}".into(),
1174+ });
1175+
1176+ let resp = crux_http::testing::ResponseBuilder::ok().body(Vec::new()).build();
1177+ let _ = app.update(Event::SendResult(Ok(resp)), &mut m);
1178+
1179+ assert!(!m.flushing, "flushing must be cleared on the success path");
1180+ assert!(m.outbox.is_empty(), "the sent item is dropped from the outbox");
1181+ }
1182+
1183+ #[test]
1184+ fn flush_next_resumes_after_a_successful_send() {
1185+ let app = Skrepka;
1186+ let mut m = with_identity();
1187+ m.token = Some("t".into());
1188+ for _ in 0..2 {
1189+ m.outbox.push_back(OutboxItem {
1190+ recipient: peer_hex(9),
1191+ envelope_json: "{}".into(),
1192+ });
1193+ }
1194+ // First send goes out and marks the model as flushing.
1195+ let _ = app.update(Event::StartFlush, &mut m);
1196+ assert!(m.flushing);
1197+
1198+ // Its success must both drop the item and let the *next* one go out.
1199+ let resp = crux_http::testing::ResponseBuilder::ok().body(Vec::new()).build();
1200+ let _ = app.update(Event::SendResult(Ok(resp)), &mut m);
1201+ assert_eq!(m.outbox.len(), 1);
1202+ assert!(m.flushing, "the second item is now in flight");
1203+ }
1204+
1205+ #[test]
1206+ fn poll_is_a_noop_while_a_poll_is_already_in_flight() {
1207+ let app = Skrepka;
1208+ let mut m = with_identity();
1209+ m.token = Some("t".into());
1210+
1211+ let _ = app.update(Event::Poll, &mut m);
1212+ assert!(m.polling, "the first Poll issues a request");
1213+
1214+ // A second Poll (e.g. from a re-auth) must not stack another loop.
1215+ let _ = app.update(Event::Poll, &mut m);
1216+ assert!(m.polling);
1217+
1218+ // Both success and failure release the guard so the loop can continue.
1219+ let resp = crux_http::testing::ResponseBuilder::ok().body(Vec::new()).build();
1220+ let _ = app.update(Event::PollResult(Ok(resp)), &mut m);
1221+ assert!(!m.polling);
1222+
1223+ let _ = app.update(Event::Poll, &mut m);
1224+ assert!(m.polling);
1225+ let _ = app.update(Event::PollResult(Err(crux_http::HttpError::Timeout)), &mut m);
1226+ assert!(!m.polling);
1227+ }
1228+
10611229 #[test]
10621230 fn view_exposes_identity_and_ob() {
10631231 let app = Skrepka;