From 058432130628d00764663ecf819a9adc6793768d Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 8 Nov 2026 03:29:35 +0100 Subject: v5.0.0: protocol v5 and the browser frontend Protocol v5 replaces newline lines with bounded [u32 len][text] frames and REQ/RES/EVT envelopes. Requests carry correlation ids and the server replays the last 256 replies per connection, so a gateway retry cannot double-debit and a late reply cannot clobber a newer request. HELLO/PONG carry server time; crash rounds announce started_at and push coalesced 10 Hz ticks. Crash now commits sha256(seed) before a round and reveals the seed when it ends, so clients recompute the crash point. Adds the webd/ gateway: one blocking thread, one ClientCore and one pinned upstream TLS connection per session, an HttpOnly session cookie, takeover, and a 60-second grace window. The browser sends Command JSON and renders View JSON; it never speaks the game protocol or stores credentials. Adds the vanilla TypeScript browser app at /play/ (login, lobby, all four games, history, notices, crash verification), the nginx /play and /ws routes, and the headless DOM smoke test. webd and the app stay out of the TUI/GUI/server build graph. The client/server version moves to 5.0.0 in lockstep; v4 peers are refused at HELLO. --- tests/protocol.rs | 150 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 101 insertions(+), 49 deletions(-) (limited to 'tests/protocol.rs') diff --git a/tests/protocol.rs b/tests/protocol.rs index bf8a4fd..d829ba9 100644 --- a/tests/protocol.rs +++ b/tests/protocol.rs @@ -1,84 +1,136 @@ -use std::io::{Cursor, Error, ErrorKind}; +use std::io::Cursor; +use zagan_bet::protocol::{ + FrameBuffer, FrameError, MAX_FRAME_LEN, Packet, encode_frame, event_line, io_failure_message, + parse_packet, parse_request, reply_line, request_line, +}; -use zagan_bet::protocol::{io_failure_message, LineBuffer}; +fn framed(payload: &str) -> Vec { + encode_frame(payload) +} #[test] -fn keeps_data_after_the_first_newline() { - let mut buffer = LineBuffer::new(); - buffer.push(b"PONG\nGAME_RESULT session 60 10 990 OSS two\n"); +fn decodes_every_complete_frame_and_keeps_the_remainder() { + let mut buffer = FrameBuffer::new(); + let mut bytes = framed("RES 1 PONG 1700000000000"); + bytes.extend(framed("EVT 2 crash.tick session=cr1 t=1700000000100 m=1.5")); + buffer.push(&bytes); - assert_eq!(buffer.next_line().as_deref(), Some("PONG")); assert_eq!( - buffer.next_line().as_deref(), - Some("GAME_RESULT session 60 10 990 OSS two") + buffer.next_frame().unwrap().unwrap(), + "RES 1 PONG 1700000000000" + ); + assert_eq!( + buffer.next_frame().unwrap().unwrap(), + "EVT 2 crash.tick session=cr1 t=1700000000100 m=1.5" ); - assert_eq!(buffer.next_line(), None); + assert!(buffer.next_frame().is_none()); + assert!(buffer.is_empty()); } #[test] -fn handles_crlf_and_empty_lines() { - let mut buffer = LineBuffer::new(); - buffer.push(b"PONG\r\n\r\n"); +fn waits_for_a_partial_frame() { + let mut buffer = FrameBuffer::new(); + let bytes = framed("RES 7 OK"); + buffer.push(&bytes[..3]); + assert!(buffer.next_frame().is_none()); - assert_eq!(buffer.next_line().as_deref(), Some("PONG")); - assert_eq!(buffer.next_line().as_deref(), Some("")); - assert_eq!(buffer.next_line(), None); + buffer.push(&bytes[3..7]); + assert!(buffer.next_frame().is_none()); + + buffer.push(&bytes[7..]); + assert_eq!(buffer.next_frame().unwrap().unwrap(), "RES 7 OK"); } #[test] -fn waits_for_a_split_line() { - let mut buffer = LineBuffer::new(); - buffer.push(b"PO"); - assert_eq!(buffer.next_line(), None); +fn an_oversized_length_is_rejected_without_waiting_for_bytes() { + let mut buffer = FrameBuffer::new(); + buffer.push(&((MAX_FRAME_LEN as u32 + 1).to_be_bytes())); + match buffer.next_frame() { + Some(Err(FrameError::TooLarge(length))) => { + assert_eq!(length, MAX_FRAME_LEN + 1); + } + other => panic!("expected an oversize error, got {other:?}"), + } + assert!(buffer.is_empty(), "the buffer must not keep the bad frame"); +} - buffer.push(b"NG\n"); - assert_eq!(buffer.next_line().as_deref(), Some("PONG")); +#[test] +fn invalid_utf8_is_a_protocol_error() { + let mut buffer = FrameBuffer::new(); + buffer.push(&1u32.to_be_bytes()); + buffer.push(&[0xff]); + assert_eq!(buffer.next_frame(), Some(Err(FrameError::InvalidUtf8))); } #[test] -fn reads_multiple_lines_from_a_reader() { - let mut buffer = LineBuffer::new(); - let mut reader = Cursor::new(b"one\ntwo\n".to_vec()); +fn reads_frames_from_a_blocking_reader() { + let mut bytes = framed("RES 1 OK"); + bytes.extend(framed("EVT 1 crash.tick session=cr1 t=1 m=1.0")); + let mut reader = Cursor::new(bytes); + let mut buffer = FrameBuffer::new(); assert_eq!( - buffer.read_line(&mut reader).unwrap().as_deref(), - Some("one") + buffer.read_frame(&mut reader).unwrap().as_deref(), + Some("RES 1 OK") ); assert_eq!( - buffer.read_line(&mut reader).unwrap().as_deref(), - Some("two") + buffer.read_frame(&mut reader).unwrap().as_deref(), + Some("EVT 1 crash.tick session=cr1 t=1 m=1.0") ); - assert_eq!(buffer.read_line(&mut reader).unwrap(), None); + assert_eq!(buffer.read_frame(&mut reader).unwrap(), None); } #[test] -fn returns_a_partial_line_at_eof() { - let mut buffer = LineBuffer::new(); - let mut reader = Cursor::new(b"partial".to_vec()); - +fn builds_and_parses_the_envelope() { assert_eq!( - buffer.read_line(&mut reader).unwrap().as_deref(), - Some("partial") + request_line(42, None, "PLAY_START crash 50"), + "REQ 42 PLAY_START crash 50" ); - assert_eq!(buffer.read_line(&mut reader).unwrap(), None); + assert_eq!( + request_line(42, Some("abc123"), "PLAY_START crash 50"), + "REQ 42 idem=abc123 PLAY_START crash 50" + ); + + let request = parse_request("REQ 42 idem=abc123 PLAY_START crash 50").unwrap(); + assert_eq!(request.id, 42); + assert_eq!(request.idem.as_deref(), Some("abc123")); + assert_eq!(request.payload, "PLAY_START crash 50"); + + let request = parse_request("REQ 7 PING").unwrap(); + assert_eq!(request.id, 7); + assert_eq!(request.idem, None); + assert_eq!(request.payload, "PING"); + + assert!(parse_request("PLAY_START crash 50").is_err()); + assert!(parse_request("REQ nope PING").is_err()); + assert!(parse_request("REQ 1 idem= PING").is_err()); } #[test] -fn timeout_errors_get_a_timeout_message() { - assert_eq!( - io_failure_message(&Error::from(ErrorKind::TimedOut)), - "ERROR Request timed out" - ); - assert_eq!( - io_failure_message(&Error::from(ErrorKind::WouldBlock)), - "ERROR Request timed out" - ); +fn parses_replies_and_events() { assert_eq!( - io_failure_message(&Error::from(ErrorKind::UnexpectedEof)), - "ERROR Connection lost" + parse_packet(&reply_line(9, "ERROR Insufficient balance")).unwrap(), + Packet::Reply { + id: 9, + body: "ERROR Insufficient balance".to_string() + } ); assert_eq!( - io_failure_message(&Error::from(ErrorKind::ConnectionReset)), - "ERROR Connection lost" + parse_packet(&event_line(3, "CRASH_BUSTED {}")).unwrap(), + Packet::Event { + seq: 3, + body: "CRASH_BUSTED {}".to_string() + } ); + assert!(parse_packet("REQ 1 PING").is_err()); + assert!(parse_packet("RES nope OK").is_err()); +} + +#[test] +fn io_failures_are_reported_as_short_messages() { + let timeout = std::io::Error::new(std::io::ErrorKind::TimedOut, "slow"); + assert_eq!(io_failure_message(&timeout), "ERROR Request timed out"); + + let closed = std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "gone"); + assert_eq!(io_failure_message(&closed), "ERROR Connection lost"); } -- cgit v1.2.3