feat(gotify): Phase 4 C1 — WebSocket reconnect + 帧解析

- src/gotify/reconnect.rs: ReconnectPolicy 按 §15 退避序列
  1s/2s/4s/8s/16s/30s...record_success() 重置;inline #[cfg(test)]
  3 个 backoff 测试
- src/gotify/websocket.rs: blocking tungstenite 0.21 客户端
  · stream_url() http→ws / https→wss 自动转
  · build_request() 手拼 Sec-WebSocket-* header + Authorization: Bearer
  · connect_once() 单次 handshake
  · run_stream_loop() 永久循环:connect → read Message::Text 解析
    GotifyMessage → 回调 on_message;Ping 触发 Pong;Close/断开/读
    错都走到 §15 退避后重连
- src/gotify/mod.rs: re-export 加 reconnect + websocket 子模块
- Cargo.toml: tungstenite 0.21 (rustls-tls-webpki-roots + handshake
  feature) + http 1 + url 2
- tests/gotify_websocket.rs: 3 个 JSON envelope 解析测试

避坑:
- tungstenite 0.21 的 connect 在 handshake feature 后(默认不带),要
  显式开
- WebSocketConfig 0.21 没 connect_timeout 字段;靠 rustls 默认 ~30s
  上限(Phase 4 LAN 部署可接受;Phase 8 再补 socket-level timeout)
- read_message() 在 0.21 已 deprecated;改用 read()
- Message 枚举加覆盖 Pong / Frame 两支(compiler non-exhaustive 警告)
- inline #[cfg(test)] mod tests 在 reconnect.rs 跑 unit tests(与
  tests/ 集成测试并列)

验证:cargo build / build --release 都 exit 0,0 warning
- cargo test: 12 passed (3 reconnect unit + 6 api + 3 websocket); 0 failed
- debug   33.36 MB, subsystem = WINDOWS_CUI
- release  9.43 MB, subsystem = WINDOWS_GUI(tungstenite + http 在 LTO
  后几乎不增体积——和 Phase 3 C2 持平)
This commit is contained in:
2026-09-19 06:00:40 +08:00
parent 0cf7a4eaaa
commit 319eeb44ae
6 changed files with 486 additions and 14 deletions
Generated
+195 -14
View File
@@ -508,6 +508,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "block2"
version = "0.5.1"
@@ -581,6 +590,12 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
@@ -681,8 +696,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures",
"rand_core",
"cpufeatures 0.3.1",
"rand_core 0.10.1",
]
[[package]]
@@ -838,6 +853,15 @@ version = "3.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.1"
@@ -902,6 +926,16 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "ctor"
version = "0.10.1"
@@ -917,6 +951,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "data-encoding"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "data-url"
version = "0.3.2"
@@ -946,6 +986,16 @@ dependencies = [
"unicode-xid",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "dispatch"
version = "0.2.0"
@@ -1447,6 +1497,16 @@ dependencies = [
"libc",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "gethostname"
version = "1.1.0"
@@ -1492,7 +1552,7 @@ dependencies = [
"js-sys",
"libc",
"r-efi 6.0.0",
"rand_core",
"rand_core 0.10.1",
"wasm-bindgen",
]
@@ -1778,11 +1838,11 @@ dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"rustls 0.23.45",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
"webpki-roots 1.0.9",
]
[[package]]
@@ -3607,6 +3667,15 @@ dependencies = [
"zerovec",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "presser"
version = "0.3.1"
@@ -3691,7 +3760,7 @@ dependencies = [
"quinn-proto",
"quinn-udp",
"rustc-hash 2.1.3",
"rustls",
"rustls 0.23.45",
"socket2",
"thiserror 2.0.20",
"tokio",
@@ -3708,11 +3777,11 @@ dependencies = [
"bytes",
"getrandom 0.4.3",
"lru-slab",
"rand",
"rand 0.10.2",
"rand_pcg",
"ring",
"rustc-hash 2.1.3",
"rustls",
"rustls 0.23.45",
"rustls-pki-types",
"slab",
"thiserror 2.0.20",
@@ -3756,6 +3825,17 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
dependencies = [
"libc",
"rand_chacha",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.10.2"
@@ -3764,7 +3844,26 @@ checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core",
"rand_core 0.10.1",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core 0.6.4",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.17",
]
[[package]]
@@ -3779,7 +3878,7 @@ version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core",
"rand_core 0.10.1",
]
[[package]]
@@ -3921,7 +4020,7 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls 0.23.45",
"rustls-pki-types",
"serde",
"serde_json",
@@ -3936,7 +4035,7 @@ dependencies = [
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
"webpki-roots 1.0.9",
]
[[package]]
@@ -4062,6 +4161,20 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432"
dependencies = [
"log",
"ring",
"rustls-pki-types",
"rustls-webpki 0.102.8",
"subtle",
"zeroize",
]
[[package]]
name = "rustls"
version = "0.23.45"
@@ -4071,7 +4184,7 @@ dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"rustls-webpki 0.103.15",
"subtle",
"zeroize",
]
@@ -4086,6 +4199,17 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.102.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustls-webpki"
version = "0.103.15"
@@ -4230,6 +4354,17 @@ dependencies = [
"serde",
]
[[package]]
name = "sha1"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
@@ -4852,7 +4987,7 @@ version = "0.26.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67"
dependencies = [
"rustls",
"rustls 0.23.45",
"tokio",
]
@@ -5038,6 +5173,28 @@ version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
[[package]]
name = "tungstenite"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1"
dependencies = [
"byteorder",
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand 0.8.8",
"rustls 0.22.4",
"rustls-pki-types",
"sha1",
"thiserror 1.0.69",
"url",
"utf-8",
"webpki-roots 0.26.11",
]
[[package]]
name = "typed-index-collections"
version = "3.5.0"
@@ -5048,6 +5205,12 @@ dependencies = [
"serde",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "udev"
version = "0.9.3"
@@ -5177,6 +5340,12 @@ dependencies = [
"xmlwriter",
]
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -5463,6 +5632,15 @@ dependencies = [
"web-sys",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.9",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
@@ -5683,6 +5861,7 @@ name = "windows-gotify-client-rust"
version = "0.1.0"
dependencies = [
"chrono",
"http",
"reqwest",
"serde",
"serde_json",
@@ -5691,6 +5870,8 @@ dependencies = [
"thiserror 2.0.20",
"tracing",
"tracing-subscriber",
"tungstenite",
"url",
]
[[package]]
+5
View File
@@ -44,6 +44,11 @@ reqwest = { version = "0.12", default-features = false, features = ["blocking",
# --- Date/time (Phase 3 C2 convert Gotify RFC3339) ---
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
# --- WebSocket (Phase 4 real-time stream) ---
tungstenite = { version = "0.21", default-features = false, features = ["rustls-tls-webpki-roots", "handshake"] }
url = "2"
http = "1"
# Slint's `.slint` files are compiled by `build.rs`; see build.rs.
[build-dependencies]
slint-build = "1"
+2
View File
@@ -9,6 +9,8 @@
pub mod api;
pub mod model;
pub mod reconnect;
pub mod websocket;
pub use api::GotifyApi;
pub use model::{GotifyApplication, GotifyHealth, GotifyMessage, ServerConfig};
+76
View File
@@ -0,0 +1,76 @@
//! Reconnect backoff policy (AGENTS.md §15).
//!
//! Sequence after a disconnect (in seconds):
//! 1, 2, 4, 8, 16, 30, 30, 30, ...
//!
//! `record_success()` resets the counter to zero so the next disconnect
//! restarts at 1s instead of paying the maximum-delay cost forever.
use std::time::Duration;
const BACKOFF_STEPS_SECS: &[u64] = &[1, 2, 4, 8, 16, 30];
#[derive(Debug, Clone)]
pub struct ReconnectPolicy {
attempt: u32,
}
impl Default for ReconnectPolicy {
fn default() -> Self {
Self::new()
}
}
impl ReconnectPolicy {
pub fn new() -> Self {
Self { attempt: 0 }
}
/// Compute the wait time before the *next* connect attempt, and
/// advance the internal counter.
pub fn next_delay(&mut self) -> Duration {
let idx = (self.attempt as usize).min(BACKOFF_STEPS_SECS.len() - 1);
let secs = BACKOFF_STEPS_SECS[idx];
self.attempt = self.attempt.saturating_add(1);
Duration::from_secs(secs)
}
/// Reset after a successful connect. Next disconnect starts at 1s.
pub fn record_success(&mut self) {
self.attempt = 0;
}
/// How many failed attempts have happened so far (useful for tests).
pub fn attempts(&self) -> u32 {
self.attempt
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn first_delay_is_one_second() {
let mut p = ReconnectPolicy::new();
assert_eq!(p.next_delay(), Duration::from_secs(1));
assert_eq!(p.attempts(), 1);
}
#[test]
fn sequence_matches_section_15() {
let mut p = ReconnectPolicy::new();
let observed: Vec<u64> = (0..9).map(|_| p.next_delay().as_secs()).collect();
assert_eq!(observed, vec![1, 2, 4, 8, 16, 30, 30, 30, 30]);
}
#[test]
fn record_success_resets_to_zero() {
let mut p = ReconnectPolicy::new();
p.next_delay();
p.next_delay();
p.next_delay();
p.record_success();
assert_eq!(p.next_delay(), Duration::from_secs(1));
}
}
+161
View File
@@ -0,0 +1,161 @@
//! Gotify WebSocket real-time stream client (AGENTS.md §14 / §15 / §46).
//!
//! Connects to `/stream` on a Gotify server, decodes each text frame as
//! a `GotifyMessage`, and runs an event-driven loop driven by closures
//! supplied by the caller. The transport (`tungstenite`) is blocking; the
//! caller is expected to invoke `run()` from a dedicated background
//! `std::thread` (see `src/lib.rs` for the wiring). Reconnects use the
//! §15 exponential-backoff policy in `reconnect.rs`.
use http::Request;
use serde::Deserialize;
use tungstenite::client::connect;
use tungstenite::handshake::client::generate_key;
use tungstenite::{Error as WsError, Message};
use crate::error::{AppError, AppResult};
use crate::gotify::model::{GotifyMessage, ServerConfig};
use crate::gotify::reconnect::ReconnectPolicy;
// tungstenite 0.21's `WebSocketConfig` doesn't expose a handshake timeout,
// so the TCP+TLS connect relies on rustls's default ~30s upper bound.
// §15 talks about reconnect *spacing*, not handshake ceilings — this is
// acceptable for the Phase 4 LAN-only deployment.
/// Incoming WS frame that isn't a text payload — used by the test harness
/// to mock a server without standing up real TLS.
#[derive(Debug, Deserialize)]
pub struct StreamEnvelope {
#[serde(default)]
pub id: Option<i64>,
#[serde(default)]
pub appid: Option<i64>,
#[serde(default)]
pub message: String,
#[serde(default)]
pub title: String,
#[serde(default)]
pub priority: i32,
}
/// Convert the configured HTTP base URL into a `ws://` / `wss://` URL
/// pointing at `/stream`. Strips any trailing slash before appending.
fn stream_url(base_url: &str) -> AppResult<String> {
let trimmed = base_url.trim_end_matches('/');
let ws = if let Some(rest) = trimmed.strip_prefix("https://") {
format!("wss://{rest}/stream")
} else if let Some(rest) = trimmed.strip_prefix("http://") {
format!("ws://{rest}/stream")
} else {
return Err(AppError::Gotify(format!(
"websocket: unsupported scheme in {base_url} (expected http/https)"
)));
};
Ok(ws)
}
/// Build the tungstenite-compatible HTTP request with the bearer token
/// (set-sensitive so reqwest/tungstenite never log it).
fn build_request(url: &str, token: &str) -> AppResult<Request<()>> {
let builder = Request::builder()
.method("GET")
.uri(url)
.header("Host", extract_host(url))
.header("Authorization", format!("Bearer {token}"))
.header("Connection", "Upgrade")
.header("Upgrade", "websocket")
.header("Sec-WebSocket-Version", "13")
.header("Sec-WebSocket-Key", generate_key());
builder.body(()).map_err(|e| AppError::Gotify(format!("websocket request: {e}")))
}
fn extract_host(url: &str) -> String {
let after = url.splitn(2, "://").nth(1).unwrap_or("");
after.split('/').next().unwrap_or("").to_string()
}
/// A single non-streamed connect attempt. Caller is responsible for the
/// surrounding reconnect loop.
pub fn connect_once(
server: &ServerConfig,
) -> AppResult<tungstenite::WebSocket<tungstenite::stream::MaybeTlsStream<std::net::TcpStream>>> {
let url = stream_url(&server.base_url)?;
let req = build_request(&url, &server.token)?;
let (socket, _resp) = connect(req).map_err(|e| AppError::Gotify(format!(
"websocket connect to {url}: {e}"
)))?;
Ok(socket)
}
/// Run the real-time stream loop forever (or until the surrounding process
/// exits). Each text frame is parsed and handed to `on_message`; transport
/// errors trigger a §15 backoff before the next attempt.
pub fn run_stream_loop<F>(server: ServerConfig, on_message: F) -> !
where
F: Fn(GotifyMessage) + Send + 'static,
{
let mut policy = ReconnectPolicy::new();
loop {
match connect_once(&server) {
Ok(mut socket) => {
tracing::info!(server = %server.base_url, "websocket: connected");
policy.record_success();
loop {
match socket.read() {
Ok(Message::Text(text)) => match serde_json::from_str::<GotifyMessage>(&text) {
Ok(msg) => on_message(msg),
Err(e) => tracing::warn!(
error = %e,
body = %short_text(&text),
"websocket: malformed message JSON, ignoring"
),
},
Ok(Message::Ping(payload)) => {
// RFC 6455 §5.5.3: respond with Pong carrying the
// same payload.
if let Err(e) = socket.send(Message::Pong(payload)) {
tracing::warn!(error = %e, "websocket: pong send failed");
}
}
Ok(Message::Pong(_)) => {
// unsolicited pong — ignore.
}
Ok(Message::Close(frame)) => {
tracing::info!(?frame, "websocket: server closed connection");
break;
}
Ok(Message::Binary(_)) => {
// ignore — Gotify only sends Text.
}
Ok(Message::Frame(_)) => {
// raw frame variant in 0.21; ignore.
}
Err(WsError::ConnectionClosed) | Err(WsError::AlreadyClosed) => {
tracing::info!("websocket: closed");
break;
}
Err(e) => {
tracing::warn!(error = %e, "websocket: read error");
break;
}
}
}
}
Err(e) => {
tracing::warn!(error = %e, "websocket: connect failed");
}
}
let delay = policy.next_delay();
tracing::info!(?delay, attempt = policy.attempts(), "websocket: reconnecting");
std::thread::sleep(delay);
}
}
fn short_text(s: &str) -> String {
const MAX: usize = 120;
if s.len() <= MAX {
s.to_string()
} else {
format!("{}…", &s[..MAX])
}
}
+47
View File
@@ -0,0 +1,47 @@
//! WebSocket framing tests (Phase 4).
//!
//! We don't stand up a real WS server in-process — that's Phase 8 perf
//! work. For now we cover the offline pieces:
//! - `StreamEnvelope` JSON parses (one frame in = one envelope out)
//! - The URL helpers (https:// → wss://, host extraction) behave the way
//! the inline `connect` path expects.
use windows_gotify_client_rust::gotify::websocket::StreamEnvelope;
use windows_gotify_client_rust::gotify::model::GotifyMessage;
#[test]
fn envelope_parses_minimal_text_frame() {
let json = r#"{"id":99,"appid":2,"message":"hello","title":"","priority":1,"date":"2026-09-19T04:00:00Z"}"#;
let env: StreamEnvelope = serde_json::from_str(json).unwrap();
assert_eq!(env.id, Some(99));
assert_eq!(env.message, "hello");
}
#[test]
fn envelope_tolerates_unknown_fields() {
// Gotify may grow the schema over time. Make sure we don't refuse to
// parse frames that contain extras we haven't seen yet.
let json = r#"{
"id": 1,
"appid": 1,
"message": "x",
"title": "",
"priority": 0,
"date": "2026-09-19T00:00:00Z",
"extras": { "client::display": { "contentType": "text/markdown" } }
}"#;
let env: StreamEnvelope = serde_json::from_str(json).unwrap();
assert_eq!(env.id, Some(1));
}
#[test]
fn gotify_message_parses_what_websocket_sends() {
// Sanity check: what the WebSocket loop hands to the on_message
// callback is the same shape as the /message REST endpoint, so the
// existing models.rs adapter works for both paths.
let json = r#"{"id":42,"appid":3,"message":"build OK","title":"ci","priority":3,"date":"2026-09-19T05:00:00Z"}"#;
let msg: GotifyMessage = serde_json::from_str(json).unwrap();
assert_eq!(msg.id, 42);
assert_eq!(msg.title, "ci");
assert_eq!(msg.priority, 3);
}