summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-10-28 22:38:54 +0100
committersalmonik <salmonik@tuta.io>2026-10-28 22:38:54 +0100
commitef2e13df274aaf0f2e50c03c63db33d65d9a0275 (patch)
tree991fa12e70ec11e84e8f6c6e7bc0a60b0a150759
parentv0.2.5: fix terminal width, ping timing and output formatting (diff)
downloadzagan-bet-ef2e13df274aaf0f2e50c03c63db33d65d9a0275.tar.gz
zagan-bet-ef2e13df274aaf0f2e50c03c63db33d65d9a0275.zip
v0.2.6: preserve buffered lines and add request read timeouts
-rw-r--r--Cargo.toml2
-rw-r--r--src/bin/client/connection.rs8
-rw-r--r--src/bin/client/repl.rs5
-rw-r--r--src/bin/client/tls.rs36
-rw-r--r--src/bin/server/tls.rs26
-rw-r--r--src/lib.rs1
-rw-r--r--src/protocol.rs70
-rw-r--r--tests/protocol.rs78
8 files changed, 203 insertions, 23 deletions
diff --git a/Cargo.toml b/Cargo.toml
index 8228349..359d16f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
1[package] 1[package]
2name = "zagan-bet" 2name = "zagan-bet"
3version = "0.2.5" 3version = "0.2.6"
4edition = "2024" 4edition = "2024"
5 5
6[dependencies] 6[dependencies]
diff --git a/src/bin/client/connection.rs b/src/bin/client/connection.rs
index 93a7829..49695dd 100644
--- a/src/bin/client/connection.rs
+++ b/src/bin/client/connection.rs
@@ -66,6 +66,7 @@ pub fn connect(
66 } 66 }
67 67
68 let mut tls = tls; 68 let mut tls = tls;
69 tls.set_read_timeout(Some(Duration::from_secs(10)))?;
69 70
70 let start = Instant::now(); 71 let start = Instant::now();
71 tls.write_line("PING")?; 72 tls.write_line("PING")?;
@@ -124,6 +125,13 @@ impl TlsStream {
124 self.inner.write_line(msg) 125 self.inner.write_line(msg)
125 } 126 }
126 127
128 pub fn set_read_timeout(
129 &self,
130 duration: Option<std::time::Duration>,
131 ) -> std::io::Result<()> {
132 self.inner.set_read_timeout(duration)
133 }
134
127 pub fn try_clone(&self) -> std::io::Result<TlsStream> { 135 pub fn try_clone(&self) -> std::io::Result<TlsStream> {
128 Ok(TlsStream { 136 Ok(TlsStream {
129 inner: self.inner.try_clone()?, 137 inner: self.inner.try_clone()?,
diff --git a/src/bin/client/repl.rs b/src/bin/client/repl.rs
index 76b149b..033fb1a 100644
--- a/src/bin/client/repl.rs
+++ b/src/bin/client/repl.rs
@@ -18,6 +18,7 @@ use zagan_bet::client::database;
18use zagan_bet::client::ui::{ 18use zagan_bet::client::ui::{
19 display_width, format_pong, host_lines, input_cursor_column, lines_to_move_up, 19 display_width, format_pong, host_lines, input_cursor_column, lines_to_move_up,
20}; 20};
21use zagan_bet::protocol::io_failure_message;
21 22
22use crate::connection::TlsStream; 23use crate::connection::TlsStream;
23 24
@@ -299,9 +300,9 @@ impl ClientState {
299 self.stream = Some(stream); 300 self.stream = Some(stream);
300 response 301 response
301 } 302 }
302 Err(_) => { 303 Err(error) => {
303 self.stream = None; 304 self.stream = None;
304 "ERROR Connection lost".to_string() 305 io_failure_message(&error).to_string()
305 } 306 }
306 } 307 }
307 } 308 }
diff --git a/src/bin/client/tls.rs b/src/bin/client/tls.rs
index ca99838..b118f63 100644
--- a/src/bin/client/tls.rs
+++ b/src/bin/client/tls.rs
@@ -19,6 +19,7 @@ use std::sync::Arc;
19use parking_lot::Mutex; 19use parking_lot::Mutex;
20use rustls::client::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; 20use rustls::client::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
21use rustls::DigitallySignedStruct; 21use rustls::DigitallySignedStruct;
22use zagan_bet::protocol::LineBuffer;
22use zagan_bet::tofu::{decide, spki_fingerprint, TrustDecision}; 23use zagan_bet::tofu::{decide, spki_fingerprint, TrustDecision};
23 24
24pub struct TlsConnector { 25pub struct TlsConnector {
@@ -59,7 +60,11 @@ impl TlsConnector {
59 conn.complete_io(&mut sock)?; 60 conn.complete_io(&mut sock)?;
60 61
61 Ok(TlsStream { 62 Ok(TlsStream {
62 session: Arc::new(Mutex::new(Session { sock, tls: conn })), 63 session: Arc::new(Mutex::new(Session {
64 sock,
65 tls: conn,
66 lines: LineBuffer::new(),
67 })),
63 }) 68 })
64 } 69 }
65 70
@@ -144,6 +149,7 @@ impl ServerCertVerifier for TofuVerifier {
144struct Session { 149struct Session {
145 sock: TcpStream, 150 sock: TcpStream,
146 tls: rustls::ClientConnection, 151 tls: rustls::ClientConnection,
152 lines: LineBuffer,
147} 153}
148 154
149impl Session { 155impl Session {
@@ -159,17 +165,25 @@ pub struct TlsStream {
159 165
160impl TlsStream { 166impl TlsStream {
161 pub fn read_line(&mut self, buf: &mut String) -> std::io::Result<usize> { 167 pub fn read_line(&mut self, buf: &mut String) -> std::io::Result<usize> {
162 let mut byte_buf = [0u8; 1024];
163 let mut s = self.session.lock(); 168 let mut s = self.session.lock();
164 loop { 169 loop {
170 if let Some(line) = s.lines.next_line() {
171 buf.push_str(&line);
172 buf.push('\n');
173 return Ok(line.len() + 1);
174 }
175
176 let mut byte_buf = [0u8; 4096];
165 match s.tls.reader().read(&mut byte_buf) { 177 match s.tls.reader().read(&mut byte_buf) {
166 Ok(0) => return Ok(0), 178 Ok(0) => {
167 Ok(n) => { 179 if let Some(rest) = s.lines.take_rest() {
168 buf.push_str(std::str::from_utf8(&byte_buf[..n]).unwrap_or("")); 180 let read = rest.len();
169 if buf.contains('\n') { 181 buf.push_str(&rest);
170 return Ok(n); 182 return Ok(read);
171 } 183 }
184 return Ok(0);
172 } 185 }
186 Ok(n) => s.lines.push(&byte_buf[..n]),
173 Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => s.drive()?, 187 Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => s.drive()?,
174 Err(e) => return Err(e), 188 Err(e) => return Err(e),
175 } 189 }
@@ -183,6 +197,14 @@ impl TlsStream {
183 s.drive() 197 s.drive()
184 } 198 }
185 199
200 /// Apply a read timeout to the underlying TCP socket.
201 pub fn set_read_timeout(
202 &self,
203 duration: Option<std::time::Duration>,
204 ) -> std::io::Result<()> {
205 self.session.lock().sock.set_read_timeout(duration)
206 }
207
186 /// Clones share the same underlying TLS session. 208 /// Clones share the same underlying TLS session.
187 pub fn try_clone(&self) -> std::io::Result<TlsStream> { 209 pub fn try_clone(&self) -> std::io::Result<TlsStream> {
188 Ok(self.clone()) 210 Ok(self.clone())
diff --git a/src/bin/server/tls.rs b/src/bin/server/tls.rs
index acf3315..62a25db 100644
--- a/src/bin/server/tls.rs
+++ b/src/bin/server/tls.rs
@@ -1,6 +1,8 @@
1use std::io::{Read, Write}; 1use std::io::{Read, Write};
2use std::net::TcpStream; 2use std::net::TcpStream;
3 3
4use zagan_bet::protocol::LineBuffer;
5
4#[derive(Clone)] 6#[derive(Clone)]
5pub struct TlsServer { 7pub struct TlsServer {
6 acceptor: native_tls::TlsAcceptor, 8 acceptor: native_tls::TlsAcceptor,
@@ -42,27 +44,25 @@ impl TlsServer {
42 44
43pub struct TlsStream { 45pub struct TlsStream {
44 stream: native_tls::TlsStream<TcpStream>, 46 stream: native_tls::TlsStream<TcpStream>,
47 lines: LineBuffer,
45} 48}
46 49
47impl TlsStream { 50impl TlsStream {
48 pub fn new(stream: native_tls::TlsStream<TcpStream>) -> Self { 51 pub fn new(stream: native_tls::TlsStream<TcpStream>) -> Self {
49 Self { stream } 52 Self {
53 stream,
54 lines: LineBuffer::new(),
55 }
50 } 56 }
51 57
52 pub fn read_line(&mut self, s: &mut String) -> std::io::Result<usize> { 58 pub fn read_line(&mut self, s: &mut String) -> std::io::Result<usize> {
53 let mut buf = [0u8; 1024]; 59 match self.lines.read_line(&mut self.stream)? {
54 loop { 60 Some(line) => {
55 match self.stream.read(&mut buf) { 61 s.push_str(&line);
56 Ok(0) => return Ok(0), 62 s.push('\n');
57 Ok(n) => { 63 Ok(line.len() + 1)
58 let chunk = std::str::from_utf8(&buf[..n]).unwrap_or("");
59 s.push_str(chunk);
60 if s.contains('\n') {
61 return Ok(n);
62 }
63 }
64 Err(e) => return Err(e),
65 } 64 }
65 None => Ok(0),
66 } 66 }
67 } 67 }
68 68
diff --git a/src/lib.rs b/src/lib.rs
index a4fb801..e700173 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -6,5 +6,6 @@
6//! `tests/` without a network or a terminal. 6//! `tests/` without a network or a terminal.
7 7
8pub mod client; 8pub mod client;
9pub mod protocol;
9pub mod server; 10pub mod server;
10pub mod tofu; 11pub mod tofu;
diff --git a/src/protocol.rs b/src/protocol.rs
new file mode 100644
index 0000000..11f54a1
--- /dev/null
+++ b/src/protocol.rs
@@ -0,0 +1,70 @@
1//! Newline-delimited protocol helpers shared by the client and server.
2//!
3//! TLS reads can return more than one line at a time. Keeping the leftover
4//! bytes is essential: dropping them silently loses the next message.
5
6use std::io::{self, Read};
7
8#[derive(Debug, Default)]
9pub struct LineBuffer {
10 buffer: Vec<u8>,
11}
12
13impl LineBuffer {
14 pub fn new() -> Self {
15 Self::default()
16 }
17
18 pub fn push(&mut self, bytes: &[u8]) {
19 self.buffer.extend_from_slice(bytes);
20 }
21
22 /// Return the next complete line, including a line that ended at EOF.
23 pub fn next_line(&mut self) -> Option<String> {
24 let end = self.buffer.iter().position(|byte| *byte == b'\n')?;
25 let mut line: Vec<u8> = self.buffer.drain(..=end).collect();
26 line.pop(); // '\n'
27 if line.last() == Some(&b'\r') {
28 line.pop();
29 }
30 Some(String::from_utf8_lossy(&line).into_owned())
31 }
32
33 /// Return the unterminated remainder at EOF, if any.
34 pub fn take_rest(&mut self) -> Option<String> {
35 if self.buffer.is_empty() {
36 return None;
37 }
38 let mut rest = std::mem::take(&mut self.buffer);
39 if rest.last() == Some(&b'\r') {
40 rest.pop();
41 }
42 Some(String::from_utf8_lossy(&rest).into_owned())
43 }
44
45 /// Read one line from a blocking reader, preserving any extra lines for
46 /// the next call. Returns `None` at EOF with nothing buffered.
47 pub fn read_line<R: Read>(&mut self, reader: &mut R) -> io::Result<Option<String>> {
48 loop {
49 if let Some(line) = self.next_line() {
50 return Ok(Some(line));
51 }
52
53 let mut chunk = [0u8; 4096];
54 match reader.read(&mut chunk) {
55 Ok(0) => return Ok(self.take_rest()),
56 Ok(n) => self.push(&chunk[..n]),
57 Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
58 Err(error) => return Err(error),
59 }
60 }
61 }
62}
63
64/// A stable, user-facing message for a failed request.
65pub fn io_failure_message(error: &io::Error) -> &'static str {
66 match error.kind() {
67 io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock => "ERROR Request timed out",
68 _ => "ERROR Connection lost",
69 }
70}
diff --git a/tests/protocol.rs b/tests/protocol.rs
new file mode 100644
index 0000000..2962574
--- /dev/null
+++ b/tests/protocol.rs
@@ -0,0 +1,78 @@
1use std::io::{Cursor, Error, ErrorKind};
2
3use zagan_bet::protocol::{io_failure_message, LineBuffer};
4
5#[test]
6fn keeps_data_after_the_first_newline() {
7 let mut buffer = LineBuffer::new();
8 buffer.push(b"PONG\nGAME_RESULT session 60 10 990 OSS two\n");
9
10 assert_eq!(buffer.next_line().as_deref(), Some("PONG"));
11 assert_eq!(
12 buffer.next_line().as_deref(),
13 Some("GAME_RESULT session 60 10 990 OSS two")
14 );
15 assert_eq!(buffer.next_line(), None);
16}
17
18#[test]
19fn handles_crlf_and_empty_lines() {
20 let mut buffer = LineBuffer::new();
21 buffer.push(b"PONG\r\n\r\n");
22
23 assert_eq!(buffer.next_line().as_deref(), Some("PONG"));
24 assert_eq!(buffer.next_line().as_deref(), Some(""));
25 assert_eq!(buffer.next_line(), None);
26}
27
28#[test]
29fn waits_for_a_split_line() {
30 let mut buffer = LineBuffer::new();
31 buffer.push(b"PO");
32 assert_eq!(buffer.next_line(), None);
33
34 buffer.push(b"NG\n");
35 assert_eq!(buffer.next_line().as_deref(), Some("PONG"));
36}
37
38#[test]
39fn reads_multiple_lines_from_a_reader() {
40 let mut buffer = LineBuffer::new();
41 let mut reader = Cursor::new(b"one\ntwo\n".to_vec());
42
43 assert_eq!(buffer.read_line(&mut reader).unwrap().as_deref(), Some("one"));
44 assert_eq!(buffer.read_line(&mut reader).unwrap().as_deref(), Some("two"));
45 assert_eq!(buffer.read_line(&mut reader).unwrap(), None);
46}
47
48#[test]
49fn returns_a_partial_line_at_eof() {
50 let mut buffer = LineBuffer::new();
51 let mut reader = Cursor::new(b"partial".to_vec());
52
53 assert_eq!(
54 buffer.read_line(&mut reader).unwrap().as_deref(),
55 Some("partial")
56 );
57 assert_eq!(buffer.read_line(&mut reader).unwrap(), None);
58}
59
60#[test]
61fn timeout_errors_get_a_timeout_message() {
62 assert_eq!(
63 io_failure_message(&Error::from(ErrorKind::TimedOut)),
64 "ERROR Request timed out"
65 );
66 assert_eq!(
67 io_failure_message(&Error::from(ErrorKind::WouldBlock)),
68 "ERROR Request timed out"
69 );
70 assert_eq!(
71 io_failure_message(&Error::from(ErrorKind::UnexpectedEof)),
72 "ERROR Connection lost"
73 );
74 assert_eq!(
75 io_failure_message(&Error::from(ErrorKind::ConnectionReset)),
76 "ERROR Connection lost"
77 );
78}