v1.1.0 — Gzip canvas compression + throttled sends
All checks were successful
CI / ci (push) Successful in 6m29s
All checks were successful
CI / ci (push) Successful in 6m29s
- Canvas snapshots gzip-compressed before sending: 1MB raw -> ~1-5KB for a mostly-white canvas, ~100-500KB for a busy one - Semaphore throttles concurrent canvas sends (default 20) to prevent memory spikes during connection bursts - Frontend decompresses gzip via browser DecompressionStream API - New config: MAX_CONCURRENT_CANVAS_SENDS (default 20) - Added flate2 dependency for gzip encoding
This commit is contained in:
62
Cargo.lock
generated
62
Cargo.lock
generated
@@ -2,6 +2,12 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "adler2"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "allocator-api2"
|
name = "allocator-api2"
|
||||||
version = "0.2.21"
|
version = "0.2.21"
|
||||||
@@ -10,7 +16,7 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "api-types"
|
name = "api-types"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"domain",
|
"domain",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -18,7 +24,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "application"
|
name = "application"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"domain",
|
"domain",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
@@ -160,7 +166,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "canvas-file"
|
name = "canvas-file"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"config",
|
"config",
|
||||||
"domain",
|
"domain",
|
||||||
@@ -186,14 +192,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "config"
|
name = "config"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "config-env"
|
name = "config-env"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"config",
|
"config",
|
||||||
]
|
]
|
||||||
@@ -222,6 +228,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crc32fast"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-utils"
|
name = "crossbeam-utils"
|
||||||
version = "0.8.19"
|
version = "0.8.19"
|
||||||
@@ -322,7 +337,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "domain"
|
name = "domain"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
]
|
]
|
||||||
@@ -385,6 +400,16 @@ version = "1.0.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flate2"
|
||||||
|
version = "1.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||||
|
dependencies = [
|
||||||
|
"crc32fast",
|
||||||
|
"miniz_oxide",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fnv"
|
name = "fnv"
|
||||||
version = "1.0.7"
|
version = "1.0.7"
|
||||||
@@ -634,7 +659,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http-axum"
|
name = "http-axum"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"config",
|
"config",
|
||||||
@@ -841,6 +866,16 @@ dependencies = [
|
|||||||
"unicase",
|
"unicase",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "miniz_oxide"
|
||||||
|
version = "0.8.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||||
|
dependencies = [
|
||||||
|
"adler2",
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
@@ -1208,7 +1243,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "server"
|
name = "server"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"application",
|
"application",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -1275,6 +1310,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd-adler32"
|
||||||
|
version = "0.3.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.9"
|
version = "0.4.9"
|
||||||
@@ -1305,7 +1346,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "socketio"
|
name = "socketio"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"api-types",
|
"api-types",
|
||||||
"application",
|
"application",
|
||||||
@@ -1823,11 +1864,12 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "websocket"
|
name = "websocket"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"application",
|
"application",
|
||||||
"axum",
|
"axum",
|
||||||
"domain",
|
"domain",
|
||||||
|
"flate2",
|
||||||
"futures",
|
"futures",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ default-members = [
|
|||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
@@ -37,6 +37,7 @@ config-env = { path = "crates/adapters/config-env" }
|
|||||||
http-axum = { path = "crates/adapters/http-axum" }
|
http-axum = { path = "crates/adapters/http-axum" }
|
||||||
socketio = { path = "crates/adapters/socketio" }
|
socketio = { path = "crates/adapters/socketio" }
|
||||||
websocket = { path = "crates/adapters/websocket" }
|
websocket = { path = "crates/adapters/websocket" }
|
||||||
|
flate2 = "1"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
|
|
||||||
axum = "0.8.9"
|
axum = "0.8.9"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const DEFAULT_BROADCAST_CAPACITY: usize = 1024;
|
|||||||
const DEFAULT_SNAPSHOT_INTERVAL_SECS: u64 = 300;
|
const DEFAULT_SNAPSHOT_INTERVAL_SECS: u64 = 300;
|
||||||
const DEFAULT_SNAPSHOT_MAX: usize = 5;
|
const DEFAULT_SNAPSHOT_MAX: usize = 5;
|
||||||
const DEFAULT_SNAPSHOT_DIR: &str = "snapshots/";
|
const DEFAULT_SNAPSHOT_DIR: &str = "snapshots/";
|
||||||
|
const DEFAULT_MAX_CONCURRENT_CANVAS_SENDS: usize = 20;
|
||||||
|
|
||||||
pub struct EnvConfigSource;
|
pub struct EnvConfigSource;
|
||||||
|
|
||||||
@@ -24,6 +25,10 @@ impl ConfigSource for EnvConfigSource {
|
|||||||
address: env_or("ADDRESS", DEFAULT_ADDRESS),
|
address: env_or("ADDRESS", DEFAULT_ADDRESS),
|
||||||
port: parse_env("PORT", DEFAULT_PORT)?,
|
port: parse_env("PORT", DEFAULT_PORT)?,
|
||||||
enable_cors: parse_bool_env("ENABLE_CORS", true),
|
enable_cors: parse_bool_env("ENABLE_CORS", true),
|
||||||
|
max_concurrent_canvas_sends: parse_env(
|
||||||
|
"MAX_CONCURRENT_CANVAS_SENDS",
|
||||||
|
DEFAULT_MAX_CONCURRENT_CANVAS_SENDS,
|
||||||
|
)?,
|
||||||
},
|
},
|
||||||
canvas: CanvasConfig {
|
canvas: CanvasConfig {
|
||||||
width: parse_env("CANVAS_WIDTH", DEFAULT_CANVAS_WIDTH)?,
|
width: parse_env("CANVAS_WIDTH", DEFAULT_CANVAS_WIDTH)?,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ edition.workspace = true
|
|||||||
application = { workspace = true }
|
application = { workspace = true }
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
axum = { workspace = true, features = ["ws"] }
|
axum = { workspace = true, features = ["ws"] }
|
||||||
|
flate2 = { workspace = true }
|
||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use std::io::Write;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
@@ -5,8 +6,10 @@ use axum::extract::State;
|
|||||||
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
use domain::{BroadcastEvent, BroadcastSubscription, Color, Position};
|
use domain::{BroadcastEvent, BroadcastSubscription, Color, Position};
|
||||||
|
use flate2::Compression;
|
||||||
|
use flate2::write::GzEncoder;
|
||||||
use futures::{SinkExt, StreamExt, stream::SplitSink};
|
use futures::{SinkExt, StreamExt, stream::SplitSink};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::{Semaphore, mpsc};
|
||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
|
|
||||||
use crate::WsState;
|
use crate::WsState;
|
||||||
@@ -36,7 +39,7 @@ async fn handle_connection(socket: WebSocket, state: Arc<WsState>) {
|
|||||||
// Subscribe before snapshotting to avoid missing updates
|
// Subscribe before snapshotting to avoid missing updates
|
||||||
let subscription = state.app_state.broadcaster().subscribe();
|
let subscription = state.app_state.broadcaster().subscribe();
|
||||||
|
|
||||||
if !send_canvas_snapshot(&mut sender, &state.app_state).await {
|
if !send_canvas_snapshot(&mut sender, &state.app_state, &state.canvas_send_semaphore).await {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,10 +70,39 @@ async fn handle_connection(socket: WebSocket, state: Arc<WsState>) {
|
|||||||
application::soldiers::disconnect::execute(&state.app_state, &connection_id);
|
application::soldiers::disconnect::execute(&state.app_state, &connection_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_canvas_snapshot(sender: &mut WsSender, state: &AppState) -> bool {
|
async fn send_canvas_snapshot(
|
||||||
|
sender: &mut WsSender,
|
||||||
|
state: &AppState,
|
||||||
|
semaphore: &Semaphore,
|
||||||
|
) -> bool {
|
||||||
|
let Ok(_permit) = semaphore.acquire().await else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
let pixels = application::canvas::get_state::execute(state);
|
let pixels = application::canvas::get_state::execute(state);
|
||||||
let bytes = Color::collect_as_bytes(&pixels);
|
let raw_bytes = Color::collect_as_bytes(&pixels);
|
||||||
sender.send(Message::Binary(bytes.into())).await.is_ok()
|
|
||||||
|
let Some(compressed) = gzip_compress(&raw_bytes) else {
|
||||||
|
error!("Failed to compress canvas snapshot");
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"Sending canvas snapshot: {}KB raw -> {}KB gzip",
|
||||||
|
raw_bytes.len() / 1024,
|
||||||
|
compressed.len() / 1024
|
||||||
|
);
|
||||||
|
|
||||||
|
sender
|
||||||
|
.send(Message::Binary(compressed.into()))
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gzip_compress(data: &[u8]) -> Option<Vec<u8>> {
|
||||||
|
let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
|
||||||
|
encoder.write_all(data).ok()?;
|
||||||
|
encoder.finish().ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_send_loop(
|
async fn run_send_loop(
|
||||||
|
|||||||
@@ -6,16 +6,19 @@ use std::sync::atomic::AtomicU64;
|
|||||||
|
|
||||||
use application::AppState;
|
use application::AppState;
|
||||||
use axum::{Router, routing::get};
|
use axum::{Router, routing::get};
|
||||||
|
use tokio::sync::Semaphore;
|
||||||
|
|
||||||
pub(crate) struct WsState {
|
pub(crate) struct WsState {
|
||||||
app_state: Arc<AppState>,
|
app_state: Arc<AppState>,
|
||||||
connection_counter: AtomicU64,
|
connection_counter: AtomicU64,
|
||||||
|
canvas_send_semaphore: Arc<Semaphore>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_router(state: Arc<AppState>) -> Router {
|
pub fn build_router(state: Arc<AppState>, max_concurrent_canvas_sends: usize) -> Router {
|
||||||
let ws_state = Arc::new(WsState {
|
let ws_state = Arc::new(WsState {
|
||||||
app_state: state,
|
app_state: state,
|
||||||
connection_counter: AtomicU64::new(0),
|
connection_counter: AtomicU64::new(0),
|
||||||
|
canvas_send_semaphore: Arc::new(Semaphore::new(max_concurrent_canvas_sends)),
|
||||||
});
|
});
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ pub struct ServerConfig {
|
|||||||
pub address: String,
|
pub address: String,
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
pub enable_cors: bool,
|
pub enable_cors: bool,
|
||||||
|
pub max_concurrent_canvas_sends: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ fn build_app(
|
|||||||
state: Arc<AppState>,
|
state: Arc<AppState>,
|
||||||
config: &AppConfig,
|
config: &AppConfig,
|
||||||
) -> Result<axum::Router, Box<dyn std::error::Error>> {
|
) -> Result<axum::Router, Box<dyn std::error::Error>> {
|
||||||
let ws_router = websocket::build_router(state);
|
let ws_router = websocket::build_router(state, config.server.max_concurrent_canvas_sends);
|
||||||
let http_router = http_axum::build_router(config.server.enable_cors, &config.rate_limit)?;
|
let http_router = http_axum::build_router(config.server.enable_cors, &config.rate_limit)?;
|
||||||
Ok(ws_router.merge(http_router))
|
Ok(ws_router.merge(http_router))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ const createSocketIoTransport = () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const decompressGzip = async (buffer) => {
|
||||||
|
const stream = new Blob([buffer])
|
||||||
|
.stream()
|
||||||
|
.pipeThrough(new DecompressionStream("gzip"));
|
||||||
|
return new Response(stream).arrayBuffer();
|
||||||
|
};
|
||||||
|
|
||||||
const createWebSocketTransport = () => {
|
const createWebSocketTransport = () => {
|
||||||
const handlers = {};
|
const handlers = {};
|
||||||
const wsUrl = isDebug
|
const wsUrl = isDebug
|
||||||
@@ -38,9 +45,10 @@ const createWebSocketTransport = () => {
|
|||||||
|
|
||||||
ws.addEventListener("open", () => dispatch("connect"));
|
ws.addEventListener("open", () => dispatch("connect"));
|
||||||
|
|
||||||
ws.addEventListener("message", (event) => {
|
ws.addEventListener("message", async (event) => {
|
||||||
if (event.data instanceof ArrayBuffer) {
|
if (event.data instanceof ArrayBuffer) {
|
||||||
const pixels = new Uint32Array(event.data);
|
const decompressed = await decompressGzip(event.data);
|
||||||
|
const pixels = new Uint32Array(decompressed);
|
||||||
dispatch("canvas_state", Array.from(pixels));
|
dispatch("canvas_state", Array.from(pixels));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user