From b6aaaf4c46a01639a4a6a5c9a3d8156ce42f55bd Mon Sep 17 00:00:00 2001 From: ExplodingKonjac Date: Sun, 4 Oct 2026 15:17:06 +0800 Subject: [PATCH 1/3] refactor: harden protocol actors wal and plugins --- README.md | 4 +- docs/development/architecture.md | 2 +- docs/plugin-dev.md | 2 +- phira-mp-plus-server/src/benchmark/harness.rs | 29 +- .../src/persistence/wal/mod.rs | 514 ++++++---- phira-mp-plus-server/src/plugin.rs | 41 +- phira-mp-plus-server/src/plugin_abi/mod.rs | 2 +- phira-mp-plus-server/src/room.rs | 57 +- phira-mp-plus-server/src/room_actor/actor.rs | 93 +- .../src/room_actor/command.rs | 18 +- .../src/room_actor/handler.rs | 949 +++++++++++++----- .../src/room_actor/mailbox.rs | 70 +- .../src/room_actor/ops/membership.rs | 61 +- phira-mp-plus-server/src/server/disconnect.rs | 37 +- phira-mp-plus-server/src/server/rooms.rs | 22 +- phira-mp-plus-server/src/session.rs | 95 +- phira-mp-plus-server/src/session_lifecycle.rs | 162 +-- phira-mp-plus-server/src/session_room.rs | 286 +++--- phira-mp-plus-server/src/wasm_host.rs | 77 +- phira-mp-plus-server/src/wasm_host_helpers.rs | 34 +- phira-mp-plus-server/src/wit_host/mod.rs | 307 ++++-- .../tests/test-plugin/src/lib.rs | 4 +- .../tests/wit_abi_contracts.rs | 15 +- phira-mp/phira-mp-common/Cargo.toml | 2 +- phira-mp/phira-mp-common/src/bin.rs | 9 +- phira-mp/phira-mp-common/src/command.rs | 7 +- phira-mp/phira-mp-common/src/lib.rs | 21 +- .../phira-mp-common/tests/protocol_golden.rs | 246 +++-- phira-plugin-sdk/src/lib.rs | 4 +- 29 files changed, 2101 insertions(+), 1069 deletions(-) diff --git a/README.md b/README.md index b2845072d..5e48eaeba 100644 --- a/README.md +++ b/README.md @@ -229,7 +229,7 @@ Phira-mp-plus/ ├── README.md ├── server_config.yml # YAML 配置文件 ├── wit/ # WIT 接口定义 -│ └── phira-plugin.wit # Plugin ABI v2 WIT (15 interfaces) +│ └── phira-plugin.wit # Plugin ABI v3 WIT (16 interfaces) │ ├── scripts/ │ └── docgen.sh # WIT → Markdown 文档生成脚本 @@ -438,4 +438,4 @@ Phira-mp+ 整体采用 **Apache License, Version 2.0** — 详见 [LICENSE](LICE -![:name](https://count.getloli.com/@:phira-mp-plus-md) \ No newline at end of file +![:name](https://count.getloli.com/@:phira-mp-plus-md) diff --git a/docs/development/architecture.md b/docs/development/architecture.md index ba00468ed..2df50cc20 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -49,7 +49,7 @@ PMP → wasmtime (component model) → quarantine 故障隔离 ``` -插件通过 WIT ABI v2 定义 host API,每插件独立 capability 声明。 +插件通过 WIT ABI v3 定义 host API,每插件独立 capability 声明。 ## 关键不变量 diff --git a/docs/plugin-dev.md b/docs/plugin-dev.md index 0f20fb8df..727bd743f 100644 --- a/docs/plugin-dev.md +++ b/docs/plugin-dev.md @@ -318,7 +318,7 @@ PMP 插件状态机:`加载 → 验证 → 启用 → 运行 → 禁用 → | `tcp` | 发起 TCP 连接 | | `max_concurrent_calls` | 并发 API 调用数(默认 1) | -缺省授予 `default_capabilities` 集合(`state.read`、`send`、`ext`、`config`、`http`、`room.manage`、`admin`、`crypto`、`timer`、`tcp`、`room-state` 等)——manifest 声明的能力用于显式覆盖/收紧。**动态**能力请求(`request-capability`)默认拒绝。 +缺省仅授予 `default_capabilities` 的低权限集合(`state.read`、`send`、`ext`、`config`、`timer`、`room-state`)。HTTP、TCP、文件、加密、handler、插件调用、房间管理和管理员能力必须由 manifest 显式授予。**动态**能力请求(`request-capability`)默认拒绝。 ### 构建与部署 diff --git a/phira-mp-plus-server/src/benchmark/harness.rs b/phira-mp-plus-server/src/benchmark/harness.rs index be4b3eacc..c2ee624c8 100644 --- a/phira-mp-plus-server/src/benchmark/harness.rs +++ b/phira-mp-plus-server/src/benchmark/harness.rs @@ -84,11 +84,7 @@ pub struct BenchmarkHarness { } impl BenchmarkHarness { - pub fn new( - state: Arc, - params: ModeParams, - status: Arc, - ) -> Self { + pub fn new(state: Arc, params: ModeParams, status: Arc) -> Self { Self { state, params, @@ -133,10 +129,7 @@ impl BenchmarkHarness { None, ); self.state.users.write().await.insert(id, Arc::new(user)); - self.sessions.push(SessionEntry { - user_id: id, - name, - }); + self.sessions.push(SessionEntry { user_id: id, name }); id } @@ -175,7 +168,16 @@ impl BenchmarkHarness { if let Err(e) = self .state .room_commands - .add_user(&self.state, &room_id, uid, &name, false, deadline, None) + .add_user( + &self.state, + &room_id, + uid, + &name, + None, + false, + deadline, + None, + ) .await { self.errors += 1; @@ -231,7 +233,12 @@ impl BenchmarkHarness { /// 关闭一个房间(teardown / 轮换)。 async fn close_room(&self, room_id: &str) { - if let Err(e) = self.state.room_commands.close_room(&self.state, room_id).await { + if let Err(e) = self + .state + .room_commands + .close_room(&self.state, room_id) + .await + { tracing::warn!(room = room_id, %e, "bench close_room failed"); } } diff --git a/phira-mp-plus-server/src/persistence/wal/mod.rs b/phira-mp-plus-server/src/persistence/wal/mod.rs index 221d32e63..c1e125308 100644 --- a/phira-mp-plus-server/src/persistence/wal/mod.rs +++ b/phira-mp-plus-server/src/persistence/wal/mod.rs @@ -19,10 +19,10 @@ mod marker; -use std::collections::HashSet; -use serde::{Deserialize, Serialize}; use crate::persistence::message::PersistenceEvent; +use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use tokio::io::AsyncWriteExt; @@ -191,7 +191,7 @@ impl PersistenceWal { for component in path.components() { match component { Component::Normal(_) => components.push(component), - Component::CurDir => {} // skip standalone "." + Component::CurDir => {} // skip standalone "." Component::ParentDir => { if !components.as_os_str().is_empty() { components.pop(); @@ -249,10 +249,18 @@ impl PersistenceWal { pub fn degraded_reasons(&self) -> Vec<&'static str> { let mask = self.degraded.load(Ordering::Acquire); let mut out = Vec::new(); - if mask & DEGRADED_ACK != 0 { out.push("ack"); } - if mask & DEGRADED_CORRUPTION != 0 { out.push("corruption"); } - if mask & DEGRADED_MARKER != 0 { out.push("marker"); } - if mask & DEGRADED_COMPACT != 0 { out.push("compact"); } + if mask & DEGRADED_ACK != 0 { + out.push("ack"); + } + if mask & DEGRADED_CORRUPTION != 0 { + out.push("corruption"); + } + if mask & DEGRADED_MARKER != 0 { + out.push("marker"); + } + if mask & DEGRADED_COMPACT != 0 { + out.push("compact"); + } out } @@ -399,11 +407,14 @@ impl PersistenceWal { .len(); let write_result = async { - file.write_all(&line).await + file.write_all(&line) + .await .map_err(|e| format!("append WAL {}: {e}", self.path.display()))?; - file.flush().await + file.flush() + .await .map_err(|e| format!("flush WAL {}: {e}", self.path.display()))?; - file.sync_data().await + file.sync_data() + .await .map_err(|e| format!("sync WAL {}: {e}", self.path.display()))?; Ok::<(), String>(()) } @@ -472,11 +483,7 @@ impl PersistenceWal { // moves the tail, so [original_len, EOF) is exactly what this append // attempt wrote. let tail = match async { - let cur_len = file - .metadata() - .await - .map_err(|e| e.to_string())? - .len(); + let cur_len = file.metadata().await.map_err(|e| e.to_string())?.len(); if cur_len < original_len { return Err( "WAL is shorter than the pre-append length; the tail cannot be classified" @@ -487,7 +494,9 @@ impl PersistenceWal { .await .map_err(|e| e.to_string())?; let mut buf = Vec::new(); - file.read_to_end(&mut buf).await.map_err(|e| e.to_string())?; + file.read_to_end(&mut buf) + .await + .map_err(|e| e.to_string())?; Ok::, String>(buf) } .await @@ -676,7 +685,11 @@ impl PersistenceWal { // unchanged on failure — the next admission retries the same sequence. let _guard = self.io_gate.lock().await; let seq = self.admit_sequence.load(Ordering::Acquire) + 1; - let frame = WalFrame::new(WalRecord::Admission { id, event, sequence: seq })?; + let frame = WalFrame::new(WalRecord::Admission { + id, + event, + sequence: seq, + })?; match self.append_frame_inner(&frame).await { Ok(AppendOutcome::AdmittedDegraded) => { // P0-B: the frame is fully present and was re-synced — the @@ -837,14 +850,9 @@ impl PersistenceWal { } } - // A hard kill (SIGKILL, crash, power loss) can tear the FINAL frame - // mid-append. It shows up in two physical forms: - // 1. a partial frame with no trailing newline (below), or - // 2. a complete line ending in '\n' whose body is corrupt — parse or - // checksum failure — because the delimiter flushed but the frame - // body did not (PMP47). - // Both are auto-cleaned at the tail; corruption in any EARLIER line — - // or in a WAL with no intact prefix — still fails replay closed. + // A hard kill can leave an incomplete final JSON line. Only syntactic + // truncation is auto-cleaned; a complete frame with a bad checksum is + // treated as corruption even when it is the final line. if bytes.last().map(|&b| b != b'\n').unwrap_or(false) { // If the last byte was not a newline, the final segment may be an // incomplete write OR a complete frame whose trailing newline was @@ -853,15 +861,30 @@ impl PersistenceWal { if let Some(last) = lines.pop() { if !last.is_empty() { match serde_json::from_slice::(last) { - Ok(frame) if frame.verify().is_ok() && frame.ver <= WAL_FORMAT_VERSION => { + Ok(frame) => { + if frame.ver > WAL_FORMAT_VERSION { + self.mark_degraded(DEGRADED_CORRUPTION); + return Err(format!( + "WAL {} final frame uses unsupported format version {}", + self.path.display(), + frame.ver + )); + } + if let Err(error) = frame.verify() { + self.mark_degraded(DEGRADED_CORRUPTION); + return Err(format!( + "corrupt WAL {} final frame: {error}", + self.path.display() + )); + } // Complete valid frame without trailing newline — keep it // and normalize by appending the missing newline so the // next append does not corrupt it (PMP38 P0-B). lines.push(last); self.append_missing_newline().await?; } - _ => { - // Genuinely truncated or corrupt — discard. + Err(_) => { + // Syntactically incomplete tail — discard. has_truncated = true; truncated_at = bytes.len().saturating_sub(last.len()); } @@ -883,14 +906,30 @@ impl PersistenceWal { let has_intact_prefix = lines[..last_real_idx].iter().any(|l| !l.is_empty()); if has_intact_prefix && !lines[last_real_idx].is_empty() { let last_real = lines[last_real_idx]; - let is_valid = serde_json::from_slice::(last_real) - .map(|f| f.verify().is_ok() && f.ver <= WAL_FORMAT_VERSION) - .unwrap_or(false); - if !is_valid { - has_truncated = true; - torn_final_line = true; - truncated_at = line_starts[last_real_idx]; - lines.truncate(last_real_idx); + match serde_json::from_slice::(last_real) { + Err(_) => { + has_truncated = true; + torn_final_line = true; + truncated_at = line_starts[last_real_idx]; + lines.truncate(last_real_idx); + } + Ok(frame) => { + if frame.ver > WAL_FORMAT_VERSION { + self.mark_degraded(DEGRADED_CORRUPTION); + return Err(format!( + "WAL {} final frame uses unsupported format version {}", + self.path.display(), + frame.ver + )); + } + if let Err(error) = frame.verify() { + self.mark_degraded(DEGRADED_CORRUPTION); + return Err(format!( + "corrupt WAL {} final frame: {error}", + self.path.display() + )); + } + } } } } @@ -934,7 +973,11 @@ impl PersistenceWal { parsed_records.push(frame.record.clone()); match frame.record { - WalRecord::Admission { id, event, sequence } => { + WalRecord::Admission { + id, + event, + sequence, + } => { admitted.push((id, event, sequence)); } WalRecord::Ack { id } => { @@ -1059,7 +1102,6 @@ impl PersistenceWal { Ok(unacked) } - /// Upgrade a v1-format WAL to v2, assigning sequential sequence numbers /// to v1 admission records that lack the `sequence` field. /// @@ -1067,7 +1109,11 @@ impl PersistenceWal { /// idempotent migration — the WAL is rewritten atomically (write temp, /// fsync, rename, fsync-parent) so a crash during upgrade leaves the /// original v1 file intact. - async fn upgrade_wal_from_v1(&self, records: &[WalRecord], v1_start: u64) -> Result<(), String> { + async fn upgrade_wal_from_v1( + &self, + records: &[WalRecord], + v1_start: u64, + ) -> Result<(), String> { let temp = self.path.with_extension("wal.tmp"); let mut file = tokio::fs::OpenOptions::new() .create(true) @@ -1080,7 +1126,11 @@ impl PersistenceWal { let mut next_seq = v1_start; for record in records { let upgraded = match record { - WalRecord::Admission { id, event, sequence } if *sequence == 0 => { + WalRecord::Admission { + id, + event, + sequence, + } if *sequence == 0 => { let seq = next_seq; next_seq += 1; WalRecord::Admission { @@ -1244,7 +1294,11 @@ impl PersistenceWal { ) })?; match &frame.record { - WalRecord::Admission { id, event, sequence } => { + WalRecord::Admission { + id, + event, + sequence, + } => { admitted.push((*id, event.clone(), *sequence)); } WalRecord::Ack { id } => { @@ -1267,7 +1321,10 @@ impl PersistenceWal { let max_sequence = self.admit_sequence.load(Ordering::Acquire); let _ = tokio::fs::remove_file(&self.path).await; let marker_path = self.path.with_extension("wal.instance"); - if let Err(e) = self.write_marker_inner(&marker_path, true, max_sequence).await { + if let Err(e) = self + .write_marker_inner(&marker_path, true, max_sequence) + .await + { // The WAL was already removed; without a clean marker the next // boot would fail-closed on the missing WAL. This is a // compact-transaction failure — lock the WAL (P0-C). @@ -1304,36 +1361,30 @@ impl PersistenceWal { id: *id, event: event.clone(), sequence: *sequence, - }).map_err(|e| { + }) + .map_err(|e| { self.mark_degraded(DEGRADED_COMPACT); format!("serialize compacted WAL frame: {e}") })?; - let mut line = serde_json::to_vec(&frame) - .map_err(|e| { - self.mark_degraded(DEGRADED_COMPACT); - format!("serialize compacted WAL frame: {e}") - })?; - line.push(b'\n'); - file.write_all(&line) - .await - .map_err(|e| { - self.mark_degraded(DEGRADED_COMPACT); - format!("write WAL temp {}: {e}", temp.display()) - })?; - } - - file.flush() - .await - .map_err(|e| { + let mut line = serde_json::to_vec(&frame).map_err(|e| { self.mark_degraded(DEGRADED_COMPACT); - format!("flush WAL temp {}: {e}", temp.display()) + format!("serialize compacted WAL frame: {e}") })?; - file.sync_all() - .await - .map_err(|e| { + line.push(b'\n'); + file.write_all(&line).await.map_err(|e| { self.mark_degraded(DEGRADED_COMPACT); - format!("sync WAL temp {}: {e}", temp.display()) + format!("write WAL temp {}: {e}", temp.display()) })?; + } + + file.flush().await.map_err(|e| { + self.mark_degraded(DEGRADED_COMPACT); + format!("flush WAL temp {}: {e}", temp.display()) + })?; + file.sync_all().await.map_err(|e| { + self.mark_degraded(DEGRADED_COMPACT); + format!("sync WAL temp {}: {e}", temp.display()) + })?; drop(file); // Atomic rename. @@ -1349,12 +1400,10 @@ impl PersistenceWal { // Sync parent directory so the rename is durable. if let Some(parent) = self.path.parent() { if let Ok(dir) = tokio::fs::File::open(parent).await { - dir.sync_all() - .await - .map_err(|e| { - self.mark_degraded(DEGRADED_COMPACT); - format!("sync parent directory {}: {e}", parent.display()) - })?; + dir.sync_all().await.map_err(|e| { + self.mark_degraded(DEGRADED_COMPACT); + format!("sync parent directory {}: {e}", parent.display()) + })?; } } @@ -1425,16 +1474,9 @@ impl PersistenceWal { // mid-file corruption. let mut segments: Vec<&[u8]> = bytes.split(|b| *b == b'\n').collect(); - // Tail leniency (PMP47, symmetric with `replay()`): a hard kill tears - // only the FINAL frame. It can leave the final line in two forms: - // 1. a partial frame with no trailing newline (handled below), or - // 2. a newline-terminated line whose body fails parse/checksum (the - // delimiter flushed but the frame body did not). - // Both are crash artifacts, auto-dropped at the tail; corruption in - // any EARLIER line — or a WAL whose sole content is the corrupt line — - // still fails closed. Without this, a torn final frame would latch - // CORRUPTION at runtime (the 5s recovery scanner calls list_pending), - // rejecting all further admissions and auth until manual WAL deletion. + // Tail leniency mirrors replay(): only syntactically incomplete final + // lines are discarded. A parsed frame with a bad checksum is real + // corruption and must fail closed. let file_ends_with_newline = bytes.last().map(|&b| b == b'\n').unwrap_or(true); if file_ends_with_newline { // File ends with a newline, so the final split segment is "". The @@ -1449,18 +1491,33 @@ impl PersistenceWal { let has_intact_prefix = segments[..last_real_idx].iter().any(|l| !l.is_empty()); if has_intact_prefix && !segments[last_real_idx].is_empty() { let last_real = segments[last_real_idx]; - let is_valid = serde_json::from_slice::(last_real) - .map(|f| f.verify().is_ok() && f.ver <= WAL_FORMAT_VERSION) - .unwrap_or(false); - if !is_valid { - // Hard-kill torn final frame — drop it from the read. - self.truncated_frames.fetch_add(1, Ordering::Release); - warn!( - "WAL {} had a corrupt final frame (skipped by list_pending); \ - expected after a hard kill that tore the tail write", - self.path.display(), - ); - segments.truncate(last_real_idx); + match serde_json::from_slice::(last_real) { + Err(_) => { + // Hard-kill torn final frame — drop it from the read. + self.truncated_frames.fetch_add(1, Ordering::Release); + warn!( + "WAL {} had a syntactically truncated final frame (skipped by list_pending)", + self.path.display(), + ); + segments.truncate(last_real_idx); + } + Ok(frame) if frame.ver > WAL_FORMAT_VERSION => { + self.mark_degraded(DEGRADED_CORRUPTION); + return Err(format!( + "WAL {} during list_pending: unsupported final format version {}", + self.path.display(), + frame.ver + )); + } + Ok(frame) => { + if let Err(error) = frame.verify() { + self.mark_degraded(DEGRADED_CORRUPTION); + return Err(format!( + "corrupt WAL {} during list_pending final frame: {error}", + self.path.display() + )); + } + } } } } @@ -1469,12 +1526,28 @@ impl PersistenceWal { // a complete-but-unflushed frame OR a truncated tail. Try to parse // it; only discard if it is genuinely corrupt/truncated. if !last.is_empty() { - let is_valid = serde_json::from_slice::(last) - .map(|f| f.verify().is_ok() && f.ver <= WAL_FORMAT_VERSION) - .unwrap_or(false); - if !is_valid { - // Genuinely truncated tail — pop it and move on. - segments.pop(); + match serde_json::from_slice::(last) { + Err(_) => { + // Syntactically truncated tail — pop it and move on. + segments.pop(); + } + Ok(frame) if frame.ver > WAL_FORMAT_VERSION => { + self.mark_degraded(DEGRADED_CORRUPTION); + return Err(format!( + "WAL {} during list_pending: unsupported final format version {}", + self.path.display(), + frame.ver + )); + } + Ok(frame) => { + if let Err(error) = frame.verify() { + self.mark_degraded(DEGRADED_CORRUPTION); + return Err(format!( + "corrupt WAL {} during list_pending final frame: {error}", + self.path.display() + )); + } + } } } } @@ -1511,7 +1584,11 @@ impl PersistenceWal { )); } match frame.record { - WalRecord::Admission { id, event, sequence } => { + WalRecord::Admission { + id, + event, + sequence, + } => { admitted.push((id, event, sequence)); } WalRecord::Ack { id } => { @@ -1771,7 +1848,10 @@ mod tests { let wal2 = PersistenceWal::new(&path); let replay = wal2.replay().await; - assert!(replay.is_ok(), "clean marker must allow replay with no WAL: {replay:?}"); + assert!( + replay.is_ok(), + "clean marker must allow replay with no WAL: {replay:?}" + ); let _ = tokio::fs::remove_file(path.with_extension("wal.instance")).await; } @@ -1807,8 +1887,14 @@ mod tests { // list_pending must fail closed on MID-file corruption (not skip // silently) — only the final line enjoys the torn-write leniency. let result = wal.list_pending().await; - assert!(result.is_err(), "list_pending must fail on mid-file corruption"); - assert!(wal.is_degraded(), "WAL must be marked degraded on corruption"); + assert!( + result.is_err(), + "list_pending must fail on mid-file corruption" + ); + assert!( + wal.is_degraded(), + "WAL must be marked degraded on corruption" + ); let _ = tokio::fs::remove_file(&path).await; let _ = tokio::fs::remove_file(path.with_extension("wal.instance")).await; @@ -1833,12 +1919,17 @@ mod tests { .await .unwrap(); use tokio::io::AsyncWriteExt; - file.write_all(b"{\"ver\":2,\"record\":\"admission\",\"id\":\"").await.unwrap(); + file.write_all(b"{\"ver\":2,\"record\":\"admission\",\"id\":\"") + .await + .unwrap(); drop(file); // A trailing truncated line is tolerated; list_pending still succeeds. let result = wal.list_pending().await; - assert!(result.is_ok(), "trailing truncation must be tolerated: {result:?}"); + assert!( + result.is_ok(), + "trailing truncation must be tolerated: {result:?}" + ); assert!(!wal.is_degraded()); let _ = tokio::fs::remove_file(&path).await; @@ -1846,14 +1937,10 @@ mod tests { } #[tokio::test] - async fn list_pending_tolerates_hard_kill_torn_final_newline_frame() { - // A hard kill can tear the FINAL frame even when the trailing newline - // was flushed (checksum mismatch with the delimiter present). The 5s - // recovery scanner calls list_pending — without this leniency a torn - // final frame latches CORRUPTION at runtime and rejects all further - // admissions/auth until manual WAL deletion. With an intact prefix it - // must be skipped, not fail-closed (PMP47 tail rule, symmetric with - // replay()). + async fn list_pending_rejects_checksum_corrupt_final_frame() { + // A complete JSON frame with a bad checksum is indistinguishable from + // on-disk corruption and must fail closed, even when it is the final + // line. Only syntactically incomplete tails are tolerated. let path = std::env::temp_dir().join(format!( "pmp-wal-runtime-torn-{}.jsonl", uuid::Uuid::new_v4() @@ -1874,10 +1961,13 @@ mod tests { let result = wal.list_pending().await; assert!( - result.is_ok(), - "torn final frame must be tolerated by list_pending: {result:?}" + result.is_err(), + "checksum corruption must fail closed: {result:?}" + ); + assert!( + wal.is_degraded(), + "checksum corruption must latch degradation" ); - assert!(!wal.is_degraded(), "torn final frame must not latch corruption"); let _ = tokio::fs::remove_file(&path).await; let _ = tokio::fs::remove_file(path.with_extension("wal.instance")).await; @@ -1917,10 +2007,7 @@ mod tests { // (open for append will fail with EISDIR). The sequence counter // must NOT advance, so a subsequent successful admit reuses the // same sequence — no gap. - let path = std::env::temp_dir().join(format!( - "pmp-wal-gap-{}.jsonl", - uuid::Uuid::new_v4() - )); + let path = std::env::temp_dir().join(format!("pmp-wal-gap-{}.jsonl", uuid::Uuid::new_v4())); let wal = PersistenceWal::new(&path); wal.replay().await.unwrap(); @@ -1942,7 +2029,8 @@ mod tests { #[tokio::test] async fn fuzz_malformed_json_is_rejected() { - let path = std::env::temp_dir().join(format!("pmp-wal-fuzz-{}.jsonl", uuid::Uuid::new_v4())); + let path = + std::env::temp_dir().join(format!("pmp-wal-fuzz-{}.jsonl", uuid::Uuid::new_v4())); // Write completely invalid JSON tokio::fs::write(&path, b"not valid json\n").await.unwrap(); let wal = PersistenceWal::new(&path); @@ -1952,12 +2040,10 @@ mod tests { } #[tokio::test] - async fn hard_kill_torn_final_frame_is_auto_cleaned() { - // A hard kill can tear the FINAL frame even when the trailing newline - // was flushed: the body fails checksum (or won't parse) while the line - // terminator is present. With an intact prefix before it, replay must - // truncate the corrupt final line instead of failing closed — the - // server boots without manual WAL cleanup. + async fn replay_rejects_checksum_corrupt_final_frame() { + // A complete JSON frame with a bad checksum is corruption, not a + // syntactically truncated crash tail. Replay must fail closed rather + // than silently deleting an admitted event. let path = std::env::temp_dir().join(format!("pmp-wal-torn-final-{}.jsonl", uuid::Uuid::new_v4())); let wal = PersistenceWal::new(&path); @@ -1975,17 +2061,13 @@ mod tests { tokio::fs::write(&path, &content).await.unwrap(); let replay = wal.replay().await; - assert!(replay.is_ok(), "corrupt final frame must auto-clean: {replay:?}"); - // The intact prefix was fully ACKed; nothing remains pending. - assert_eq!(replay.unwrap().len(), 0); - // The file must be physically truncated back to the intact prefix so - // the next append does not merge into a corrupt tail. - let after = tokio::fs::read(&path).await.unwrap(); - assert_eq!(after, intact_prefix, "WAL must be truncated to the intact prefix"); - assert_eq!( - wal.truncated_frames_count(), - 1, - "the torn final frame must be counted" + assert!( + replay.is_err(), + "checksum corruption must fail closed: {replay:?}" + ); + assert!( + wal.is_degraded(), + "checksum corruption must latch degradation" ); let _ = tokio::fs::remove_file(path.with_extension("wal.instance")).await; @@ -2002,25 +2084,36 @@ mod tests { uuid::Uuid::new_v4() )); let corrupt = r#"{"ver":2,"record":"admission","id":"00000000-0000-0000-0000-0000000000ff","event":{"ServerEvent":{"kind":"bad","payload":{"n":1}}},"sequence":99,"cksum":"0000"}"#; - tokio::fs::write(&path, format!("{corrupt}\n")).await.unwrap(); + tokio::fs::write(&path, format!("{corrupt}\n")) + .await + .unwrap(); let wal = PersistenceWal::new(&path); let result = wal.replay().await; - assert!(result.is_err(), "no intact prefix must fail closed: {result:?}"); + assert!( + result.is_err(), + "no intact prefix must fail closed: {result:?}" + ); assert!(!wal.replay_succeeded()); let _ = tokio::fs::remove_file(path).await; } #[tokio::test] async fn fuzz_partial_frame_at_end_is_truncated() { - let path = std::env::temp_dir().join(format!("pmp-wal-trunc2-{}.jsonl", uuid::Uuid::new_v4())); + let path = + std::env::temp_dir().join(format!("pmp-wal-trunc2-{}.jsonl", uuid::Uuid::new_v4())); let wal = PersistenceWal::new(&path); wal.replay().await.unwrap(); wal.admit(make_event("good")).await.unwrap(); // Append a truncated JSON fragment let mut file = tokio::fs::OpenOptions::new() - .append(true).open(&path).await.unwrap(); + .append(true) + .open(&path) + .await + .unwrap(); use tokio::io::AsyncWriteExt; - file.write_all(b"{\"ver\":1,\"record\":\"admission\"").await.unwrap(); + file.write_all(b"{\"ver\":1,\"record\":\"admission\"") + .await + .unwrap(); file.flush().await.unwrap(); drop(file); // Replay should succeed, discarding truncated line @@ -2031,7 +2124,8 @@ mod tests { #[tokio::test] async fn fuzz_repeated_ack_is_idempotent() { - let path = std::env::temp_dir().join(format!("pmp-wal-idem-{}.jsonl", uuid::Uuid::new_v4())); + let path = + std::env::temp_dir().join(format!("pmp-wal-idem-{}.jsonl", uuid::Uuid::new_v4())); let wal = PersistenceWal::new(&path); wal.replay().await.unwrap(); let (id, _) = wal.admit(make_event("test")).await.unwrap(); @@ -2044,7 +2138,8 @@ mod tests { #[tokio::test] async fn fuzz_concurrent_admit_and_replay() { - let path = std::env::temp_dir().join(format!("pmp-wal-conc-{}.jsonl", uuid::Uuid::new_v4())); + let path = + std::env::temp_dir().join(format!("pmp-wal-conc-{}.jsonl", uuid::Uuid::new_v4())); let wal = std::sync::Arc::new(PersistenceWal::new(&path)); wal.replay().await.unwrap(); @@ -2069,8 +2164,7 @@ mod tests { #[tokio::test] async fn replay_sequence_numbers_are_monotonic() { - let path = - std::env::temp_dir().join(format!("pmp-wal-seq-{}.jsonl", uuid::Uuid::new_v4())); + let path = std::env::temp_dir().join(format!("pmp-wal-seq-{}.jsonl", uuid::Uuid::new_v4())); let wal = PersistenceWal::new(&path); wal.replay().await.unwrap(); @@ -2085,9 +2179,15 @@ mod tests { // list_pending should return the same sequences. let pending = wal.list_pending().await.unwrap(); for (pid, _pe, pseq) in &pending { - if *pid == id1 { assert_eq!(*pseq, seq1); } - if *pid == id2 { assert_eq!(*pseq, seq2); } - if *pid == id3 { assert_eq!(*pseq, seq3); } + if *pid == id1 { + assert_eq!(*pseq, seq1); + } + if *pid == id2 { + assert_eq!(*pseq, seq2); + } + if *pid == id3 { + assert_eq!(*pseq, seq3); + } } // After ACK, the entry is removed from the sequences map. @@ -2096,8 +2196,12 @@ mod tests { assert_eq!(pending_after.len(), 2); // Remaining entries should still have their original sequences. for (pid, _pe, pseq) in &pending_after { - if *pid == id1 { assert_eq!(*pseq, seq1); } - if *pid == id3 { assert_eq!(*pseq, seq3); } + if *pid == id1 { + assert_eq!(*pseq, seq1); + } + if *pid == id3 { + assert_eq!(*pseq, seq3); + } } let _ = tokio::fs::remove_file(path).await; @@ -2120,15 +2224,17 @@ mod tests { #[tokio::test] async fn fault_wal_deleted_fails_with_instance_marker() { - let path = - std::env::temp_dir().join(format!("pmp-wal-del-{}.jsonl", uuid::Uuid::new_v4())); + let path = std::env::temp_dir().join(format!("pmp-wal-del-{}.jsonl", uuid::Uuid::new_v4())); let wal = PersistenceWal::new(&path); wal.replay().await.unwrap(); // creates instance marker wal.admit(make_event("will-be-detected")).await.unwrap(); let _ = tokio::fs::remove_file(&path).await; let wal2 = PersistenceWal::new(&path); let result = wal2.replay().await; - assert!(result.is_err(), "deleted WAL after first use must fail: {result:?}"); + assert!( + result.is_err(), + "deleted WAL after first use must fail: {result:?}" + ); assert!(!wal2.replay_succeeded()); // Cleanup: remove instance marker let marker = path.with_extension("wal.instance"); @@ -2137,8 +2243,7 @@ mod tests { #[tokio::test] async fn fault_compact_and_admit_no_data_loss() { - let path = - std::env::temp_dir().join(format!("pmp-wal-cc-{}.jsonl", uuid::Uuid::new_v4())); + let path = std::env::temp_dir().join(format!("pmp-wal-cc-{}.jsonl", uuid::Uuid::new_v4())); let wal = std::sync::Arc::new(PersistenceWal::new(&path)); wal.replay().await.unwrap(); let _ = wal.admit(make_event("seed1")).await.unwrap(); @@ -2151,7 +2256,10 @@ mod tests { }); let _ = tokio::join!(h1, h2); let replay = wal.replay().await.unwrap(); - let kinds: Vec = replay.iter().map(|(_, e, _)| e.kind().to_string()).collect(); + let kinds: Vec = replay + .iter() + .map(|(_, e, _)| e.kind().to_string()) + .collect(); assert!( kinds.contains(&"concurrent".to_string()), "concurrent event must survive: {kinds:?}" @@ -2169,7 +2277,10 @@ mod tests { tokio::fs::write(&path, b"").await.unwrap(); let wal2 = PersistenceWal::new(&path); let result = wal2.replay().await; - assert!(result.is_err(), "zeroed WAL after first use must fail: {result:?}"); + assert!( + result.is_err(), + "zeroed WAL after first use must fail: {result:?}" + ); assert!(!wal2.replay_succeeded()); let marker = path.with_extension("wal.instance"); let _ = tokio::fs::remove_file(&marker).await; @@ -2208,8 +2319,10 @@ mod tests { #[tokio::test] async fn fatal_compact_rejects_ack_but_recovers_on_success() { - let path = - std::env::temp_dir().join(format!("pmp-wal-compactfatal-{}.jsonl", uuid::Uuid::new_v4())); + let path = std::env::temp_dir().join(format!( + "pmp-wal-compactfatal-{}.jsonl", + uuid::Uuid::new_v4() + )); let wal = PersistenceWal::new(&path); wal.replay().await.unwrap(); let (id, _) = wal.admit(make_event("e")).await.unwrap(); @@ -2224,7 +2337,10 @@ mod tests { // A successful compact clears the recoverable COMPACT bit (P0-C). wal.clear_compact_degraded(); assert!(!wal.is_fatal(), "clearing COMPACT must un-latch fatal"); - assert!(wal.ack(id).await.is_ok(), "ack must work after COMPACT cleared"); + assert!( + wal.ack(id).await.is_ok(), + "ack must work after COMPACT cleared" + ); let _ = tokio::fs::remove_file(path.with_extension("wal.instance")).await; let _ = tokio::fs::remove_file(path).await; @@ -2363,10 +2479,18 @@ mod tests { original_len + line.len() as u64 + 1, "the confirmed frame must remain with exactly one trailing newline" ); - assert_eq!(bytes.last(), Some(&b'\n'), "the tail must end with a newline"); + assert_eq!( + bytes.last(), + Some(&b'\n'), + "the tail must end with a newline" + ); // Replay must see the normalized frame and report no pending work. let replay = wal.replay().await.unwrap(); - assert_eq!(replay.len(), 0, "the normalized ack frame must replay cleanly"); + assert_eq!( + replay.len(), + 0, + "the normalized ack frame must replay cleanly" + ); let _ = tokio::fs::remove_file(path.with_extension("wal.instance")).await; let _ = tokio::fs::remove_file(path).await; @@ -2376,8 +2500,10 @@ mod tests { #[tokio::test] async fn compact_corruption_latches_fatal_and_blocks_admissions() { - let path = - std::env::temp_dir().join(format!("pmp-wal-compact-corrupt-{}.jsonl", uuid::Uuid::new_v4())); + let path = std::env::temp_dir().join(format!( + "pmp-wal-compact-corrupt-{}.jsonl", + uuid::Uuid::new_v4() + )); let wal = PersistenceWal::new(&path); wal.replay().await.unwrap(); let (id, _) = wal.admit(make_event("one")).await.unwrap(); @@ -2394,7 +2520,10 @@ mod tests { let result = wal.compact().await; assert!(result.is_err(), "compact must fail on mid-file corruption"); - assert!(wal.is_fatal(), "corruption found by compact must be latched"); + assert!( + wal.is_fatal(), + "corruption found by compact must be latched" + ); assert!( wal.degraded.load(Ordering::Acquire) & DEGRADED_CORRUPTION != 0, "the corruption bit (not merely COMPACT) must be set" @@ -2410,8 +2539,10 @@ mod tests { #[tokio::test] async fn compact_success_clears_compact_but_not_corruption() { - let path = - std::env::temp_dir().join(format!("pmp-wal-compact-clear-{}.jsonl", uuid::Uuid::new_v4())); + let path = std::env::temp_dir().join(format!( + "pmp-wal-compact-clear-{}.jsonl", + uuid::Uuid::new_v4() + )); let wal = PersistenceWal::new(&path); wal.replay().await.unwrap(); let (id, _) = wal.admit(make_event("keep")).await.unwrap(); @@ -2442,8 +2573,10 @@ mod tests { #[tokio::test] async fn marker_recreated_active_when_missing_at_runtime() { - let path = - std::env::temp_dir().join(format!("pmp-wal-marker-lost-{}.jsonl", uuid::Uuid::new_v4())); + let path = std::env::temp_dir().join(format!( + "pmp-wal-marker-lost-{}.jsonl", + uuid::Uuid::new_v4() + )); let wal = PersistenceWal::new(&path); wal.replay().await.unwrap(); let marker_path = path.with_extension("wal.instance"); @@ -2454,8 +2587,14 @@ mod tests { // The next admission must recreate it (active), not silently ignore it. let _ = wal.admit(make_event("recreate")).await.unwrap(); - assert!(marker_path.exists(), "marker must be recreated after runtime loss"); - assert!(!wal.marker_degraded(), "successful recreate must clear MARKER degraded"); + assert!( + marker_path.exists(), + "marker must be recreated after runtime loss" + ); + assert!( + !wal.marker_degraded(), + "successful recreate must clear MARKER degraded" + ); let content = tokio::fs::read_to_string(&marker_path).await.unwrap(); let v: serde_json::Value = serde_json::from_str(&content).unwrap(); @@ -2471,8 +2610,10 @@ mod tests { #[tokio::test] async fn active_marker_verification_clears_marker_degraded() { - let path = - std::env::temp_dir().join(format!("pmp-wal-marker-selfheal-{}.jsonl", uuid::Uuid::new_v4())); + let path = std::env::temp_dir().join(format!( + "pmp-wal-marker-selfheal-{}.jsonl", + uuid::Uuid::new_v4() + )); let wal = PersistenceWal::new(&path); wal.replay().await.unwrap(); wal.admit(make_event("a")).await.unwrap(); // marker becomes active @@ -2495,8 +2636,10 @@ mod tests { #[tokio::test] async fn clean_marker_empty_wal_equivalent_to_no_wal() { - let path = - std::env::temp_dir().join(format!("pmp-wal-clean-empty-{}.jsonl", uuid::Uuid::new_v4())); + let path = std::env::temp_dir().join(format!( + "pmp-wal-clean-empty-{}.jsonl", + uuid::Uuid::new_v4() + )); let wal = PersistenceWal::new(&path); wal.replay().await.unwrap(); // writes a CLEAN first-boot marker @@ -2521,8 +2664,10 @@ mod tests { #[tokio::test] async fn active_marker_empty_wal_fails_closed() { - let path = - std::env::temp_dir().join(format!("pmp-wal-active-empty-{}.jsonl", uuid::Uuid::new_v4())); + let path = std::env::temp_dir().join(format!( + "pmp-wal-active-empty-{}.jsonl", + uuid::Uuid::new_v4() + )); let wal = PersistenceWal::new(&path); wal.replay().await.unwrap(); wal.admit(make_event("data")).await.unwrap(); // marker becomes ACTIVE @@ -2543,15 +2688,20 @@ mod tests { #[tokio::test] async fn empty_wal_without_marker_gets_clean_marker() { - let path = - std::env::temp_dir().join(format!("pmp-wal-nomarker-empty-{}.jsonl", uuid::Uuid::new_v4())); + let path = std::env::temp_dir().join(format!( + "pmp-wal-nomarker-empty-{}.jsonl", + uuid::Uuid::new_v4() + )); // An empty WAL file with NO marker (crash during first-boot replay after // create(true) but before the marker was written). tokio::fs::write(&path, b"").await.unwrap(); let wal = PersistenceWal::new(&path); let replay = wal.replay().await; - assert!(replay.is_ok(), "empty WAL without marker must replay Ok: {replay:?}"); + assert!( + replay.is_ok(), + "empty WAL without marker must replay Ok: {replay:?}" + ); let marker_path = path.with_extension("wal.instance"); let content = tokio::fs::read_to_string(&marker_path).await.unwrap(); diff --git a/phira-mp-plus-server/src/plugin.rs b/phira-mp-plus-server/src/plugin.rs index 993e126a4..dbde0d8f0 100644 --- a/phira-mp-plus-server/src/plugin.rs +++ b/phira-mp-plus-server/src/plugin.rs @@ -11,8 +11,8 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; #[cfg(feature = "plugin-system")] use std::collections::HashSet; -use std::path::Path; use std::future::Future; +use std::path::Path; use std::pin::Pin; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, RwLock as StdRwLock}; @@ -207,12 +207,13 @@ impl PluginSlotInner { loop { match self.try_execution() { Ok(permit) => return Ok(permit), - Err("plugin already has an in-flight call") => { + Err("plugin has reached maximum concurrent calls") => { if std::time::Instant::now() >= deadline { return Err("plugin execution slot wait timed out"); } std::thread::sleep(std::time::Duration::from_millis(2)); } + Err("plugin concurrency race") => continue, Err(reason) => return Err(reason), } } @@ -391,7 +392,8 @@ pub struct PluginManager { #[cfg(feature = "plugin-system")] wasm_services: Arc, /// TCP actor command sender, set after the actor is started. - plugin_tcp_tx: tokio::sync::Mutex>>, + plugin_tcp_tx: + tokio::sync::Mutex>>, /// Tracks which handlers are owned by each plugin (method name list per plugin). /// Used by remove_plugin to clean up the shared handler registry. handler_owners: Arc>>>, @@ -499,7 +501,10 @@ impl PluginManager { PluginDispatchMessage::Event(e) => e.kind(), _ => "control", }; - warn!(kind, "plugin event queue full, dropping event to avoid blocking core protocol"); + warn!( + kind, + "plugin event queue full, dropping event to avoid blocking core protocol" + ); } Err(TrySendError::Closed(msg)) => { let kind = match &msg { @@ -624,7 +629,10 @@ impl PluginManager { } /// Set the TCP actor command sender after the TCP actor is started. - pub fn set_plugin_tcp_tx(&self, tx: tokio::sync::mpsc::Sender) { + pub fn set_plugin_tcp_tx( + &self, + tx: tokio::sync::mpsc::Sender, + ) { if let Ok(mut guard) = self.plugin_tcp_tx.try_lock() { *guard = Some(tx); } @@ -635,7 +643,9 @@ impl PluginManager { pub fn set_tcp_callback( &self, cb: Arc< - dyn Fn(String, serde_json::Value) -> Pin + Send>> + Send + Sync, + dyn Fn(String, serde_json::Value) -> Pin + Send>> + + Send + + Sync, >, ) { #[cfg(feature = "plugin-system")] @@ -778,7 +788,9 @@ impl PluginManager { "plugin requests concurrent execution" ); } - { slot.set_max_concurrent(meta.max_concurrent_calls); } + { + slot.set_max_concurrent(meta.max_concurrent_calls); + } self.wasm_services.register_plugin_runtime(&stable_id); self.plugins.write().await.push(slot); Ok(meta) @@ -1048,8 +1060,14 @@ impl PluginManager { plugin_id: plugin_name.clone(), reply, }); - // Wait up to 5s for the TCP actor to clean up handles - let _ = rx.recv_timeout(std::time::Duration::from_secs(5)); + // The TCP actor uses a synchronous reply for WIT host + // calls. Wait on it off the async runtime so plugin + // reload cannot block a Tokio worker thread. + let _ = tokio::time::timeout( + std::time::Duration::from_secs(5), + tokio::task::spawn_blocking(move || rx.recv()), + ) + .await; } } } @@ -1093,8 +1111,9 @@ impl PluginManager { removed_any = true; } if sidecar.exists() { - std::fs::remove_file(&sidecar) - .map_err(|e| format!("remove capability sidecar '{}': {e}", sidecar.display()))?; + std::fs::remove_file(&sidecar).map_err(|e| { + format!("remove capability sidecar '{}': {e}", sidecar.display()) + })?; removed_any = true; } if data_dir.exists() { diff --git a/phira-mp-plus-server/src/plugin_abi/mod.rs b/phira-mp-plus-server/src/plugin_abi/mod.rs index 91c7822d9..11600cf4a 100644 --- a/phira-mp-plus-server/src/plugin_abi/mod.rs +++ b/phira-mp-plus-server/src/plugin_abi/mod.rs @@ -1,6 +1,6 @@ //! Plugin ABI boundary. //! -//! WIT/component-model ABI v2 is the only supported ABI. The legacy JSON-memory +//! WIT/component-model ABI v3 is the only supported ABI. The legacy JSON-memory //! bridge (abi-json-v1) has been removed. The ABI is stable (no longer a migration phase). mod plan; diff --git a/phira-mp-plus-server/src/room.rs b/phira-mp-plus-server/src/room.rs index 221cfe72e..bb05edf17 100644 --- a/phira-mp-plus-server/src/room.rs +++ b/phira-mp-plus-server/src/room.rs @@ -15,9 +15,7 @@ //! mutations through RoomActorCommand variants via RoomCommandGateway. use crate::plugin::{JudgeEventItem, PluginManager, TouchEventPoint}; -use phira_mp_common::{ - Message, PartialRoomData, RoomEvent, RoomId, RoundData, ServerCommand, -}; +use phira_mp_common::{Message, PartialRoomData, RoomEvent, RoomId, RoundData, ServerCommand}; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::{ @@ -338,7 +336,8 @@ impl Room { pub fn is_live(&self) -> bool { if let Some(server) = self.server.upgrade() { - server.room_snapshot(&self.id.to_string()) + server + .room_snapshot(&self.id.to_string()) .map(|s| s.live) .unwrap_or(false) } else { @@ -372,11 +371,13 @@ impl Room { let room_seq = Some(self.last_room_seq.load(Ordering::Relaxed)); if let ServerCommand::Message(msg) = &cmd { if matches!(msg, Message::Chat { .. }) { - let mut buf = self.chat_history.write().await; - if buf.len() >= self.chat_history_cap { - buf.pop_front(); + if self.chat_history_cap > 0 { + let mut buf = self.chat_history.write().await; + if buf.len() >= self.chat_history_cap { + buf.pop_front(); + } + buf.push_back(msg.clone()); } - buf.push_back(msg.clone()); } } for session in self.users().await.into_iter().chain(self.monitors().await) { @@ -388,11 +389,13 @@ impl Room { let room_seq = Some(self.last_room_seq.load(Ordering::Relaxed)); if let ServerCommand::Message(msg) = &cmd { if matches!(msg, Message::Chat { .. }) { - let mut buf = self.chat_history.write().await; - if buf.len() >= self.chat_history_cap { - buf.pop_front(); + if self.chat_history_cap > 0 { + let mut buf = self.chat_history.write().await; + if buf.len() >= self.chat_history_cap { + buf.pop_front(); + } + buf.push_back(msg.clone()); } - buf.push_back(msg.clone()); } } for session in self.users().await.into_iter().chain(self.monitors().await) { @@ -437,14 +440,19 @@ impl Room { let room_seq = Some(self.last_room_seq.load(Ordering::Relaxed)); for user in users.iter().chain(monitors.iter()) { let content = translate(&user.lang); - user.try_send(ServerCommand::Message(Message::Chat { user: 0, content }), room_seq).await; + user.try_send( + ServerCommand::Message(Message::Chat { user: 0, content }), + room_seq, + ) + .await; } } /// Broadcast a localized system message with no args. pub async fn send_system_msg_simple(&self, key: &str) { let key = key.to_owned(); - self.send_system_msg(&|lang| crate::l10n::try_translate(&lang.0, &key)).await; + self.send_system_msg(&|lang| crate::l10n::try_translate(&lang.0, &key)) + .await; } /// Broadcast a `PartialRoomData` update to the monitoring infrastructure. @@ -579,10 +587,31 @@ impl Room { #[cfg(test)] mod tests { + use super::*; + use phira_mp_common::{Message, ServerCommand}; #[test] fn default_snapshot_fallback() { // control_snapshot on a room with no actor returns sensible defaults. // This is exercised indirectly via construction flows. } + + #[tokio::test] + async fn zero_chat_history_capacity_does_not_retain_messages() { + let room = Room::new_empty( + "history-zero".to_string().try_into().unwrap(), + None, + std::sync::Weak::new(), + 4, + None, + 0, + 0, + ); + room.broadcast(ServerCommand::Message(Message::Chat { + user: 1, + content: "not retained".to_string(), + })) + .await; + assert!(room.chat_history.read().await.is_empty()); + } } diff --git a/phira-mp-plus-server/src/room_actor/actor.rs b/phira-mp-plus-server/src/room_actor/actor.rs index 8566f8e69..ff71c4c9c 100644 --- a/phira-mp-plus-server/src/room_actor/actor.rs +++ b/phira-mp-plus-server/src/room_actor/actor.rs @@ -11,9 +11,9 @@ use crate::persistence::message::PersistenceEvent; use crate::room::{InternalRoomState, PlayerLiveData, Room, RoomControlSnapshot}; use crate::server::PlusServerState; use serde::{Deserialize, Serialize}; -use tracing::warn; use std::collections::HashMap; use std::sync::Arc; +use tracing::warn; /// Debounce interval (ms) for RoomSnapshot persistence enqueues. /// After a state change, a debounce timer is started. If no further @@ -86,24 +86,22 @@ impl RoomSnapshot { _ => None, }, results_keys: match &state.state.lifecycle { - InternalRoomState::Playing { results, .. } => { - results.keys().copied().collect() - } + InternalRoomState::Playing { results, .. } => results.keys().copied().collect(), _ => Vec::new(), }, aborted_users: match &state.state.lifecycle { - InternalRoomState::Playing { aborted, .. } => { - aborted.iter().copied().collect() - } + InternalRoomState::Playing { aborted, .. } => aborted.iter().copied().collect(), _ => Vec::new(), }, playing_users: match &state.state.lifecycle { - InternalRoomState::Playing { results, aborted } => { - state.state.members.users.iter() - .filter(|u| !results.contains_key(u) && !aborted.contains(u)) - .copied() - .collect() - } + InternalRoomState::Playing { results, aborted } => state + .state + .members + .users + .iter() + .filter(|u| !results.contains_key(u) && !aborted.contains(u)) + .copied() + .collect(), _ => Vec::new(), }, members: state.state.members.clone(), @@ -170,7 +168,12 @@ pub struct RoomState { impl RoomState { /// 构建 RoomSnapshot(供外部只读路径使用)。 - pub fn to_snapshot(&self, room_id: &str, room_uuid: &str, created_at: i64) -> crate::room_actor::actor::RoomSnapshot { + pub fn to_snapshot( + &self, + room_id: &str, + room_uuid: &str, + created_at: i64, + ) -> crate::room_actor::actor::RoomSnapshot { crate::room_actor::actor::RoomSnapshot { room_id: room_id.to_string(), room_uuid: room_uuid.to_string(), @@ -194,24 +197,21 @@ impl RoomState { _ => None, }, results_keys: match &self.lifecycle { - InternalRoomState::Playing { results, .. } => { - results.keys().copied().collect() - } + InternalRoomState::Playing { results, .. } => results.keys().copied().collect(), _ => Vec::new(), }, aborted_users: match &self.lifecycle { - InternalRoomState::Playing { aborted, .. } => { - aborted.iter().copied().collect() - } + InternalRoomState::Playing { aborted, .. } => aborted.iter().copied().collect(), _ => Vec::new(), }, playing_users: match &self.lifecycle { - InternalRoomState::Playing { results, aborted } => { - self.members.users.iter() - .filter(|u| !results.contains_key(u) && !aborted.contains(u)) - .copied() - .collect() - } + InternalRoomState::Playing { results, aborted } => self + .members + .users + .iter() + .filter(|u| !results.contains_key(u) && !aborted.contains(u)) + .copied() + .collect(), _ => Vec::new(), }, members: self.members.clone(), @@ -258,12 +258,7 @@ pub struct RoomActorState { impl RoomActorState { /// Create a new `RoomActorState` from its constituent parts. - pub fn new( - room_id: String, - room_uuid: String, - state: RoomState, - created_at: i64, - ) -> Self { + pub fn new(room_id: String, room_uuid: String, state: RoomState, created_at: i64) -> Self { Self { room_id, room_uuid, @@ -289,19 +284,26 @@ pub struct RoomActor { } impl RoomActor { - pub fn new(room: Arc, state: Arc) -> Self { + pub async fn new(room: Arc, state: Arc) -> Self { // Initialize actor state from Room fields (first-time population). let control = room.control_snapshot(); + // Room::new installs the creator before the mailbox is created. Seed + // the actor from that registry so the first authoritative snapshot + // cannot lose the creator or any monitor attached during startup. + let users = room.users().await.into_iter().map(|user| user.id).collect(); + let monitors = room + .monitors() + .await + .into_iter() + .map(|user| user.id) + .collect(); let actor_state = RoomActorState::new( room.id.to_string(), room.uuid.to_string(), RoomState { control, lifecycle: InternalRoomState::SelectChart, - members: RoomMembers { - users: Vec::new(), - monitors: Vec::new(), - }, + members: RoomMembers { users, monitors }, chart: None, chart_name: None, round: RoundInfo { @@ -348,11 +350,19 @@ impl RoomActor { /// no oneshot reply, no `finish_command`. Only updates `player_data`. pub(super) async fn execute_telemetry(&mut self, command: RoomActorCommand) { match command { - RoomActorCommand::TelemetryTouches { room_id: _, user_id, touches } => { + RoomActorCommand::TelemetryTouches { + room_id: _, + user_id, + touches, + } => { let entry = self.actor_state.player_data.entry(user_id).or_default(); entry.push_touches(&touches); } - RoomActorCommand::TelemetryJudges { room_id: _, user_id, judges } => { + RoomActorCommand::TelemetryJudges { + room_id: _, + user_id, + judges, + } => { let entry = self.actor_state.player_data.entry(user_id).or_default(); entry.push_judges(&judges); } @@ -364,8 +374,8 @@ impl RoomActor { /// All commands go through execute_with_actor which writes actor_state. /// The snapshot cache is updated directly after execution. pub(super) async fn execute_command(&mut self, command: RoomActorCommand) -> bool { - use super::handler::RoomCommandHandler; use super::context::RoomCommandContext; + use super::handler::RoomCommandHandler; use super::lifecycle::DefaultRoomLifecycle; let room = Arc::clone(&self.room); @@ -395,7 +405,8 @@ impl RoomActor { let room_id = self.room.id.to_string(); let snapshot = self.latest_snapshot.clone(); self.snapshot_debounce_handle = Some(tokio::spawn(async move { - tokio::time::sleep(std::time::Duration::from_millis(ROOM_SNAPSHOT_DEBOUNCE_MS)).await; + tokio::time::sleep(std::time::Duration::from_millis(ROOM_SNAPSHOT_DEBOUNCE_MS)) + .await; let room_id_for_msg = room_id.clone(); if let Ok(payload) = serde_json::to_value(&snapshot) { if let Err(e) = persistence diff --git a/phira-mp-plus-server/src/room_actor/command.rs b/phira-mp-plus-server/src/room_actor/command.rs index 86c7eb951..0ebd687d7 100644 --- a/phira-mp-plus-server/src/room_actor/command.rs +++ b/phira-mp-plus-server/src/room_actor/command.rs @@ -1,7 +1,9 @@ //! Room actor command envelope types. -use crate::plugin::{JudgeEventItem, TouchEventPoint}; use super::RoomCommandResult; +use crate::plugin::{JudgeEventItem, TouchEventPoint}; +use crate::session::User; +use std::sync::Arc; use tokio::sync::oneshot; /// Origin identity of the Session that issued a room command (PMP44 P0-C). @@ -268,6 +270,10 @@ pub(crate) enum RoomActorCommand { room_id: String, user_id: i32, user_name: String, + /// Connection reference attached in the same actor turn as the + /// authoritative membership commit. `None` is used by benchmarks and + /// internal recovery paths that have no live session. + connection: Option>, monitor: bool, /// Absolute actor deadline (P0-C/P0-G). The handler refuses to add the /// user when the deadline has already passed. @@ -455,9 +461,15 @@ mod tests { assert_eq!(RoomCommandKind::CloseRoom.action(), "close"); assert_eq!(RoomCommandKind::KickUser.action(), "kick"); assert_eq!(RoomCommandKind::StartRoom.action(), "start"); - assert_eq!(RoomCommandKind::EnterReadyPhase.action(), "enter_ready_phase"); + assert_eq!( + RoomCommandKind::EnterReadyPhase.action(), + "enter_ready_phase" + ); assert_eq!(RoomCommandKind::CancelStart.action(), "cancel"); - assert_eq!(RoomCommandKind::BindAndSnapshot.action(), "bind_and_snapshot"); + assert_eq!( + RoomCommandKind::BindAndSnapshot.action(), + "bind_and_snapshot" + ); } #[test] diff --git a/phira-mp-plus-server/src/room_actor/handler.rs b/phira-mp-plus-server/src/room_actor/handler.rs index 8081f0ba9..2703d5da2 100644 --- a/phira-mp-plus-server/src/room_actor/handler.rs +++ b/phira-mp-plus-server/src/room_actor/handler.rs @@ -12,9 +12,11 @@ //! - `users()` / `monitors()` / `on_user_leave()` — user management use super::{ - command::{RoomActorCommand, RoomOrigin}, context::RoomCommandContext, - lifecycle::RoomLifecycle, BindAndSnapshotData, BindAndSnapshotUser, RoomCommandDelivery, - RoomCommandPayload, RoomCommandResult, + command::{RoomActorCommand, RoomOrigin}, + context::RoomCommandContext, + lifecycle::RoomLifecycle, + BindAndSnapshotData, BindAndSnapshotUser, RoomCommandDelivery, RoomCommandPayload, + RoomCommandResult, }; use crate::official_client_compat::protocol_trace::ProtocolTrace; use crate::plugin::PluginEvent; @@ -68,7 +70,9 @@ pub(super) async fn send_progress_notice( if !matches!(as_.state.lifecycle, InternalRoomState::Playing { .. }) { return; } - let Some(started) = as_.state.playing_started_at else { return }; + let Some(started) = as_.state.playing_started_at else { + return; + }; // 用户在房间(玩家或观战者)才发送;否则可能已离开。 let mut user = lc.users().await.into_iter().find(|u| u.id == user_id); @@ -86,8 +90,8 @@ pub(super) async fn send_progress_notice( let ratio = (elapsed / dur).clamp(0.0, 1.0); let percent = (ratio * 100.0).round() as i64; let filled = (ratio * 20.0).round() as usize; // 进度条宽度 20 - // 填充用深色 shade ▓、空用浅色 shade ░:同为 shade 渲染一致、等宽, - // 视觉比例准确(空格与 > 宽度不一致、█ 常渲染成不等宽正方形,均不可用)。 + // 填充用深色 shade ▓、空用浅色 shade ░:同为 shade 渲染一致、等宽, + // 视觉比例准确(空格与 > 宽度不一致、█ 常渲染成不等宽正方形,均不可用)。 let bar = format!("{}{}", "▓".repeat(filled), "░".repeat(20 - filled)); let remaining = ((dur - elapsed) / 60.0).max(0.0); @@ -103,8 +107,11 @@ pub(super) async fn send_progress_notice( &fluent::FluentArgs::new(), ), }; - user.try_send(ServerCommand::Message(Message::Chat { user: 0, content }), None) - .await; + user.try_send( + ServerCommand::Message(Message::Chat { user: 0, content }), + None, + ) + .await; } /// Helper: build an error result. @@ -125,7 +132,11 @@ fn ok(payload: RoomCommandPayload) -> RoomCommandResult { /// **事件产生时**读取它给 `SnapshotCovered` 事件打戳(`room_seq`),随出站 /// 条目携带,而非出站消费时读共享镜像——避免 N+1 over-stamp(audit §7.5)。 fn room_seq(lc: &dyn RoomLifecycle) -> Option { - Some(lc.room().last_room_seq.load(std::sync::atomic::Ordering::Relaxed)) + Some( + lc.room() + .last_room_seq + .load(std::sync::atomic::Ordering::Relaxed), + ) } /// PMP46 Blocker 2 / PMP47 B: 权威状态事件序号递增 + 写入 Room 广播总线的 @@ -154,7 +165,10 @@ fn deadline_refused(deadline: std::time::Instant) -> RoomCommandResult { crate::official_client_compat::protocol_trace::ProtocolTrace::get() .late_commit .fetch_add(1, std::sync::atomic::Ordering::Relaxed); - warn!(?deadline, "room command arrived after deadline; refusing to commit"); + warn!( + ?deadline, + "room command arrived after deadline; refusing to commit" + ); err("command deadline elapsed") } @@ -213,7 +227,11 @@ fn refuse_stale_origin() -> RoomCommandResult { } /// Helper: broadcast a state change via `on_state_change`. -async fn broadcast_state_change(lc: &dyn RoomLifecycle, state: &InternalRoomState, chart: Option) { +async fn broadcast_state_change( + lc: &dyn RoomLifecycle, + state: &InternalRoomState, + chart: Option, +) { let room_state = state.to_client(chart); lc.broadcast(ServerCommand::ChangeState(room_state)).await; let stripped = state.stripped(); @@ -268,7 +286,10 @@ async fn save_round_history( let mut users_map: HashMap = HashMap::new(); let room_ref = lc.room(); for u in lc.users().await { - let name = display_names.get(&u.id).cloned().unwrap_or_else(|| u.name.clone()); + let name = display_names + .get(&u.id) + .cloned() + .unwrap_or_else(|| u.name.clone()); users_map.insert(u.id, name); } @@ -403,7 +424,10 @@ async fn check_all_ready( // Clone the lifecycle to check state let lifecycle = as_.state.lifecycle.clone(); match &lifecycle { - InternalRoomState::WaitForReady { started, admin_started } => { + InternalRoomState::WaitForReady { + started, + admin_started, + } => { // 只算玩家(非观战)——观战无需准备,不应阻塞开赛。 let total: Vec<_> = lc.users().await; let ready_count = total.iter().filter(|it| started.contains(&it.id)).count(); @@ -415,17 +439,21 @@ async fn check_all_ready( } // Admin start (force start) skips the per-user ready check — all // players are moved directly into the game without waiting for Ready. - if *admin_started - || total.iter().all(|it| started.contains(&it.id)) - { + if *admin_started || total.iter().all(|it| started.contains(&it.id)) { // All ready — transition to Playing let prev_ready_countdown = as_.state.ready_countdown_started_at.take(); let prev_admin_pending = as_.state.control.admin_start_pending; if *admin_started { // Finish admin start as_.state.control.admin_start_pending = false; - if let Some(host) = lc.users().await.iter().find(|u| as_.state.control.host_id == Some(u.id)) { - host.try_send(ServerCommand::ChangeHost(true), room_seq(lc)).await; + if let Some(host) = lc + .users() + .await + .iter() + .find(|u| as_.state.control.host_id == Some(u.id)) + { + host.try_send(ServerCommand::ChangeHost(true), room_seq(lc)) + .await; } } let round_id = uuid::Uuid::new_v4(); @@ -482,8 +510,10 @@ async fn check_all_ready( // P0-F: clear the ready set so the room returns to an // explicitly retryable WaitingForReady instead of a // full-ready dead state where no further start can occur. - if let InternalRoomState::WaitForReady { started, admin_started } = - &mut as_.state.lifecycle + if let InternalRoomState::WaitForReady { + started, + admin_started, + } = &mut as_.state.lifecycle { started.clear(); *admin_started = false; @@ -491,13 +521,19 @@ async fn check_all_ready( as_.state.round.round_id = None; as_.state.control.admin_start_pending = prev_admin_pending; as_.state.ready_countdown_started_at = prev_ready_countdown; - lc.room().send_system_msg_simple("game-start-failed-retry").await; + lc.room() + .send_system_msg_simple("game-start-failed-retry") + .await; broadcast_state_change(lc, &as_.state.lifecycle, as_.state.chart).await; // PMP44 P0-N: 向此前所有已 Ready 的用户发送官方 CancelReady, // 使客户端本地 Ready 状态与服务器收敛(服务器 started 已清空)。 for uid in ready_before { if let Some(u) = lc.users().await.into_iter().find(|u| u.id == uid) { - u.try_send(ServerCommand::Message(Message::CancelReady { user: uid }), room_seq(lc)).await; + u.try_send( + ServerCommand::Message(Message::CancelReady { user: uid }), + room_seq(lc), + ) + .await; } } return ReadyCheckOutcome::StartFailed; @@ -524,7 +560,10 @@ async fn check_all_ready( ReadyCheckOutcome::Waiting } InternalRoomState::Playing { results, aborted } => { - if lc.users().await.into_iter() + if lc + .users() + .await + .into_iter() .all(|it| results.contains_key(&it.id) || aborted.contains(&it.id)) { let rid = as_.state.round.round_id; @@ -535,12 +574,14 @@ async fn check_all_ready( as_.state.chart, as_.state.chart_name.as_deref(), &as_.display_names, - ).await; + ) + .await; if let Some(round) = &completed_round { lc.publish_room_event(RoomEvent::StartRound { room: lc.room().id.clone(), round: crate::room::protocol_round(round), - }).await; + }) + .await; } // Round close is now part of the atomic commit_round_completed @@ -569,49 +610,85 @@ async fn check_all_ready( // play_history 缓存(结算显示的是当前局真实成绩,绝不被旧轮覆盖)。 // 赛事模式抑制每轮结算广播——由 PPB 统一汇总结算展示。 if !as_.state.control.tournament { - { - if let Some(last) = &completed_round { - let mut sorted = last.results.clone(); - sorted.sort_by(|a, b| b.score.cmp(&a.score)); - for user in lc.users().await.into_iter().chain(lc.monitors().await) { - let lang = user.lang.clone(); - // 标题行 - { - let mut args = fluent::FluentArgs::new(); - args.set("chart_name", &last.chart_name); - let content = crate::l10n::translate_system(&lang, "result-ranking-title", &args); - user.try_send(ServerCommand::Message(Message::Chat { user: 0, content }), room_seq(lc)).await; - } - // 每位玩家两行 - for (i, rr) in sorted.iter().enumerate() { - let status_str = if rr.aborted { - crate::l10n::translate_system(&lang, "result-aborted", &fluent::FluentArgs::new()) - } else { String::new() }; - let fc_str = if rr.full_combo { - crate::l10n::translate_system(&lang, "result-fc", &fluent::FluentArgs::new()) - } else { String::new() }; - let mut args = fluent::FluentArgs::new(); - args.set("rank", (i + 1) as i64); - args.set("name", &rr.user_name); - args.set("score", rr.score); - args.set("accuracy", format!("{:.2}", rr.accuracy * 100.0)); - args.set("std", format!("{:.1}", rr.std * 1000.0)); - args.set("fc", &fc_str); - args.set("status", &status_str); - let content = crate::l10n::translate_system(&lang, "result-player-line", &args); - user.try_send(ServerCommand::Message(Message::Chat { user: 0, content }), room_seq(lc)).await; - let mut args2 = fluent::FluentArgs::new(); - args2.set("perfect", rr.perfect); - args2.set("good", rr.good); - args2.set("bad", rr.bad); - args2.set("miss", rr.miss); - args2.set("max_combo", rr.max_combo); - let content = crate::l10n::translate_system(&lang, "result-detail-line", &args2); - user.try_send(ServerCommand::Message(Message::Chat { user: 0, content }), room_seq(lc)).await; + { + if let Some(last) = &completed_round { + let mut sorted = last.results.clone(); + sorted.sort_by(|a, b| b.score.cmp(&a.score)); + for user in lc.users().await.into_iter().chain(lc.monitors().await) { + let lang = user.lang.clone(); + // 标题行 + { + let mut args = fluent::FluentArgs::new(); + args.set("chart_name", &last.chart_name); + let content = crate::l10n::translate_system( + &lang, + "result-ranking-title", + &args, + ); + user.try_send( + ServerCommand::Message(Message::Chat { user: 0, content }), + room_seq(lc), + ) + .await; + } + // 每位玩家两行 + for (i, rr) in sorted.iter().enumerate() { + let status_str = if rr.aborted { + crate::l10n::translate_system( + &lang, + "result-aborted", + &fluent::FluentArgs::new(), + ) + } else { + String::new() + }; + let fc_str = if rr.full_combo { + crate::l10n::translate_system( + &lang, + "result-fc", + &fluent::FluentArgs::new(), + ) + } else { + String::new() + }; + let mut args = fluent::FluentArgs::new(); + args.set("rank", (i + 1) as i64); + args.set("name", &rr.user_name); + args.set("score", rr.score); + args.set("accuracy", format!("{:.2}", rr.accuracy * 100.0)); + args.set("std", format!("{:.1}", rr.std * 1000.0)); + args.set("fc", &fc_str); + args.set("status", &status_str); + let content = crate::l10n::translate_system( + &lang, + "result-player-line", + &args, + ); + user.try_send( + ServerCommand::Message(Message::Chat { user: 0, content }), + room_seq(lc), + ) + .await; + let mut args2 = fluent::FluentArgs::new(); + args2.set("perfect", rr.perfect); + args2.set("good", rr.good); + args2.set("bad", rr.bad); + args2.set("miss", rr.miss); + args2.set("max_combo", rr.max_combo); + let content = crate::l10n::translate_system( + &lang, + "result-detail-line", + &args2, + ); + user.try_send( + ServerCommand::Message(Message::Chat { user: 0, content }), + room_seq(lc), + ) + .await; + } } } } - } } // end !tournament(赛事模式抑制每轮结算广播) lc.send_msg(Message::GameEnd).await; as_.state.round.round_id = None; @@ -647,10 +724,13 @@ async fn check_all_ready( lc.send_msg(Message::NewHost { user: new_host.id }).await; if let Some(old_uid) = old_id { if let Some(old) = lc.users().await.iter().find(|u| u.id == old_uid) { - old.try_send(ServerCommand::ChangeHost(false), room_seq(lc)).await; + old.try_send(ServerCommand::ChangeHost(false), room_seq(lc)) + .await; } } - new_host.try_send(ServerCommand::ChangeHost(true), room_seq(lc)).await; + new_host + .try_send(ServerCommand::ChangeHost(true), room_seq(lc)) + .await; lc.publish_update(PartialRoomData { host: Some(new_host.id), ..Default::default() @@ -693,15 +773,25 @@ pub(super) async fn force_start_playing( InternalRoomState::WaitForReady { started, .. } => started.clone(), _ => HashSet::new(), }; - users.iter().map(|u| u.id).filter(|id| !ready.contains(id)).collect() + users + .iter() + .map(|u| u.id) + .filter(|id| !ready.contains(id)) + .collect() }; // If admin_started, restore host if let InternalRoomState::WaitForReady { admin_started, .. } = &state.lifecycle { if *admin_started { state.control.admin_start_pending = false; - if let Some(host) = lc.users().await.iter().find(|u| state.control.host_id == Some(u.id)) { - host.try_send(ServerCommand::ChangeHost(true), room_seq(lc)).await; + if let Some(host) = lc + .users() + .await + .iter() + .find(|u| state.control.host_id == Some(u.id)) + { + host.try_send(ServerCommand::ChangeHost(true), room_seq(lc)) + .await; } } } @@ -760,18 +850,28 @@ pub(super) async fn force_start_playing( }; // P0-F: clear the ready set so the room returns to an explicitly // retryable WaitingForReady instead of a full-ready dead state. - if let InternalRoomState::WaitForReady { started, admin_started } = &mut state.lifecycle { + if let InternalRoomState::WaitForReady { + started, + admin_started, + } = &mut state.lifecycle + { started.clear(); *admin_started = false; } state.round.round_id = None; state.ready_countdown_started_at = None; - lc.room().send_system_msg_simple("game-start-failed-retry").await; + lc.room() + .send_system_msg_simple("game-start-failed-retry") + .await; broadcast_state_change(lc, &state.lifecycle, state.chart).await; // PMP44 P0-N: 向此前已 Ready 的用户发送官方 CancelReady,收敛客户端状态。 for uid in ready_before { if let Some(u) = lc.users().await.into_iter().find(|u| u.id == uid) { - u.try_send(ServerCommand::Message(Message::CancelReady { user: uid }), room_seq(lc)).await; + u.try_send( + ServerCommand::Message(Message::CancelReady { user: uid }), + room_seq(lc), + ) + .await; } } return; @@ -813,7 +913,8 @@ pub(super) async fn force_start_playing( lc.dispatch_plugin_event(PluginEvent::GameStart { user_id: 0, room_id: lc.room().id.to_string(), - }).await; + }) + .await; } /// End the playing phase due to timeout. Unfinished players are marked aborted, @@ -829,7 +930,11 @@ pub(super) async fn force_end_playing( // 不会被认证 cutover 误删(audit §7.5)。 let _seq = bump_room_seq(lc, &mut *state).await; // Remove unfinished and un-aborted players by adding them to aborted - if let InternalRoomState::Playing { ref mut results, ref mut aborted } = &mut state.lifecycle { + if let InternalRoomState::Playing { + ref mut results, + ref mut aborted, + } = &mut state.lifecycle + { let users = lc.users().await; for u in &users { if !results.contains_key(&u.id) { @@ -843,7 +948,9 @@ pub(super) async fn force_end_playing( let all_done = match &state.lifecycle { InternalRoomState::Playing { results, aborted } => { let users = lc.users().await; - users.iter().all(|u| results.contains_key(&u.id) || aborted.contains(&u.id)) + users + .iter() + .all(|u| results.contains_key(&u.id) || aborted.contains(&u.id)) } _ => true, }; @@ -857,12 +964,14 @@ pub(super) async fn force_end_playing( state.chart, state.chart_name.as_deref(), &std::collections::HashMap::new(), // display_names not available here - ).await; + ) + .await; if let Some(round) = &completed_round { lc.publish_room_event(RoomEvent::StartRound { room: lc.room().id.clone(), round: crate::room::protocol_round(round), - }).await; + }) + .await; } } // Round close is now part of the atomic commit_round_completed @@ -892,7 +1001,14 @@ impl RoomCommandHandler { let lc: &dyn RoomLifecycle = ctx.lc; match command { - RoomActorCommand::SetLock { room_id, locked, actor_user_id, deadline, origin, .. } => { + RoomActorCommand::SetLock { + room_id, + locked, + actor_user_id, + deadline, + origin, + .. + } => { let as_ = ctx.expect_actor_state(); // P0-C/P0-G: never mutate lock state after the absolute actor deadline. if crate::official_client_compat::timing::deadline_expired(*deadline) { @@ -906,7 +1022,11 @@ impl RoomCommandHandler { // PMP46 Blocker 2: 权威状态变更前递增序号(audit §7.5)。 let _seq = bump_room_seq(lc, &mut as_.state).await; as_.state.set_locked(*locked); - lc.publish_update(PartialRoomData { lock: Some(*locked), ..Default::default() }).await; + lc.publish_update(PartialRoomData { + lock: Some(*locked), + ..Default::default() + }) + .await; lc.publish_runtime_event(crate::event_bus::MpEvent::RoomLocked { room_id: room_id.clone().try_into().unwrap(), locked: *locked, @@ -928,10 +1048,20 @@ impl RoomCommandHandler { .await; }, ); - ok(RoomCommandPayload::LockChanged { room_id: room_id.clone().to_string(), locked: *locked }) + ok(RoomCommandPayload::LockChanged { + room_id: room_id.clone().to_string(), + locked: *locked, + }) } - RoomActorCommand::SetCycle { room_id, cycle, actor_user_id, deadline, origin, .. } => { + RoomActorCommand::SetCycle { + room_id, + cycle, + actor_user_id, + deadline, + origin, + .. + } => { let as_ = ctx.expect_actor_state(); // P0-C/P0-G: never mutate cycle state after the absolute actor deadline. if crate::official_client_compat::timing::deadline_expired(*deadline) { @@ -945,7 +1075,11 @@ impl RoomCommandHandler { // PMP46 Blocker 2: 权威状态变更前递增序号(audit §7.5)。 let _seq = bump_room_seq(lc, &mut as_.state).await; as_.state.set_cycle(*cycle); - lc.publish_update(PartialRoomData { cycle: Some(*cycle), ..Default::default() }).await; + lc.publish_update(PartialRoomData { + cycle: Some(*cycle), + ..Default::default() + }) + .await; lc.publish_runtime_event(crate::event_bus::MpEvent::RoomCycled { room_id: room_id.clone().try_into().unwrap(), cycle: *cycle, @@ -966,22 +1100,34 @@ impl RoomCommandHandler { .await; }, ); - ok(RoomCommandPayload::CycleChanged { room_id: room_id.clone().to_string(), cycle: *cycle }) + ok(RoomCommandPayload::CycleChanged { + room_id: room_id.clone().to_string(), + cycle: *cycle, + }) } - RoomActorCommand::SetHidden { room_id, hidden, .. } => { + RoomActorCommand::SetHidden { + room_id, hidden, .. + } => { let as_ = ctx.expect_actor_state(); // PMP46 Blocker 2: 权威状态变更前递增序号(audit §7.5)。 let _seq = bump_room_seq(lc, &mut as_.state).await; as_.state.set_hidden(*hidden); lc.dispatch_plugin_event(PluginEvent::RoomModify { - user_id: 0, room_id: room_id.clone().to_string(), + user_id: 0, + room_id: room_id.clone().to_string(), data: json!({"action":"hidden","value":hidden}).to_string(), - }).await; - ok(RoomCommandPayload::HiddenChanged { room_id: room_id.clone().to_string(), hidden: *hidden }) + }) + .await; + ok(RoomCommandPayload::HiddenChanged { + room_id: room_id.clone().to_string(), + hidden: *hidden, + }) } - RoomActorCommand::SetHost { room_id, target_id, .. } => { + RoomActorCommand::SetHost { + room_id, target_id, .. + } => { let as_ = ctx.expect_actor_state(); // PMP46 Blocker 2: 权威状态变更前递增序号(audit §7.5)。 let _seq = bump_room_seq(lc, &mut as_.state).await; @@ -992,34 +1138,38 @@ impl RoomCommandHandler { let users = lc.users().await; users.iter().find(|u| u.id == *uid).map(|u| u.name.clone()) }; - let name = as_.display_names.get(uid) + let name = as_ + .display_names + .get(uid) .cloned() .or(fallback_name) .unwrap_or_else(|| uid.to_string()); // Send messages directly via Room broadcast let name_clone = name.clone(); if as_.state.control.host_id.is_some() { - lc.room().send_system_msg( - &|lang| { + lc.room() + .send_system_msg(&|lang| { let mut a = fluent::FluentArgs::new(); a.set("name", &name_clone); crate::l10n::translate_system(lang, "host-transferred-to", &a) - }, - ).await; + }) + .await; } else { - lc.room().send_system_msg( - &|lang| { + lc.room() + .send_system_msg(&|lang| { let mut a = fluent::FluentArgs::new(); a.set("name", &name_clone); crate::l10n::translate_system(lang, "user-became-host", &a) - }, - ).await; + }) + .await; } // Notify old host if let Some(old_uid) = as_.state.control.host_id { if old_uid != *uid { - if let Some(old) = lc.users().await.iter().find(|u| u.id == old_uid) { - old.try_send(ServerCommand::ChangeHost(false), room_seq(lc)).await; + if let Some(old) = lc.users().await.iter().find(|u| u.id == old_uid) + { + old.try_send(ServerCommand::ChangeHost(false), room_seq(lc)) + .await; } } } @@ -1029,12 +1179,14 @@ impl RoomCommandHandler { // Announce lc.send_msg(Message::NewHost { user: *uid }).await; if let Some(u) = lc.users().await.iter().find(|u| u.id == *uid) { - u.try_send(ServerCommand::ChangeHost(true), room_seq(lc)).await; + u.try_send(ServerCommand::ChangeHost(true), room_seq(lc)) + .await; } lc.publish_update(PartialRoomData { host: Some(*uid), ..Default::default() - }).await; + }) + .await; (Some(*uid), name, false) } None => { @@ -1042,7 +1194,8 @@ impl RoomCommandHandler { // Notify old host if let Some(old_uid) = as_.state.control.host_id { if let Some(old) = lc.users().await.iter().find(|u| u.id == old_uid) { - old.try_send(ServerCommand::ChangeHost(false), room_seq(lc)).await; + old.try_send(ServerCommand::ChangeHost(false), room_seq(lc)) + .await; } } as_.state.control.host_id = None; @@ -1051,7 +1204,8 @@ impl RoomCommandHandler { lc.publish_update(PartialRoomData { host: Some(-1), ..Default::default() - }).await; + }) + .await; (None, "?".to_string(), true) } }; @@ -1060,18 +1214,26 @@ impl RoomCommandHandler { host: *target_id, }); ok(RoomCommandPayload::HostChanged { - room_id: room_id.clone().to_string(), host: *target_id, host_name, host_is_system: system_host, + room_id: room_id.clone().to_string(), + host: *target_id, + host_name, + host_is_system: system_host, }) } - RoomActorCommand::SetEndpoint { room_id, endpoint, .. } => { + RoomActorCommand::SetEndpoint { + room_id, endpoint, .. + } => { let as_ = ctx.expect_actor_state(); // PMP46 Blocker 2: 权威状态变更前递增序号(audit §7.5)。 let _seq = bump_room_seq(lc, &mut as_.state).await; let endpoint = endpoint.clone(); as_.state.control.phira_api_endpoint = endpoint.clone(); ok(RoomCommandPayload::EndpointChanged { - room_id: room_id.clone().to_string(), endpoint: endpoint.clone().unwrap_or_default(), endpoint_override: endpoint.clone(), using_room_override: false, + room_id: room_id.clone().to_string(), + endpoint: endpoint.clone().unwrap_or_default(), + endpoint_override: endpoint.clone(), + using_room_override: false, }) } @@ -1081,50 +1243,77 @@ impl RoomCommandHandler { let as_ = ctx.expect_actor_state(); let _seq = bump_room_seq(lc, &mut as_.state).await; } - lc.room().send_system_msg_simple("room-closed-by-admin").await; + lc.room() + .send_system_msg_simple("room-closed-by-admin") + .await; for user in lc.users().await { *user.room.write().await = None; - user.try_send(ServerCommand::LeaveRoom(Ok(())), room_seq(lc)).await; - lc.publish_room_event(RoomEvent::LeaveRoom { room: lc.room().id.clone(), user: user.id }).await; + user.try_send(ServerCommand::LeaveRoom(Ok(())), room_seq(lc)) + .await; + lc.publish_room_event(RoomEvent::LeaveRoom { + room: lc.room().id.clone(), + user: user.id, + }) + .await; } for monitor in lc.monitors().await { *monitor.room.write().await = None; - monitor.try_send(ServerCommand::LeaveRoom(Ok(())), room_seq(lc)).await; + monitor + .try_send(ServerCommand::LeaveRoom(Ok(())), room_seq(lc)) + .await; } lc.remove_room(&lc.room().id).await; lc.dispatch_plugin_event(PluginEvent::RoomModify { - user_id: 0, room_id: lc.room().id.to_string(), + user_id: 0, + room_id: lc.room().id.to_string(), data: json!({"action":"closed"}).to_string(), - }).await; - ok(RoomCommandPayload::RoomClosed { room_id: lc.room().id.to_string() }) + }) + .await; + ok(RoomCommandPayload::RoomClosed { + room_id: lc.room().id.to_string(), + }) } - RoomActorCommand::KickUser { room_id, target_id, .. } => { + RoomActorCommand::KickUser { + room_id, target_id, .. + } => { let users = lc.users().await; let monitors = lc.monitors().await; - let user = match users.into_iter().chain(monitors).find(|u| u.id == *target_id) { - Some(u) => u, None => return err("user not in room"), + let user = match users + .into_iter() + .chain(monitors) + .find(|u| u.id == *target_id) + { + Some(u) => u, + None => return err("user not in room"), }; let name = user.name.clone(); - lc.room().send_system_msg( - &|lang| { + lc.room() + .send_system_msg(&|lang| { let mut a = fluent::FluentArgs::new(); a.set("name", &name); crate::l10n::translate_system(lang, "user-kicked-from-room", &a) - }, - ).await; + }) + .await; // PMP46 Blocker 2: 权威成员移除前递增序号(audit §7.5)。 { let as_ = ctx.expect_actor_state(); let _seq = bump_room_seq(lc, &mut as_.state).await; } let was_monitor = user.monitor.load(std::sync::atomic::Ordering::SeqCst); - let should_drop = lc.on_user_leave(&user).await - && !lc.room().control_snapshot().persistent_empty; - user.try_send(ServerCommand::LeaveRoom(Ok(())), room_seq(lc)).await; - if should_drop { lc.remove_room(&lc.room().id).await; } + let should_drop = + lc.on_user_leave(&user).await && !lc.room().control_snapshot().persistent_empty; + user.try_send(ServerCommand::LeaveRoom(Ok(())), room_seq(lc)) + .await; + if should_drop { + lc.remove_room(&lc.room().id).await; + } if !was_monitor { - lc.publish_room_event(RoomEvent::LeaveRoom { room: lc.room().id.clone(), user: *target_id }).await; + lc.publish_room_event(RoomEvent::LeaveRoom { + room: lc.room().id.clone(), + user: *target_id, + }) + .await; } // Clean up cached player data and display names for the kicked user. let as_ = ctx.expect_actor_state(); @@ -1141,19 +1330,21 @@ impl RoomCommandHandler { as_.state.control.host_id = Some(next.id); as_.state.control.system_host = false; let next_name = next.name.clone(); - lc.room().send_system_msg( - &|lang| { + lc.room() + .send_system_msg(&|lang| { let mut a = fluent::FluentArgs::new(); a.set("name", &next_name); crate::l10n::translate_system(lang, "host-transferred-to", &a) - }, - ).await; + }) + .await; lc.send_msg(Message::NewHost { user: next.id }).await; - next.try_send(ServerCommand::ChangeHost(true), room_seq(lc)).await; + next.try_send(ServerCommand::ChangeHost(true), room_seq(lc)) + .await; lc.publish_update(PartialRoomData { host: Some(next.id), ..Default::default() - }).await; + }) + .await; } else if as_.state.control.persistent_empty { let _seq = bump_room_seq(lc, &mut as_.state).await; as_.state.control.host_id = None; @@ -1162,16 +1353,21 @@ impl RoomCommandHandler { lc.publish_update(PartialRoomData { host: Some(-1), ..Default::default() - }).await; + }) + .await; } } lc.dispatch_plugin_event(PluginEvent::RoomModify { - user_id: *target_id, room_id: room_id.clone().to_string(), + user_id: *target_id, + room_id: room_id.clone().to_string(), data: json!({"action":"kicked"}).to_string(), - }).await; + }) + .await; ok(RoomCommandPayload::UserKicked { - room_id: room_id.clone().to_string(), user_id: *target_id, - user_name: user.name.clone(), room_dropped: should_drop, + room_id: room_id.clone().to_string(), + user_id: *target_id, + user_name: user.name.clone(), + room_dropped: should_drop, }) } @@ -1194,8 +1390,14 @@ impl RoomCommandHandler { broadcast_state_change(lc, &as_.state.lifecycle, as_.state.chart).await; // Temporarily remove host privileges - if let Some(host) = lc.users().await.iter().find(|u| as_.state.control.host_id == Some(u.id)) { - host.try_send(ServerCommand::ChangeHost(false), room_seq(lc)).await; + if let Some(host) = lc + .users() + .await + .iter() + .find(|u| as_.state.control.host_id == Some(u.id)) + { + host.try_send(ServerCommand::ChangeHost(false), room_seq(lc)) + .await; } lc.reset_game_time().await; @@ -1207,9 +1409,20 @@ impl RoomCommandHandler { }; as_.state.ready_countdown_started_at = Some(now_ms()); broadcast_state_change(lc, &as_.state.lifecycle, as_.state.chart).await; - let _ = check_all_ready(lc, as_, std::time::Instant::now() + std::time::Duration::from_secs(30)).await; - lc.dispatch_plugin_event(PluginEvent::GameStart { user_id: 0, room_id: room_id.clone().to_string() }).await; - ok(RoomCommandPayload::RoomStarted { room_id: room_id.clone().to_string() }) + let _ = check_all_ready( + lc, + as_, + std::time::Instant::now() + std::time::Duration::from_secs(30), + ) + .await; + lc.dispatch_plugin_event(PluginEvent::GameStart { + user_id: 0, + room_id: room_id.clone().to_string(), + }) + .await; + ok(RoomCommandPayload::RoomStarted { + room_id: room_id.clone().to_string(), + }) } RoomActorCommand::EnterReadyPhase { room_id, .. } => { @@ -1266,20 +1479,31 @@ impl RoomCommandHandler { .await; }, ); - ok(RoomCommandPayload::RoomStarted { room_id: room_id.clone().to_string() }) + ok(RoomCommandPayload::RoomStarted { + room_id: room_id.clone().to_string(), + }) } RoomActorCommand::CancelStart { room_id, .. } => { let as_ = ctx.expect_actor_state(); - let canceled = matches!(as_.state.lifecycle, InternalRoomState::WaitForReady { .. }); + let canceled = + matches!(as_.state.lifecycle, InternalRoomState::WaitForReady { .. }); if canceled { // PMP46 Blocker 2: 权威状态变更前递增序号(audit §7.5)。 let _seq = bump_room_seq(lc, &mut as_.state).await; // Restore host privileges if admin_started - if let InternalRoomState::WaitForReady { admin_started, .. } = &as_.state.lifecycle { + if let InternalRoomState::WaitForReady { admin_started, .. } = + &as_.state.lifecycle + { if *admin_started { - if let Some(host) = lc.users().await.iter().find(|u| as_.state.control.host_id == Some(u.id)) { - host.try_send(ServerCommand::ChangeHost(true), room_seq(lc)).await; + if let Some(host) = lc + .users() + .await + .iter() + .find(|u| as_.state.control.host_id == Some(u.id)) + { + host.try_send(ServerCommand::ChangeHost(true), room_seq(lc)) + .await; } } } @@ -1289,10 +1513,21 @@ impl RoomCommandHandler { lc.send_msg(Message::CancelGame { user: 0 }).await; broadcast_state_change(lc, &as_.state.lifecycle, as_.state.chart).await; } - ok(RoomCommandPayload::CancelResult { room_id: room_id.clone().to_string(), canceled }) + ok(RoomCommandPayload::CancelResult { + room_id: room_id.clone().to_string(), + canceled, + }) } - RoomActorCommand::SetChart { room_id, chart_id, chart_name, actor_user_id, deadline, origin, .. } => { + RoomActorCommand::SetChart { + room_id, + chart_id, + chart_name, + actor_user_id, + deadline, + origin, + .. + } => { let as_ = ctx.expect_actor_state(); if !matches!(as_.state.lifecycle, InternalRoomState::SelectChart) { return err("cannot set chart outside SelectChart state"); @@ -1311,24 +1546,40 @@ impl RoomCommandHandler { let _seq = bump_room_seq(lc, &mut as_.state).await; as_.state.chart = Some(*chart_id); as_.state.chart_name = Some(chart_name.clone()); - lc.send_msg(Message::SelectChart { user: *actor_user_id, name: chart_name.clone(), id: *chart_id }).await; + lc.send_msg(Message::SelectChart { + user: *actor_user_id, + name: chart_name.clone(), + id: *chart_id, + }) + .await; broadcast_state_change(lc, &as_.state.lifecycle, as_.state.chart).await; - lc.publish_update(phira_mp_common::PartialRoomData { chart: Some(*chart_id), ..Default::default() }).await; + lc.publish_update(phira_mp_common::PartialRoomData { + chart: Some(*chart_id), + ..Default::default() + }) + .await; lc.publish_runtime_event(crate::event_bus::MpEvent::ChartSelected { room_id: room_id.clone().try_into().unwrap(), chart_id: *chart_id, }); - ok(RoomCommandPayload::ChartSelected { room_id: room_id.clone().to_string(), chart_id: *chart_id }) + ok(RoomCommandPayload::ChartSelected { + room_id: room_id.clone().to_string(), + chart_id: *chart_id, + }) } - RoomActorCommand::SetChartDuration { room_id, duration, .. } => { + RoomActorCommand::SetChartDuration { + room_id, duration, .. + } => { let as_ = ctx.expect_actor_state(); debug!(room = %room_id, duration = ?duration, "chart duration set"); as_.state.chart_duration = *duration; ok(RoomCommandPayload::ChartDurationSet) } - RoomActorCommand::RegisterProgress { room_id, user_id, .. } => { + RoomActorCommand::RegisterProgress { + room_id, user_id, .. + } => { let as_ = ctx.expect_actor_state(); debug!(room = %room_id, user = %user_id, "register progress subscriber"); // 复核:仅游玩中的房间注册进度通知;已结算(非 Playing)则忽略。 @@ -1339,7 +1590,13 @@ impl RoomCommandHandler { ok(RoomCommandPayload::ProgressRegistered) } - RoomActorCommand::SetReady { room_id, user_id, deadline, origin, .. } => { + RoomActorCommand::SetReady { + room_id, + user_id, + deadline, + origin, + .. + } => { let as_ = ctx.expect_actor_state(); // P0-G: never write Ready after the absolute actor deadline. if crate::official_client_compat::timing::deadline_expired(*deadline) { @@ -1353,15 +1610,24 @@ impl RoomCommandHandler { // PMP46 Blocker 2: 权威状态变更前递增序号(audit §7.5)。 let _seq = bump_room_seq(lc, &mut as_.state).await; match &mut as_.state.lifecycle { - InternalRoomState::WaitForReady { ref mut started, .. } => { - if !started.insert(*user_id) { return err("already ready"); } + InternalRoomState::WaitForReady { + ref mut started, .. + } => { + if !started.insert(*user_id) { + return err("already ready"); + } lc.send_msg(Message::Ready { user: *user_id }).await; lc.publish_runtime_event(crate::event_bus::MpEvent::PlayerReadyChanged { - room_id: room_id.clone().try_into().unwrap(), user_id: *user_id, ready: true, + room_id: room_id.clone().try_into().unwrap(), + user_id: *user_id, + ready: true, }); match check_all_ready(lc, as_, *deadline).await { ReadyCheckOutcome::Started | ReadyCheckOutcome::Waiting => { - ok(RoomCommandPayload::UserReady { room_id: room_id.clone().to_string(), user_id: *user_id }) + ok(RoomCommandPayload::UserReady { + room_id: room_id.clone().to_string(), + user_id: *user_id, + }) } ReadyCheckOutcome::StartFailed => { // PMP45 P0-L: round open 失败——服务器已清空 started, @@ -1375,11 +1641,20 @@ impl RoomCommandHandler { // P0-D: official phira-mp returns Ok (silent no-op) for Ready // outside WaitForReady — NOT an error. Replicate the official // server's observable behavior. - _ => ok(RoomCommandPayload::UserReady { room_id: room_id.clone().to_string(), user_id: *user_id }), + _ => ok(RoomCommandPayload::UserReady { + room_id: room_id.clone().to_string(), + user_id: *user_id, + }), } } - RoomActorCommand::CancelReady { room_id, user_id, deadline, origin, .. } => { + RoomActorCommand::CancelReady { + room_id, + user_id, + deadline, + origin, + .. + } => { let as_ = ctx.expect_actor_state(); // P0-G: never mutate CancelReady state after the absolute deadline. if crate::official_client_compat::timing::deadline_expired(*deadline) { @@ -1394,14 +1669,21 @@ impl RoomCommandHandler { let _seq = bump_room_seq(lc, &mut as_.state).await; let was_host = as_.state.control.host_id == Some(*user_id); match &mut as_.state.lifecycle { - InternalRoomState::WaitForReady { ref mut started, .. } => { - if !started.remove(user_id) { return err("not ready"); } + InternalRoomState::WaitForReady { + ref mut started, .. + } => { + if !started.remove(user_id) { + return err("not ready"); + } if was_host { // All users' host cancels the game. Official core // sequence: CancelGame → SelectChart → state change. let admin_started = matches!( &as_.state.lifecycle, - InternalRoomState::WaitForReady { admin_started: true, .. } + InternalRoomState::WaitForReady { + admin_started: true, + .. + } ); as_.state.control.admin_start_pending = false; as_.state.ready_countdown_started_at = None; @@ -1411,25 +1693,55 @@ impl RoomCommandHandler { // P0-D: PMP extension — restore host privileges AFTER // the official core sequence, never interleaved. if admin_started { - if let Some(host) = lc.users().await.iter().find(|u| as_.state.control.host_id == Some(u.id)) { - host.try_send(ServerCommand::ChangeHost(true), room_seq(lc)).await; + if let Some(host) = lc + .users() + .await + .iter() + .find(|u| as_.state.control.host_id == Some(u.id)) + { + host.try_send(ServerCommand::ChangeHost(true), room_seq(lc)) + .await; } } } else { lc.send_msg(Message::CancelReady { user: *user_id }).await; } lc.publish_runtime_event(crate::event_bus::MpEvent::PlayerReadyChanged { - room_id: room_id.clone().try_into().unwrap(), user_id: *user_id, ready: false, + room_id: room_id.clone().try_into().unwrap(), + user_id: *user_id, + ready: false, }); - ok(RoomCommandPayload::UserNotReady { room_id: room_id.clone().to_string(), user_id: *user_id }) + ok(RoomCommandPayload::UserNotReady { + room_id: room_id.clone().to_string(), + user_id: *user_id, + }) } // P0-D: official phira-mp returns Ok (silent no-op) for // CancelReady outside WaitForReady — NOT an error. - _ => ok(RoomCommandPayload::UserNotReady { room_id: room_id.clone().to_string(), user_id: *user_id }), + _ => ok(RoomCommandPayload::UserNotReady { + room_id: room_id.clone().to_string(), + user_id: *user_id, + }), } } - RoomActorCommand::SubmitResult { room_id, user_id, score, accuracy, perfect, good, bad, miss, max_combo, full_combo, std, std_score, deadline, origin, .. } => { + RoomActorCommand::SubmitResult { + room_id, + user_id, + score, + accuracy, + perfect, + good, + bad, + miss, + max_combo, + full_combo, + std, + std_score, + deadline, + origin, + .. + } => { // P0-C/P0-G: never insert a result after the absolute actor deadline. if crate::official_client_compat::timing::deadline_expired(*deadline) { // 迟到的成绩被拒绝(返回 deadline 错误给客户端),但绝不能因此 @@ -1439,7 +1751,9 @@ impl RoomCommandHandler { let mut mark = false; { let as_ = ctx.expect_actor_state(); - if let InternalRoomState::Playing { results, aborted } = &mut as_.state.lifecycle { + if let InternalRoomState::Playing { results, aborted } = + &mut as_.state.lifecycle + { if !results.contains_key(user_id) && !aborted.contains(user_id) { aborted.insert(*user_id); mark = true; @@ -1463,29 +1777,50 @@ impl RoomCommandHandler { // PMP46 Blocker 2: 权威状态变更前递增序号(audit §7.5)。 let _seq = bump_room_seq(lc, &mut as_.state).await; let record = crate::server::Record { - id: 0, player: *user_id, score: *score, perfect: *perfect, - good: *good, bad: *bad, miss: *miss, max_combo: *max_combo, - accuracy: *accuracy, full_combo: *full_combo, std: *std, std_score: *std_score, + id: 0, + player: *user_id, + score: *score, + perfect: *perfect, + good: *good, + bad: *bad, + miss: *miss, + max_combo: *max_combo, + accuracy: *accuracy, + full_combo: *full_combo, + std: *std, + std_score: *std_score, }; // player_score 域事件用协议 Record(字段与 server::Record 一致)。 let event_record = phira_mp_common::Record { - id: record.id, player: record.player, score: record.score, - perfect: record.perfect, good: record.good, bad: record.bad, - miss: record.miss, max_combo: record.max_combo, - accuracy: record.accuracy, full_combo: record.full_combo, - std: record.std, std_score: record.std_score, + id: record.id, + player: record.player, + score: record.score, + perfect: record.perfect, + good: record.good, + bad: record.bad, + miss: record.miss, + max_combo: record.max_combo, + accuracy: record.accuracy, + full_combo: record.full_combo, + std: record.std, + std_score: record.std_score, }; match &mut as_.state.lifecycle { InternalRoomState::Playing { results, aborted } => { - if aborted.contains(user_id) { return err("user aborted"); } - if results.insert(*user_id, record).is_some() { return err("already uploaded"); } + if aborted.contains(user_id) { + return err("user aborted"); + } + if results.insert(*user_id, record).is_some() { + return err("already uploaded"); + } } _ => return err("not in Playing state"), } lc.publish_room_event(RoomEvent::PlayerScore { room: lc.room().id.clone(), record: event_record, - }).await; + }) + .await; // 首个完成者出现后延长对局超时(给其他玩家追赶时间) if let InternalRoomState::Playing { results, .. } = &as_.state.lifecycle { let users = lc.users().await; @@ -1493,14 +1828,30 @@ impl RoomCommandHandler { let total = users.len(); if finished == 1 && total > 1 { // 第一个完成,延长截止时间 - let offset = (lc.server_state().config.playing_timeout_offset_secs as f64) * 1000.0; + let offset = + (lc.server_state().config.playing_timeout_offset_secs as f64) * 1000.0; if offset > 0.0 { - as_.state.playing_timeout_deadline = as_.state.playing_timeout_deadline.map(|d| d + offset as i64); - debug!("playing timeout extended by {}ms after first finish", offset as i64); + as_.state.playing_timeout_deadline = as_ + .state + .playing_timeout_deadline + .map(|d| d + offset as i64); + debug!( + "playing timeout extended by {}ms after first finish", + offset as i64 + ); } } } - lc.send_msg(Message::Played { user: *user_id, score: *score, accuracy: *accuracy, full_combo: *full_combo, perfect: *perfect, good: *good, bad: *bad, miss: *miss, max_combo: *max_combo }).await; + // Keep the official v1 wire shape. Detailed judgement counts + // remain in the internal record/plugin event, not in the + // compatibility Message::Played packet. + lc.send_msg(Message::Played { + user: *user_id, + score: *score, + accuracy: *accuracy, + full_combo: *full_combo, + }) + .await; let _ = check_all_ready(lc, as_, *deadline).await; // PMP45 P0-O: GameEnd 插件事件是 response-after——插件回调(WASM) // 绝不阻塞 Actor reply(audit §26)。权威提交(results 插入 + @@ -1535,10 +1886,20 @@ impl RoomCommandHandler { .await; }, ); - ok(RoomCommandPayload::RoundResultSubmitted { room_id: room_id.clone().to_string(), user_id: *user_id, score: *score }) + ok(RoomCommandPayload::RoundResultSubmitted { + room_id: room_id.clone().to_string(), + user_id: *user_id, + score: *score, + }) } - RoomActorCommand::AbortRound { room_id, user_id, deadline, origin, .. } => { + RoomActorCommand::AbortRound { + room_id, + user_id, + deadline, + origin, + .. + } => { let as_ = ctx.expect_actor_state(); // P0-C/P0-G: never insert an abort after the absolute actor deadline. if crate::official_client_compat::timing::deadline_expired(*deadline) { @@ -1553,23 +1914,40 @@ impl RoomCommandHandler { let _seq = bump_room_seq(lc, &mut as_.state).await; match &mut as_.state.lifecycle { InternalRoomState::Playing { results, aborted } => { - if results.contains_key(user_id) { return err("already uploaded"); } - if !aborted.insert(*user_id) { return err("already aborted"); } + if results.contains_key(user_id) { + return err("already uploaded"); + } + if !aborted.insert(*user_id) { + return err("already aborted"); + } } _ => return err("not in Playing state"), } lc.send_msg(Message::Abort { user: *user_id }).await; let _ = check_all_ready(lc, as_, *deadline).await; - ok(RoomCommandPayload::RoundAborted { room_id: room_id.clone().to_string(), user_id: *user_id }) + ok(RoomCommandPayload::RoundAborted { + room_id: room_id.clone().to_string(), + user_id: *user_id, + }) } - RoomActorCommand::HostStart { room_id, user_id, deadline, origin, .. } => { + RoomActorCommand::HostStart { + room_id, + user_id, + deadline, + origin, + .. + } => { let as_ = ctx.expect_actor_state(); if !matches!(as_.state.lifecycle, InternalRoomState::SelectChart) { return err("room is not selecting a chart"); } - if as_.state.control.admin_start_pending { return err("administrative start is already in progress"); } - if as_.state.chart.is_none() { return err("no chart selected"); } + if as_.state.control.admin_start_pending { + return err("administrative start is already in progress"); + } + if as_.state.chart.is_none() { + return err("no chart selected"); + } // P0-G: never transition to WaitForReady after the absolute // actor deadline. if crate::official_client_compat::timing::deadline_expired(*deadline) { @@ -1589,7 +1967,8 @@ impl RoomCommandHandler { lc.reset_game_time().await; lc.send_msg(Message::GameStart { user: *user_id }).await; as_.state.lifecycle = InternalRoomState::WaitForReady { - started: std::iter::once(*user_id).collect(), admin_started: false, + started: std::iter::once(*user_id).collect(), + admin_started: false, }; as_.state.ready_countdown_started_at = Some(now_ms()); broadcast_state_change(lc, &as_.state.lifecycle, as_.state.chart).await; @@ -1620,10 +1999,21 @@ impl RoomCommandHandler { .await; }, ); - ok(RoomCommandPayload::HostStarted { room_id: room_id.clone().to_string() }) + ok(RoomCommandPayload::HostStarted { + room_id: room_id.clone().to_string(), + }) } - RoomActorCommand::AddUser { room_id, user_id, user_name: _, monitor, deadline, origin, .. } => { + RoomActorCommand::AddUser { + room_id, + user_id, + user_name: _, + connection, + monitor, + deadline, + origin, + .. + } => { let as_ = ctx.expect_actor_state(); // PMP45 P0-K: 房间处于 degraded(Join 补偿失败遗留 Ghost member, // 成员状态不确定)——在操作员 / 未来 reconcile 清空之前拒绝新的 @@ -1642,10 +2032,30 @@ impl RoomCommandHandler { if origin_stale(lc, origin, *user_id).await { return refuse_stale_origin(); } - let current_count = lc.users().await.len(); + let already_present = as_.state.members.users.contains(user_id) + || as_.state.members.monitors.contains(user_id); + if already_present { + return err("user already in room"); + } + // Capacity is checked against actor-owned membership. The + // connection registry is only a delivery index and is not a + // safe source for admission decisions. + let current_count = as_.state.members.users.len(); if current_count >= as_.state.control.max_users && !monitor { return err("room is full"); } + // Attach the live connection before publishing the actor + // membership. If the registry rejects it, no authoritative + // membership is committed and callers can retry safely. + if let Some(connection) = connection { + if !lc + .room() + .add_user(Arc::downgrade(connection), *monitor) + .await + { + return err("failed to register user connection"); + } + } // PMP46 Blocker 2: 权威状态变更前递增序号(audit §7.5)。 let _seq = bump_room_seq(lc, &mut as_.state).await; if !as_.state.live { @@ -1682,13 +2092,20 @@ impl RoomCommandHandler { }, ); ok(RoomCommandPayload::UserAdded { - room_id: room_id.clone().to_string(), user_id: *user_id, + room_id: room_id.clone().to_string(), + user_id: *user_id, monitor: *monitor, room_full: current_count + 1 >= as_.state.control.max_users, }) } - RoomActorCommand::RemoveUser { room_id, user_id, deadline, origin, .. } => { + RoomActorCommand::RemoveUser { + room_id, + user_id, + deadline, + origin, + .. + } => { // P0-C/P0-G: never mutate membership after the absolute actor deadline. if crate::official_client_compat::timing::deadline_expired(*deadline) { return deadline_refused(*deadline); @@ -1701,7 +2118,10 @@ impl RoomCommandHandler { let user = { let users = lc.users().await; let monitors = lc.monitors().await; - users.iter().find(|u| u.id == *user_id).cloned() + users + .iter() + .find(|u| u.id == *user_id) + .cloned() .or_else(|| monitors.iter().find(|u| u.id == *user_id).cloned()) }; // PMP46 Blocker 2: 权威成员移除前递增序号(audit §7.5)。 @@ -1714,9 +2134,15 @@ impl RoomCommandHandler { let was_monitor = user.monitor.load(std::sync::atomic::Ordering::SeqCst); let should_drop = lc.on_user_leave(&user).await && !lc.room().control_snapshot().persistent_empty; - if should_drop { lc.remove_room(&lc.room().id).await; } + if should_drop { + lc.remove_room(&lc.room().id).await; + } if !was_monitor { - lc.publish_room_event(RoomEvent::LeaveRoom { room: lc.room().id.clone(), user: *user_id }).await; + lc.publish_room_event(RoomEvent::LeaveRoom { + room: lc.room().id.clone(), + user: *user_id, + }) + .await; } // Clean up cached player data and display names for the removed user, // and remove from authoritative members list. @@ -1752,19 +2178,25 @@ impl RoomCommandHandler { as_.state.control.host_id = Some(next.id); as_.state.control.system_host = false; let next_name = next.name.clone(); - lc.room().send_system_msg( - &|lang| { + lc.room() + .send_system_msg(&|lang| { let mut a = fluent::FluentArgs::new(); a.set("name", &next_name); - crate::l10n::translate_system(lang, "host-transferred-to", &a) - }, - ).await; + crate::l10n::translate_system( + lang, + "host-transferred-to", + &a, + ) + }) + .await; lc.send_msg(Message::NewHost { user: next.id }).await; - next.try_send(ServerCommand::ChangeHost(true), room_seq(lc)).await; + next.try_send(ServerCommand::ChangeHost(true), room_seq(lc)) + .await; lc.publish_update(PartialRoomData { host: Some(next.id), ..Default::default() - }).await; + }) + .await; } else if as_.state.control.persistent_empty { // Room became empty but is persistent — revert to // the system host (-1) so the room stays joinable @@ -1776,7 +2208,8 @@ impl RoomCommandHandler { lc.publish_update(PartialRoomData { host: Some(-1), ..Default::default() - }).await; + }) + .await; } } // PMP45 P0-O: 插件回调是 response-after(spawn,不经过 room @@ -1806,7 +2239,9 @@ impl RoomCommandHandler { }, ); ok(RoomCommandPayload::UserRemoved { - room_id: room_id.clone().to_string(), user_id: *user_id, room_dropped: should_drop, + room_id: room_id.clone().to_string(), + user_id: *user_id, + room_dropped: should_drop, }) } None => { @@ -1846,11 +2281,14 @@ impl RoomCommandHandler { tracing::info!(room = %room_id, "room goes live via set_live"); } ok(RoomCommandPayload::LiveChanged { - room_id: room_id.clone().to_string(), live: *live, + room_id: room_id.clone().to_string(), + live: *live, }) } - RoomActorCommand::SetDegraded { room_id, degraded, .. } => { + RoomActorCommand::SetDegraded { + room_id, degraded, .. + } => { let as_ = ctx.expect_actor_state(); // PMP45 P0-K: 设置房间 degraded 标志——Join 补偿失败时置 true //(AddUser 将拒绝新的 Join,直到操作员 / 未来的 reconcile 清空), @@ -1869,7 +2307,11 @@ impl RoomCommandHandler { ok(RoomCommandPayload::Empty) } - RoomActorCommand::SetTournament { room_id, tournament, .. } => { + RoomActorCommand::SetTournament { + room_id, + tournament, + .. + } => { let as_ = ctx.expect_actor_state(); // 赛事模式房间(房间级配置):置位后禁用 PMP 默认交互行为, // 交 PPB 编排。权威状态变更前递增序号(audit §7.5)。 @@ -1883,17 +2325,28 @@ impl RoomCommandHandler { ok(RoomCommandPayload::Empty) } - RoomActorCommand::SetDisplayName { room_id, user_id, name, .. } => { + RoomActorCommand::SetDisplayName { + room_id, + user_id, + name, + .. + } => { let as_ = ctx.expect_actor_state(); // PMP46 Blocker 2: 权威状态变更前递增序号(audit §7.5)。 let _seq = bump_room_seq(lc, &mut as_.state).await; as_.display_names.insert(*user_id, name.clone()); ok(RoomCommandPayload::DisplayNameSet { - room_id: room_id.clone().to_string(), user_id: *user_id, name: name.clone(), + room_id: room_id.clone().to_string(), + user_id: *user_id, + name: name.clone(), }) } - RoomActorCommand::SetPersistentEmpty { room_id, persistent_empty, .. } => { + RoomActorCommand::SetPersistentEmpty { + room_id, + persistent_empty, + .. + } => { let as_ = ctx.expect_actor_state(); // PMP46 Blocker 2: 权威状态变更前递增序号(audit §7.5)。 let _seq = bump_room_seq(lc, &mut as_.state).await; @@ -1904,7 +2357,9 @@ impl RoomCommandHandler { }) } - RoomActorCommand::BindAndSnapshot { room_id, user_id, .. } => { + RoomActorCommand::BindAndSnapshot { + room_id, user_id, .. + } => { let as_ = ctx.expect_actor_state(); // PMP45 P0-F: 原子快照——state/lock/cycle/host/chart/live/ready // 全部从 actor 权威状态在同一排序点派生,绝不跨多次独立读取混用 @@ -1923,23 +2378,44 @@ impl RoomCommandHandler { let server = lc.server_state(); let users_guard = server.users.read().await; for id in &as_.state.members.users { - let name = as_.display_names.get(id).cloned() + let name = as_ + .display_names + .get(id) + .cloned() .or_else(|| users_guard.get(id).map(|u| u.name.clone())) .unwrap_or_else(|| id.to_string()); - users.insert(*id, UserInfo { id: *id, name, monitor: false }); + users.insert( + *id, + UserInfo { + id: *id, + name, + monitor: false, + }, + ); } for id in &as_.state.members.monitors { - let name = as_.display_names.get(id).cloned() + let name = as_ + .display_names + .get(id) + .cloned() .or_else(|| users_guard.get(id).map(|u| u.name.clone())) .unwrap_or_else(|| id.to_string()); - users.insert(*id, UserInfo { id: *id, name, monitor: true }); + users.insert( + *id, + UserInfo { + id: *id, + name, + monitor: true, + }, + ); } } // 并入连接注册表(覆盖创建者等未走 AddUser 的成员)。 // 用 `entry().or_insert_with` 避免 contains_key+insert 双查(map_entry)。 for u in lc.users().await { users.entry(u.id).or_insert_with(|| { - let name = as_.display_names + let name = as_ + .display_names .get(&u.id) .cloned() .unwrap_or_else(|| u.name.clone()); @@ -1952,7 +2428,8 @@ impl RoomCommandHandler { } for u in lc.monitors().await { users.entry(u.id).or_insert_with(|| { - let name = as_.display_names + let name = as_ + .display_names .get(&u.id) .cloned() .unwrap_or_else(|| u.name.clone()); @@ -2009,16 +2486,17 @@ impl RoomCommandHandler { } // 审计 P0: Telemetry fire-and-forget variants are handled by // execute_telemetry on the fast path; they should not arrive here. - RoomActorCommand::TelemetryTouches { .. } | RoomActorCommand::TelemetryJudges { .. } => { - ok(RoomCommandPayload::TouchesCached { - room_id: String::new(), user_id: 0, - }) - } + RoomActorCommand::TelemetryTouches { .. } + | RoomActorCommand::TelemetryJudges { .. } => ok(RoomCommandPayload::TouchesCached { + room_id: String::new(), + user_id: 0, + }), // AddTouches/AddJudges are no-op here — telemetry is now handled // by the execute_telemetry fast path in the actor. RoomActorCommand::AddTouches { .. } | RoomActorCommand::AddJudges { .. } => { ok(RoomCommandPayload::TouchesCached { - room_id: String::new(), user_id: 0, + room_id: String::new(), + user_id: 0, }) } // PMP45 P0-O: 内部响应后检查(RemoveUser 触发,fire-and-forget)。 @@ -2068,14 +2546,19 @@ mod tests { // Generation moved (reconnect) => stale. assert!(origin_token_stale(&Some((sid, 3)), 4, Some(sid))); // Bound session id no longer matches => stale. - assert!(origin_token_stale(&Some((sid, 3)), 3, Some(uuid::Uuid::new_v4()))); + assert!(origin_token_stale( + &Some((sid, 3)), + 3, + Some(uuid::Uuid::new_v4()) + )); // No bound session => stale. assert!(origin_token_stale(&Some((sid, 3)), 3, None)); } #[test] fn deadline_refused_returns_matching_error() { - let result = deadline_refused(std::time::Instant::now() - std::time::Duration::from_secs(1)); + let result = + deadline_refused(std::time::Instant::now() - std::time::Duration::from_secs(1)); assert!(!result.is_ok(), "deadline refusal must be an error result"); assert_eq!( result.error_message().as_deref(), diff --git a/phira-mp-plus-server/src/room_actor/mailbox.rs b/phira-mp-plus-server/src/room_actor/mailbox.rs index e861001f8..ccf321c78 100644 --- a/phira-mp-plus-server/src/room_actor/mailbox.rs +++ b/phira-mp-plus-server/src/room_actor/mailbox.rs @@ -1,9 +1,6 @@ //! Mailbox-backed routing for room commands. -use super::{ - actor::RoomActor, command::RoomActorCommand, - RoomCommandGateway, RoomCommandResult, -}; +use super::{actor::RoomActor, command::RoomActorCommand, RoomCommandGateway, RoomCommandResult}; use crate::room::InternalRoomState; use crate::server::PlusServerState; use phira_mp_common::ServerCommand; @@ -92,7 +89,8 @@ impl RoomCommandGateway { // 审计 P0: 独立 telemetry channel,容量 2× control 以应对高频 Touch/Judge。 let telemetry_cap = cap * 2; let (telemetry_tx, telemetry_rx) = mpsc::channel::(telemetry_cap); - let (monitor_tx, monitor_rx) = broadcast::channel::(MONITOR_TELEMETRY_CAPACITY); + let (monitor_tx, monitor_rx) = + broadcast::channel::(MONITOR_TELEMETRY_CAPACITY); mailboxes.insert( room_id.to_string(), super::RoomMailboxEntry { @@ -128,7 +126,7 @@ impl RoomCommandGateway { crate::supervisor_actor::spawn_named( format!("room-mailbox-{worker_room_id}"), async move { - let mut actor = RoomActor::new(room, state.clone()); + let mut actor = RoomActor::new(room, state.clone()).await; gateway.store_snapshot_if_current( &worker_room_id, worker_room_uuid.clone(), @@ -405,34 +403,42 @@ async fn run_lifecycle_maintenance( // Collect current member IDs from actor state (authoritative). let current_ids: std::collections::HashSet = { let members = &as_.state.members; - members.users.iter().chain(members.monitors.iter()).copied().collect() + members + .users + .iter() + .chain(members.monitors.iter()) + .copied() + .collect() }; as_.player_data.retain(|&k, _| current_ids.contains(&k)); as_.display_names.retain(|&k, _| current_ids.contains(&k)); // 准备倒计时:检查是否超时(赛事模式由 PPB 编排开赛,禁用自动开赛) if !as_.state.control.tournament { - if let InternalRoomState::WaitForReady { .. } = &as_.state.lifecycle { - if let Some(started_at) = as_.state.ready_countdown_started_at { - let elapsed = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as i64) - .unwrap_or(0) - started_at; - let timeout_ms = (actor.state.config.ready_countdown_secs.max(10) * 1000) as i64; - if elapsed >= timeout_ms { - // 超时 —— 强制开赛 - let room = Arc::clone(&actor.room); - let lc = crate::room_actor::lifecycle::DefaultRoomLifecycle::new( - room, - Arc::clone(&actor.state), - ); - crate::room_actor::handler::force_start_playing( - &lc, &mut as_.state, - std::time::Instant::now() + RoomCommandGateway::COMMAND_TIMEOUT, - ).await; + if let InternalRoomState::WaitForReady { .. } = &as_.state.lifecycle { + if let Some(started_at) = as_.state.ready_countdown_started_at { + let elapsed = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) + - started_at; + let timeout_ms = (actor.state.config.ready_countdown_secs.max(10) * 1000) as i64; + if elapsed >= timeout_ms { + // 超时 —— 强制开赛 + let room = Arc::clone(&actor.room); + let lc = crate::room_actor::lifecycle::DefaultRoomLifecycle::new( + room, + Arc::clone(&actor.state), + ); + crate::room_actor::handler::force_start_playing( + &lc, + &mut as_.state, + std::time::Instant::now() + RoomCommandGateway::COMMAND_TIMEOUT, + ) + .await; + } } } - } } // end !tournament(赛事模式禁用准备倒计时自动开赛) // 对局超时:检查 Playing 状态下是否超过截止时间 @@ -449,9 +455,7 @@ async fn run_lifecycle_maintenance( room, Arc::clone(&actor.state), ); - crate::room_actor::handler::force_end_playing( - &lc, &mut as_.state, - ).await; + crate::room_actor::handler::force_end_playing(&lc, &mut as_.state).await; } } } @@ -468,7 +472,8 @@ async fn run_lifecycle_maintenance( room, Arc::clone(&actor.state), ); - let due: Vec = as_.state + let due: Vec = as_ + .state .progress_subscribers .iter() .filter(|(_, &last)| now - last >= 30_000) @@ -558,7 +563,10 @@ mod tests { stripped: phira_mp_common::StrippedRoomState::SelectingChart, round_id: None, ready_set: None, - members: super::super::actor::RoomMembers { users: Vec::new(), monitors: Vec::new() }, + members: super::super::actor::RoomMembers { + users: Vec::new(), + monitors: Vec::new(), + }, results_keys: Vec::new(), aborted_users: Vec::new(), playing_users: Vec::new(), diff --git a/phira-mp-plus-server/src/room_actor/ops/membership.rs b/phira-mp-plus-server/src/room_actor/ops/membership.rs index 3b5db03cc..4d8f598b2 100644 --- a/phira-mp-plus-server/src/room_actor/ops/membership.rs +++ b/phira-mp-plus-server/src/room_actor/ops/membership.rs @@ -33,7 +33,6 @@ impl RoomCommandGateway { .into_untyped() } - /// Close and remove a room. pub async fn close_room( &self, @@ -57,7 +56,6 @@ impl RoomCommandGateway { .into_untyped() } - // ── AddUser ──────────────────────────────────────────────────────────── pub async fn add_user( @@ -66,6 +64,7 @@ impl RoomCommandGateway { room_id: &str, user_id: i32, user_name: &str, + connection: Option>, monitor: bool, deadline: Instant, origin: RoomOrigin, @@ -78,17 +77,23 @@ impl RoomCommandGateway { room_id: rid.clone(), user_id, user_name: uname, + connection, monitor, deadline, origin, reply, }) .await; - self.finish_command(state, RoomCommandKind::AddUser.action(), room_id, started, result) - .into_untyped() + self.finish_command( + state, + RoomCommandKind::AddUser.action(), + room_id, + started, + result, + ) + .into_untyped() } - // ── RemoveUser ────────────────────────────────────────────────────────── pub async fn remove_user( @@ -99,7 +104,8 @@ impl RoomCommandGateway { deadline: Option, origin: RoomOrigin, ) -> Result { - let deadline = deadline.unwrap_or_else(|| Instant::now() + std::time::Duration::from_secs(30)); + let deadline = + deadline.unwrap_or_else(|| Instant::now() + std::time::Duration::from_secs(30)); let started = Instant::now(); let rid = room_id.to_string(); let result = self @@ -111,11 +117,16 @@ impl RoomCommandGateway { reply, }) .await; - self.finish_command(state, RoomCommandKind::RemoveUser.action(), room_id, started, result) - .into_untyped() + self.finish_command( + state, + RoomCommandKind::RemoveUser.action(), + room_id, + started, + result, + ) + .into_untyped() } - // ── SetLive ───────────────────────────────────────────────────────────── /// Set the live flag for the room. @@ -134,11 +145,16 @@ impl RoomCommandGateway { reply, }) .await; - self.finish_command(state, RoomCommandKind::SetLive.action(), room_id, started, result) - .into_untyped() + self.finish_command( + state, + RoomCommandKind::SetLive.action(), + room_id, + started, + result, + ) + .into_untyped() } - // ── SetDegraded ───────────────────────────────────────────────────────── /// PMP45 P0-K: 设置房间 degraded 标志。Join 补偿失败后置 true 以阻塞后续 @@ -158,8 +174,14 @@ impl RoomCommandGateway { reply, }) .await; - self.finish_command(state, RoomCommandKind::SetDegraded.action(), room_id, started, result) - .into_untyped() + self.finish_command( + state, + RoomCommandKind::SetDegraded.action(), + room_id, + started, + result, + ) + .into_untyped() } /// 赛事模式房间(房间级配置)。开启后禁用 PMP 默认交互行为,交 PPB 编排。 @@ -178,8 +200,14 @@ impl RoomCommandGateway { reply, }) .await; - self.finish_command(state, RoomCommandKind::SetTournament.action(), room_id, started, result) - .into_untyped() + self.finish_command( + state, + RoomCommandKind::SetTournament.action(), + room_id, + started, + result, + ) + .into_untyped() } // ── CheckAllReady (fire-and-forget) ──────────────────────────────────── @@ -207,5 +235,4 @@ impl RoomCommandGateway { let _ = tx.send(cmd).await; let _ = state; // 与其它 gateway 方法保持签名一致(room 校验用 state) } - } diff --git a/phira-mp-plus-server/src/server/disconnect.rs b/phira-mp-plus-server/src/server/disconnect.rs index 0f27712e6..bdc2112e8 100644 --- a/phira-mp-plus-server/src/server/disconnect.rs +++ b/phira-mp-plus-server/src/server/disconnect.rs @@ -1,6 +1,6 @@ //! User disconnection and kick methods. -use phira_mp_common::{RoomEvent, ServerCommand}; +use phira_mp_common::ServerCommand; use serde_json::Value; use std::sync::Arc; use tracing::{info, warn}; @@ -60,26 +60,14 @@ pub(crate) async fn run_admin_kick_user( .ok_or("user not found")?; if let Some(room) = user.room.read().await.as_ref().map(Arc::clone) { - let room_id = room.id.to_string(); - let room_key = room.id.clone(); - let was_monitor = user.monitor.load(std::sync::atomic::Ordering::SeqCst); - if room.on_user_leave(&user).await { - state.rooms.write().await.remove(&room_key); - } - if !was_monitor { - state - .publish_room_event(RoomEvent::LeaveRoom { - room: room_key, - user: target_id, - }) - .await; - } + // All membership changes, including administrative kicks, go through + // the room actor. This keeps actor membership and connection + // references from diverging and lets the actor own LeaveRoom events. state - .dispatch_plugin_event(crate::plugin::PluginEvent::RoomLeave { - user_id: target_id, - room_id, - }) - .await; + .room_commands + .remove_user(state, &room.id.to_string(), target_id, None, None) + .await + .map_err(|error| format!("kick room removal failed: {error}"))?; } let target_session = { @@ -115,13 +103,8 @@ pub(crate) async fn run_admin_kick_user( if let Some(session) = target_session { let mut args = fluent::FluentArgs::new(); args.set("reason", reason); - let content = crate::l10n::translate_system( - &session.user.lang, "kicked-by-admin", &args, - ); - let message = ServerCommand::Message(phira_mp_common::Message::Chat { - user: 0, - content, - }); + let content = crate::l10n::translate_system(&session.user.lang, "kicked-by-admin", &args); + let message = ServerCommand::Message(phira_mp_common::Message::Chat { user: 0, content }); let _ = tokio::time::timeout( std::time::Duration::from_secs(2), session.stream.send_and_flush(message), diff --git a/phira-mp-plus-server/src/server/rooms.rs b/phira-mp-plus-server/src/server/rooms.rs index 049b90f48..c6366fc58 100644 --- a/phira-mp-plus-server/src/server/rooms.rs +++ b/phira-mp-plus-server/src/server/rooms.rs @@ -328,10 +328,7 @@ impl PlusServerState { } /// Refresh room display metadata by room ID (background spawn). - async fn refresh_room_display_metadata_background_by_id( - self: &Arc, - room_id: &str, - ) { + async fn refresh_room_display_metadata_background_by_id(self: &Arc, room_id: &str) { let rooms = self.rooms.read().await; let rid: RoomId = match room_id.to_string().try_into() { Ok(id) => id, @@ -455,6 +452,7 @@ impl PlusServerState { &rid.to_string(), target_id, &user.name, + Some(Arc::clone(&user)), monitor, admin_deadline, None, @@ -475,6 +473,7 @@ impl PlusServerState { &old_room_val.id.to_string(), target_id, &user.name, + Some(Arc::clone(&user)), was_monitor, admin_deadline, None, @@ -569,7 +568,8 @@ impl PlusServerState { } // ChangeHost 是状态告知(非响应),随 JoinRoom(Ok) flush 后经 FIFO 到达; // 作为告知传 None(cutover 不剔除)。 - user.try_send(ServerCommand::ChangeHost(is_host), None).await; + user.try_send(ServerCommand::ChangeHost(is_host), None) + .await; // Step 8: Record history. let now = std::time::SystemTime::now() @@ -601,13 +601,13 @@ impl PlusServerState { // Step 10: System message. { let uname = user.name.clone(); - target_room.send_system_msg( - &|lang| { + target_room + .send_system_msg(&|lang| { let mut a = fluent::FluentArgs::new(); a.set("name", &uname); crate::l10n::translate_system(lang, "user-moved-to-room", &a) - }, - ).await; + }) + .await; } Ok(serde_json::json!({ @@ -622,9 +622,7 @@ impl PlusServerState { // ── Room hidden flag ───────────────────────────────────────────── pub async fn set_room_hidden(&self, room_id: &str, hidden: bool) -> Result { - self.room_commands - .set_hidden(self, room_id, hidden) - .await + self.room_commands.set_hidden(self, room_id, hidden).await } // ── Phira API endpoint ─────────────────────────────────────────── diff --git a/phira-mp-plus-server/src/session.rs b/phira-mp-plus-server/src/session.rs index 2a40bb672..5f9315784 100644 --- a/phira-mp-plus-server/src/session.rs +++ b/phira-mp-plus-server/src/session.rs @@ -255,7 +255,8 @@ async fn rollback_failed_auth( } } (Some((session_id, generation)), None) => { - user.clear_session_if_matches(*session_id, *generation).await; + user.clear_session_if_matches(*session_id, *generation) + .await; } (None, _) => {} } @@ -302,13 +303,15 @@ async fn rollback_failed_auth( let now = crate::db::now_ms(); if let Err(e) = server .persistence_worker - .enqueue(crate::persistence::message::PersistenceEvent::UserDisconnect { - user_id, - user_name, - server_instance_id: crate::server_instance::current().to_string(), - session_id: session_id.clone(), - occurred_at: now, - }) + .enqueue( + crate::persistence::message::PersistenceEvent::UserDisconnect { + user_id, + user_name, + server_instance_id: crate::server_instance::current().to_string(), + session_id: session_id.clone(), + occurred_at: now, + }, + ) .await { warn!(user = user_id, kind = %e.kind(), "UserDisconnect enqueue failed during auth rollback"); @@ -640,18 +643,17 @@ async fn run_outbound_task( // 拥塞)不得无限拖住 outbound task,否则后续 Critical 响应 // (JoinRoom(Ok)/CreateRoom(Ok))被堵在队列后(audit §31)。 // 超时视为该客户端跟不上,关闭 Session 走 lost-connection 路径。 - match tokio::time::timeout( - OUTBOUND_PACKET_SEND_TIMEOUT, - send_tx.send(cmd), - ) - .await + match tokio::time::timeout(OUTBOUND_PACKET_SEND_TIMEOUT, send_tx.send(cmd)) + .await { Ok(Ok(())) => {} Ok(Err(err)) => { tracing::warn!(?err, "outbound task send failed (session teardown?)"); } Err(_) => { - tracing::warn!("outbound Packet send timed out; disconnecting slow client"); + tracing::warn!( + "outbound Packet send timed out; disconnecting slow client" + ); if let Some(session) = session_weak.get().and_then(Weak::upgrade) { session.stream.close(); let _ = session.user.server.lost_con_tx.try_send(session.id); @@ -886,14 +888,12 @@ impl SessionOutboundGate { } } } - pending - .events - .push_back(GateEntry { - cmd, - seq, - class, - room_seq: entry_room_seq, - }); + pending.events.push_back(GateEntry { + cmd, + seq, + class, + room_seq: entry_room_seq, + }); pending.bytes += size; // PMP44 P1 §33: 每次入队/丢弃后更新认证屏障 gauge(事件数 / 字节粗估), // 提供预认证缓冲的实时观测视图。 @@ -1171,8 +1171,7 @@ impl Session { Arc::new(tokio::sync::OnceCell::>>::new()); // PMP45 P0-M: 延迟绑定的 `Weak`——Session 在下方构造完成后 // 写入。出站任务只经它关闭 origin Session,不持有强引用(避免引用环)。 - let outbound_session_weak = - Arc::new(std::sync::OnceLock::>::new()); + let outbound_session_weak = Arc::new(std::sync::OnceLock::>::new()); let outbound_task_handle = tokio::spawn(run_outbound_task( outbound_rx, Arc::clone(&outbound_sender_ready), @@ -3012,6 +3011,13 @@ impl Session { }), ) .await?; + if stream.version() != phira_mp_common::PROTOCOL_VERSION { + return Err(anyhow!( + "unsupported Phira-mp protocol version {}; expected {}", + stream.version(), + phira_mp_common::PROTOCOL_VERSION + )); + } let monitor_task_handle = tokio::spawn({ let server_clone = Arc::clone(&server_clone); async move { @@ -3396,7 +3402,10 @@ mod tests { // Pong 是 Telemetry → 发送;ChangeHost(false) 是快照点后增量(room_seq 6)→ 发送。 assert_eq!(sent.len(), 4, "only snapshot-covered events are cut over"); assert!(matches!(sent[0], ServerCommand::Chat(Ok(())))); - assert!(matches!(sent[1], ServerCommand::Message(Message::GameStart { .. }))); + assert!(matches!( + sent[1], + ServerCommand::Message(Message::GameStart { .. }) + )); assert!(matches!(sent[2], ServerCommand::Pong)); assert!(matches!(sent[3], ServerCommand::ChangeHost(false))); } @@ -3445,7 +3454,10 @@ mod tests { // seq 3(room_seq 3 <= 3,快照已包含);Chat(None)与 ChangeHost(false) // (room_seq 4 > 3,快照点后增量)必须发送。 assert_eq!(sent.len(), 2, "only snapshot-covered events are cut over"); - assert!(matches!(sent[0], ServerCommand::Message(Message::Chat { .. }))); + assert!(matches!( + sent[0], + ServerCommand::Message(Message::Chat { .. }) + )); assert!(matches!(sent[1], ServerCommand::ChangeHost(false))); } @@ -3484,14 +3496,22 @@ mod tests { ); assert_eq!( classify_command(&ServerCommand::Message(Message::Played { - user: 1, score: 0, accuracy: 0.0, full_combo: false, - perfect: 0, good: 0, bad: 0, miss: 0, max_combo: 0, + user: 1, + score: 0, + accuracy: 0.0, + full_combo: false, })), GateEventClass::NonSnapshot ); - assert_eq!(classify_command(&ServerCommand::Chat(Ok(()))), GateEventClass::NonSnapshot); + assert_eq!( + classify_command(&ServerCommand::Chat(Ok(()))), + GateEventClass::NonSnapshot + ); // 遥测。 - assert_eq!(classify_command(&ServerCommand::Pong), GateEventClass::Telemetry); + assert_eq!( + classify_command(&ServerCommand::Pong), + GateEventClass::Telemetry + ); assert_eq!( classify_command(&ServerCommand::Touches { player: 1, @@ -3509,19 +3529,24 @@ mod tests { // 放不下。遥测不会触发 overflow(coalesce),因此用 Chat 验证。 let gate = SessionOutboundGate::new(8, 4, Duration::from_millis(8000)); let sink = TestSink::default(); - let dropped_before = ProtocolTrace::get().gate_control_overflow.load(Ordering::Relaxed); + let dropped_before = ProtocolTrace::get() + .gate_control_overflow + .load(Ordering::Relaxed); // 缓冲为空时入队超预算的语义事件:单事件即超预算 → overflowed。 - assert!(gate - .try_send( + assert!( + gate.try_send( &sink, ServerCommand::Message(Message::Chat { user: 1, content: "x".repeat(100), }), ) - .await); - let dropped_after = ProtocolTrace::get().gate_control_overflow.load(Ordering::Relaxed); + .await + ); + let dropped_after = ProtocolTrace::get() + .gate_control_overflow + .load(Ordering::Relaxed); assert!( dropped_after > dropped_before, "gate_control_overflow must increment" diff --git a/phira-mp-plus-server/src/session_lifecycle.rs b/phira-mp-plus-server/src/session_lifecycle.rs index 2dfc4ef0f..cd9788ffb 100644 --- a/phira-mp-plus-server/src/session_lifecycle.rs +++ b/phira-mp-plus-server/src/session_lifecycle.rs @@ -3,7 +3,7 @@ use crate::server::PlusServerState; use crate::session::Session; use anyhow::{anyhow, Result}; use fluent::FluentArgs; -use phira_mp_common::{RoomEvent, ServerCommand, UserInfo}; +use phira_mp_common::{ServerCommand, UserInfo}; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::{Arc, Weak}; use std::time::Duration; @@ -100,10 +100,7 @@ impl User { pub async fn send_system_msg(&self, key: &str, args: &FluentArgs<'_>) { let content = crate::l10n::translate_system(&self.lang, key, args); self.try_send( - ServerCommand::Message(phira_mp_common::Message::Chat { - user: 0, - content, - }), + ServerCommand::Message(phira_mp_common::Message::Chat { user: 0, content }), // 系统消息非状态事件,cutover 不适用。 None, ) @@ -335,9 +332,11 @@ impl User { // Monitor sessions are transient and never enter the player lifecycle. if self.id < 0 { if let Some(room) = room { - if room.on_user_leave(&self).await { - self.server.rooms.write().await.remove(&room.id); - } + let _ = self + .server + .room_commands + .remove_user(&self.server, &room.id.to_string(), self.id, None, None) + .await; } let mut users = self.server.users.write().await; if users @@ -359,7 +358,9 @@ impl User { } if let Some(room) = room.as_ref() { - let is_playing = room.server.upgrade() + let is_playing = room + .server + .upgrade() .and_then(|s| s.room_snapshot(&room.id.to_string())) .map(|snap| matches!(snap.stripped, phira_mp_common::StrippedRoomState::Playing)) .unwrap_or(false); @@ -368,8 +369,7 @@ impl User { if grace_secs > 0 { warn!( user = self.id, - grace_secs, - "lost connection while playing; reconnect grace started" + grace_secs, "lost connection while playing; reconnect grace started" ); // Playing reconnect grace: keep room membership, use playing-specific timer. let dangle_mark = Arc::new(()); @@ -385,16 +385,26 @@ impl User { format!("playing-grace-{}", self.id), async move { time::sleep(Duration::from_secs(grace_secs)).await; - let Some(self_) = weak_self.upgrade() else { return }; - let registration_guard = self_.server.user_registration_gate.lock().await; + let Some(self_) = weak_self.upgrade() else { + return; + }; + let registration_guard = + self_.server.user_registration_gate.lock().await; let expired = { let mut current = self_.dangle_mark.lock().await; - if current.as_ref().is_some_and(|mark| Arc::ptr_eq(mark, &dangle_mark)) { + if current + .as_ref() + .is_some_and(|mark| Arc::ptr_eq(mark, &dangle_mark)) + { current.take(); true - } else { false } + } else { + false + } }; - if !expired { return; } + if !expired { + return; + } // Grace expired — abort game, remove from room. let room = self_.room.read().await.as_ref().map(Arc::clone); @@ -402,20 +412,34 @@ impl User { let room_id = room.id.clone(); // Abort the player's game if room still exists if let Some(server) = room.server.upgrade() { - let _ = server.room_commands.abort_round( - &server, &room_id.to_string(), self_.id, None, None, - ).await; + let _ = server + .room_commands + .abort_round( + &server, + &room_id.to_string(), + self_.id, + None, + None, + ) + .await; } - let _ = self_.server.room_commands.remove_user( - &self_.server, - &room_id.to_string(), - self_.id, - None, - None, - ).await; + let _ = self_ + .server + .room_commands + .remove_user( + &self_.server, + &room_id.to_string(), + self_.id, + None, + None, + ) + .await; } let mut users = self_.server.users.write().await; - if users.get(&self_.id).is_some_and(|current| Arc::ptr_eq(current, &self_)) { + if users + .get(&self_.id) + .is_some_and(|current| Arc::ptr_eq(current, &self_)) + { users.remove(&self_.id); } drop(users); @@ -425,23 +449,33 @@ impl User { // entry — re-reading the weak ref could return a NEW // session's id after a reconnect (P0-C). let sid = disconnected_session_id.to_string(); - let _ = self_.server.persistence_worker.enqueue( - crate::persistence::message::PersistenceEvent::UserDisconnect { - user_id: self_.id, - user_name: self_.name.clone(), - server_instance_id: crate::server_instance::current().to_string(), - session_id: sid.clone(), - occurred_at: crate::db::now_ms(), - }, - ).await; - let _ = self_.server.persistence_worker.enqueue( - crate::persistence::message::PersistenceEvent::UserOffline { - user_id: self_.id, - server_instance_id: crate::server_instance::current().to_string(), - session_id: sid, - occurred_at: crate::db::now_ms(), - }, - ).await; + let _ = self_ + .server + .persistence_worker + .enqueue( + crate::persistence::message::PersistenceEvent::UserDisconnect { + user_id: self_.id, + user_name: self_.name.clone(), + server_instance_id: crate::server_instance::current() + .to_string(), + session_id: sid.clone(), + occurred_at: crate::db::now_ms(), + }, + ) + .await; + let _ = self_ + .server + .persistence_worker + .enqueue( + crate::persistence::message::PersistenceEvent::UserOffline { + user_id: self_.id, + server_instance_id: crate::server_instance::current() + .to_string(), + session_id: sid, + occurred_at: crate::db::now_ms(), + }, + ) + .await; }, ); return; @@ -451,10 +485,11 @@ impl User { "lost connection while playing; removing immediately (grace disabled)" ); let room_id = room.id.clone(); - let was_monitor = self.monitor.load(Ordering::Relaxed); - if room.on_user_leave(&self).await { - self.server.rooms.write().await.remove(&room_id); - } + let _ = self + .server + .room_commands + .remove_user(&self.server, &room_id.to_string(), self.id, None, None) + .await; let mut users = self.server.users.write().await; if users .get(&self.id) @@ -466,14 +501,6 @@ impl User { self.server.note_user_offline().await; drop(registration_guard); - if !was_monitor { - self.server - .publish_room_event(RoomEvent::LeaveRoom { - room: room_id, - user: self.id, - }) - .await; - } self.server .publish_user_disconnected(self.id, self.name.clone()) .await; @@ -564,23 +591,13 @@ impl User { } let room = self_.room.read().await.as_ref().map(Arc::clone); - let mut room_leave_event = None; if let Some(room) = room { let room_id = room.id.clone(); - let was_monitor = self_.monitor.load(Ordering::Relaxed); - let _ = self_.server.room_commands.remove_user( - &self_.server, - &room_id.to_string(), - self_.id, - None, - None, - ).await; - if !was_monitor { - room_leave_event = Some(RoomEvent::LeaveRoom { - room: room_id, - user: self_.id, - }); - } + let _ = self_ + .server + .room_commands + .remove_user(&self_.server, &room_id.to_string(), self_.id, None, None) + .await; } let mut users = self_.server.users.write().await; @@ -594,9 +611,6 @@ impl User { self_.server.note_user_offline().await; drop(registration_guard); - if let Some(event) = room_leave_event { - self_.server.publish_room_event(event).await; - } // Use the session id captured at disconnect entry (fixed), not a // re-read of the (possibly-dead) weak ref. if let Err(e) = self_ diff --git a/phira-mp-plus-server/src/session_room.rs b/phira-mp-plus-server/src/session_room.rs index 05927c51f..547b14c33 100644 --- a/phira-mp-plus-server/src/session_room.rs +++ b/phira-mp-plus-server/src/session_room.rs @@ -28,7 +28,9 @@ fn tr(e: String) -> String { "no chart selected" => Some("start-no-chart-selected"), "room is full" => Some("join-room-full"), "administrative start is already in progress" => Some("admin-start-in-progress"), - "room is not selecting a chart" | "cannot set chart outside SelectChart state" => Some("invalid-state"), + "room is not selecting a chart" | "cannot set chart outside SelectChart state" => { + Some("invalid-state") + } "not in WaitForReady state" => Some("invalid-state"), "not in Playing state" => Some("invalid-state"), "invalid room id" => Some("invalid-room-id"), @@ -43,9 +45,7 @@ fn tr(e: String) -> String { None => e, } } -use phira_mp_common::{ - JoinRoomResponse, Message, RoomEvent, RoomId, ServerCommand, -}; +use phira_mp_common::{JoinRoomResponse, Message, RoomEvent, RoomId, ServerCommand}; use std::{ collections::HashMap, sync::{atomic::Ordering, Arc}, @@ -95,7 +95,10 @@ async fn current_room_in_select_chart(user: &Arc) -> Result - phira_mp_common::RoomState::SelectChart(snap.chart), - phira_mp_common::StrippedRoomState::WaitingForReady => - phira_mp_common::RoomState::WaitingForReady, - phira_mp_common::StrippedRoomState::Playing => - phira_mp_common::RoomState::Playing, + phira_mp_common::StrippedRoomState::SelectingChart => { + phira_mp_common::RoomState::SelectChart(snap.chart) + } + phira_mp_common::StrippedRoomState::WaitingForReady => { + phira_mp_common::RoomState::WaitingForReady + } + phira_mp_common::StrippedRoomState::Playing => phira_mp_common::RoomState::Playing, } } else { phira_mp_common::RoomState::SelectChart(None) }; - let users = room.users().await.into_iter() + let users = room + .users() + .await + .into_iter() .chain(room.monitors().await) .map(|u| (u.id, u.to_info())) .collect(); @@ -163,8 +171,16 @@ pub(crate) async fn build_room_data(room: &crate::room::Room) -> phira_mp_common }; let users: Vec = room.users().await.into_iter().map(|u| u.id).collect(); let chart = snap.as_ref().and_then(|s| s.chart); - let state = snap.as_ref().map_or(phira_mp_common::StrippedRoomState::SelectingChart, |s| s.stripped); - let rounds = room.play_history.all().await.iter() + let state = snap + .as_ref() + .map_or(phira_mp_common::StrippedRoomState::SelectingChart, |s| { + s.stripped + }); + let rounds = room + .play_history + .all() + .await + .iter() .map(|r| crate::room::protocol_round(r)) .collect(); phira_mp_common::RoomData { @@ -271,7 +287,10 @@ pub async fn create_room( } if let Some(limit) = user.server.config.max_rooms { if map_guard.len() >= limit { - bail!("{}", tl!("server-room-limit-reached", limit => limit.to_string())); + bail!( + "{}", + tl!("server-room-limit-reached", limit => limit.to_string()) + ); } } if !user @@ -350,11 +369,10 @@ pub async fn create_room( room_id: room_id.to_string(), }) .await; - server - .publish_runtime_event(crate::event_bus::MpEvent::RoomCreated { - room_id: room_id.clone(), - room_uuid, - }); + server.publish_runtime_event(crate::event_bus::MpEvent::RoomCreated { + room_id: room_id.clone(), + room_uuid, + }); // Pre-create the mailbox so the first join doesn't pay creation latency. let _ = server .room_commands @@ -434,8 +452,7 @@ impl Drop for JoinCompensationGuard { // PMP45 P0-K: 补偿使用「内部清理 deadline」(200ms)而非命令原始 // deadline——补偿必须在 handler 内完整跑完,绝不能被响应预算或外层 // run_or_deadline 超时取消(取消会留下 Ghost member,audit §16.2)。 - let cleanup_deadline = - Instant::now() + std::time::Duration::from_millis(200); + let cleanup_deadline = Instant::now() + std::time::Duration::from_millis(200); let compensation = server .room_commands .remove_user( @@ -544,7 +561,8 @@ pub async fn join_room( check_deadline!(); // Read room lifecycle from actor snapshot for game state check. let stripped = if let Some(server) = room.server.upgrade() { - server.room_snapshot(&room.id.to_string()) + server + .room_snapshot(&room.id.to_string()) .map(|s| s.stripped) } else { None @@ -638,6 +656,7 @@ pub async fn join_room( &id.to_string(), user.id, &user.name, + Some(Arc::clone(&user)), monitor, deadline, origin.to_room_origin(), @@ -651,60 +670,8 @@ pub async fn join_room( // 超时 → close_uncertain + bail(P0-D uncertain-after-commit),绝不普通 // bail——那会让用户已提交而客户端被误导。 - // Also add to Room connection mapping (immediate, direct). - if !room.add_user(Arc::downgrade(&user), monitor).await { - // PMP44 P0-L: Actor AddUser 已提交成员但连接注册表拒绝该用户——在官方 - // Join 广播(OnJoinRoom/Message::JoinRoom)发出前执行补偿,撤销 Actor - // 成员,避免 Ghost member(audit §16:actor 有成员但 user.room 为空、 - // 注册表为空)。 - warn!( - user = user.id, - room = %id, - "room.add_user failed after actor AddUser; compensating actor remove_user" - ); - // PMP45 P0-J/P0-14(audit §16.2/§18):补偿使用「内部清理 deadline」 - // (`Instant::now() + 200ms`),而不是命令的原始 deadline——补偿必须 - // 在 handler 内完整跑完,绝不能被响应预算或外层 `run_or_deadline` 超时 - // 取消(取消会留下 Ghost member)。200ms 远小于 response budget - //(默认 1000ms),处于安全范围内。 - let cleanup_deadline = - Instant::now() + std::time::Duration::from_millis(200); - let compensation = user - .server - .room_commands - .remove_user( - &user.server, - &id.to_string(), - user.id, - Some(cleanup_deadline), - origin.to_room_origin(), - ) - .await; - if compensation.is_err() { - // PMP45 P0-K: 补偿也失败——Ghost member 遗留,房间进入 degraded, - // 不再接受新的 Join,直到操作员 / 未来 reconcile 清空。结果不确定 - //(actor 成员可能仍在)——关闭 origin 传输,走 lost-connection 路径, - // 客户端 reconnect Authenticate 恢复权威状态。 - warn!( - user = user.id, - room = %id, - "compensating remove_user also failed; marking room degraded and closing origin transport" - ); - let _ = user - .server - .room_commands - .set_degraded(&user.server, &id.to_string(), true) - .await; - join_guard.disarm(); - origin.close_uncertain().await; - bail!("failed to register user connection"); - } - // 补偿成功:结果确定(未提交成员),撤销 Drop 补偿,发送错误给客户端, - // 客户端可重试 Join。 - join_guard.disarm(); - bail!("failed to register user connection"); - } - // 连接映射成功——actor 成员与连接注册表齐备,撤销 Drop 补偿。 + // The actor command attached the connection and committed membership as a + // single serialized operation. join_guard.disarm(); info!( @@ -753,7 +720,8 @@ pub async fn join_room( // WaitingForReady 状态下不直接包含谱面 ID)。 let (room_state, deferred_wfr) = if late_join { let chart = if let Some(server) = room.server.upgrade() { - server.room_snapshot(&room.id.to_string()) + server + .room_snapshot(&room.id.to_string()) .and_then(|s| s.chart) } else { None @@ -761,7 +729,10 @@ pub async fn join_room( (phira_mp_common::RoomState::SelectChart(chart), false) } else { let client_state = build_client_room_state(&room, &user).await; - let is_waiting = matches!(client_state.state, phira_mp_common::RoomState::WaitingForReady); + let is_waiting = matches!( + client_state.state, + phira_mp_common::RoomState::WaitingForReady + ); (client_state.state, is_waiting) }; @@ -854,7 +825,11 @@ pub async fn join_room( { let history = room.chat_history.read().await; for msg in history.iter() { - if let Message::Chat { user: chat_user, content } = msg { + if let Message::Chat { + user: chat_user, + content, + } = msg + { let _ = origin .try_send(ServerCommand::Message(Message::Chat { user: *chat_user, @@ -914,19 +889,29 @@ pub async fn join_room( is_monitor: monitor, }) .await; - server - .publish_runtime_event(crate::event_bus::MpEvent::RoomJoined { - room_id: room_id.clone(), - user_id: uid, - }); + server.publish_runtime_event(crate::event_bus::MpEvent::RoomJoined { + room_id: room_id.clone(), + user_id: uid, + }); } server - .record_user_room_history(uid, room_id.to_string(), room_arc.uuid.to_string(), joined_at) + .record_user_room_history( + uid, + room_id.to_string(), + room_arc.uuid.to_string(), + joined_at, + ) .await; server.refresh_room_display_metadata_background(&room_arc); // Route SetLive(true) and set_display_name through mailbox — fire-and-forget. - let _ = server.room_commands.set_live(&server, &room_id.to_string(), true).await; - let _ = server.room_commands.set_display_name(&server, &room_id.to_string(), uid, &uname).await; + let _ = server + .room_commands + .set_live(&server, &room_id.to_string(), true) + .await; + let _ = server + .room_commands + .set_display_name(&server, &room_id.to_string(), uid, &uname) + .await; }); Ok(()) @@ -948,7 +933,8 @@ pub async fn leave_room( ); let was_monitor = user.monitor.load(Ordering::SeqCst); // Route through mailbox for actor_state.members update and Room cleanup. - let result = user.server + let result = user + .server .room_commands .remove_user( &user.server, @@ -958,7 +944,9 @@ pub async fn leave_room( origin.to_room_origin(), ) .await; - let room_dropped = result.as_ref().ok() + let room_dropped = result + .as_ref() + .ok() .and_then(|v| v.get("room_dropped")) .and_then(|v| v.as_bool()) .unwrap_or(false); @@ -1093,46 +1081,50 @@ pub async fn select_chart( // remaining absolute deadline — a slow/blocked API must never let a // SelectChart commit after the client already timed out. let fetch_budget = deadline.saturating_duration_since(Instant::now()); - let (chart_name, chart_meta): (String, Option<(String, String, String, Option, Option)>) = - match tokio::time::timeout( - fetch_budget, - user.server.phira_client.get_json::( - &endpoint, - None, - &format!("/chart/{id}"), - None, - crate::phira_client::PhiraRetryNoticeTarget::Silent, - None, - ), - ) - .await - { - Ok(Ok(chart)) => ( - chart.name, - Some(( - chart.charter, - chart.composer, - chart.level, - chart.rating, - chart.chart_updated, - )), - ), - Ok(Err(_)) => { - tracing::warn!("failed to fetch chart {id} from Phira API; using ID as name"); - (format!("#{id}"), None) - } - Err(_) => { - // Deadline exhausted before the API returned — the client has - // already timed out. Do not commit the chart. - bail!("select chart timed out fetching chart metadata"); - } - }; + let (chart_name, chart_meta): ( + String, + Option<(String, String, String, Option, Option)>, + ) = match tokio::time::timeout( + fetch_budget, + user.server.phira_client.get_json::( + &endpoint, + None, + &format!("/chart/{id}"), + None, + crate::phira_client::PhiraRetryNoticeTarget::Silent, + None, + ), + ) + .await + { + Ok(Ok(chart)) => ( + chart.name, + Some(( + chart.charter, + chart.composer, + chart.level, + chart.rating, + chart.chart_updated, + )), + ), + Ok(Err(_)) => { + tracing::warn!("failed to fetch chart {id} from Phira API; using ID as name"); + (format!("#{id}"), None) + } + Err(_) => { + // Deadline exhausted before the API returned — the client has + // already timed out. Do not commit the chart. + bail!("select chart timed out fetching chart metadata"); + } + }; debug!("chart name: {chart_name}"); // 异步解析谱面时长(RANGE 只下 zip 内正曲音频);经 mailbox 写入 // 房间级 chart_duration,供对局超时计算。每次选谱解析,结算时释放。 { - let file_url = user.server.phira_client + let file_url = user + .server + .phira_client .fetch_chart_by_id(&endpoint, id) .await .and_then(|c| c.file); @@ -1149,7 +1141,10 @@ pub async fn select_chart( .await; debug!(chart = cid, duration, "chart duration set"); } - None => warn!(chart = cid, "chart duration probe failed, using long fallback"), + None => warn!( + chart = cid, + "chart duration probe failed, using long fallback" + ), } }); } @@ -1176,8 +1171,10 @@ pub async fn select_chart( // 广播谱面信息(谱师/曲师/难度/评分)——按用户语言本地化 if let Some((charter, composer, level, rating, chart_updated)) = chart_meta { if !charter.is_empty() || !composer.is_empty() { - let room_seq = - Some(room.last_room_seq.load(std::sync::atomic::Ordering::Relaxed)); + let room_seq = Some( + room.last_room_seq + .load(std::sync::atomic::Ordering::Relaxed), + ); for user in room.users().await.into_iter().chain(room.monitors().await) { let lang = user.lang.clone(); let rating_part = rating @@ -1202,8 +1199,11 @@ pub async fn select_chart( args.set("rating", &rating_part); args.set("updated", &updated_part); let content = crate::l10n::translate_system(&lang, "chart-info-line", &args); - user.try_send(ServerCommand::Message(Message::Chat { user: 0, content }), room_seq) - .await; + user.try_send( + ServerCommand::Message(Message::Chat { user: 0, content }), + room_seq, + ) + .await; } } } @@ -1229,7 +1229,8 @@ pub async fn request_start( } // Check chart from snapshot. let has_chart = if let Some(server) = room.server.upgrade() { - server.room_snapshot(&room.id.to_string()) + server + .room_snapshot(&room.id.to_string()) .map(|s| s.chart.is_some()) .unwrap_or(false) } else { @@ -1335,10 +1336,19 @@ pub async fn played( user.server .room_commands .submit_result( - &user.server, &room.id.to_string(), user.id, - res.score, res.accuracy, res.perfect, res.good, - res.bad, res.miss, res.max_combo, res.full_combo, - res.std, res.std_score, + &user.server, + &room.id.to_string(), + user.id, + res.score, + res.accuracy, + res.perfect, + res.good, + res.bad, + res.miss, + res.max_combo, + res.full_combo, + res.std, + res.std_score, Some(deadline), origin.to_room_origin(), ) diff --git a/phira-mp-plus-server/src/wasm_host.rs b/phira-mp-plus-server/src/wasm_host.rs index 2e3b4c3c9..f22dd482e 100644 --- a/phira-mp-plus-server/src/wasm_host.rs +++ b/phira-mp-plus-server/src/wasm_host.rs @@ -2,7 +2,7 @@ //! //! JSON bridge ABI (phira_init, phira_get_info, phira_cleanup, phira_on_event, //! phira_on_api) has been removed. All plugins must be WIT components targeting -//! the phira-plugin-v2 world. +//! the phira-plugin-v3 world. //! //! Guest exports (expected from every WIT plugin): //! - `init() -> result<_, string>` @@ -40,8 +40,15 @@ pub struct WasmPluginServices { /// TCP event callback shared with PluginTcpActor. Dispatches /// tcp:accept / tcp:receive / tcp:disconnect / tcp:error events /// to the owning plugin via call_plugin_api. - pub tcp_callback: - Mutex Pin + Send>> + Send + Sync>>>, + pub tcp_callback: Mutex< + Option< + Arc< + dyn Fn(String, serde_json::Value) -> Pin + Send>> + + Send + + Sync, + >, + >, + >, /// Tracks which handler methods each plugin has registered. /// Shared with PluginManager — do not write separately. pub handler_owners: Arc>>>, @@ -102,7 +109,9 @@ impl WasmPluginServices { pub fn set_tcp_callback( &self, cb: Arc< - dyn Fn(String, serde_json::Value) -> Pin + Send>> + Send + Sync, + dyn Fn(String, serde_json::Value) -> Pin + Send>> + + Send + + Sync, >, ) { if let Ok(mut guard) = self.tcp_callback.lock() { @@ -198,12 +207,18 @@ impl WitPluginComponent { let plugin_manager = Arc::clone(&server.plugin_manager); let forward_plugin = plugin_name.to_string(); - let api_forward: Arc) -> Pin> + Send>> + Send + Sync> = Arc::new(move |method, args| { + let api_forward: Arc< + dyn Fn( + String, + Vec, + ) + -> Pin> + Send>> + + Send + + Sync, + > = Arc::new(move |method, args| { let pm = Arc::clone(&plugin_manager); let fp = forward_plugin.clone(); - Box::pin(async move { - pm.call_plugin_api(&fp, &method, args).await - }) + Box::pin(async move { pm.call_plugin_api(&fp, &method, args).await }) }); Ok(Arc::new(crate::wit_host::WitHostContext { @@ -217,7 +232,10 @@ impl WitPluginComponent { http_timeout_secs: server.config.wasm_runtime.http_timeout_secs, http_max_body: server.config.wasm_runtime.max_http_response_bytes, http_allow_private_network: server.config.wasm_runtime.allow_private_network, - node_key: Arc::new(crate::crypto::NodeKey::from_secret(&phira_mp_common::generate_secret_key("node_key", 32).map_err(|e| format!("node key derivation: {e}"))?)), + node_key: Arc::new(crate::crypto::NodeKey::from_secret( + &phira_mp_common::generate_secret_key("node_key", 32) + .map_err(|e| format!("node key derivation: {e}"))?, + )), timers: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())), timer_callback: None, tcp: server.plugin_tcp_tx.clone(), @@ -254,10 +272,11 @@ impl WitPluginComponent { let component = wasmtime::component::Component::new(&engine, wasm_bytes) .map_err(|e| format!("component compile: {e}"))?; let mut linker = wasmtime::component::Linker::::new(&engine); - wit_abi::PhiraPluginV3::add_to_linker::>( - &mut linker, - |state: &mut WitHostState| &mut state.host, - ).map_err(|e| format!("linker setup: {e}"))?; + wit_abi::PhiraPluginV3::add_to_linker::< + WitHostState, + wasmtime::component::HasSelf, + >(&mut linker, |state: &mut WitHostState| &mut state.host) + .map_err(|e| format!("linker setup: {e}"))?; let ctx = Self::build_context_from_services(&services, &plugin_name)?; Self::new_with_context(engine, component, linker, ctx, plugin_name, runtime) } @@ -286,10 +305,11 @@ impl WitPluginComponent { let component = wasmtime::component::Component::new(&engine, wasm_bytes) .map_err(|e| format!("component compile: {e}"))?; let mut linker = wasmtime::component::Linker::::new(&engine); - wit_abi::PhiraPluginV3::add_to_linker::>( - &mut linker, - |state: &mut WitHostState| &mut state.host, - ).map_err(|e| format!("linker setup: {e}"))?; + wit_abi::PhiraPluginV3::add_to_linker::< + WitHostState, + wasmtime::component::HasSelf, + >(&mut linker, |state: &mut WitHostState| &mut state.host) + .map_err(|e| format!("linker setup: {e}"))?; Self::new_with_context(engine, component, linker, ctx, plugin_name, runtime) } @@ -567,13 +587,13 @@ mod tests { use crate::wasm_host_helpers; #[test] - fn default_capabilities_include_all() { + fn default_capabilities_are_least_privilege() { let caps = wasm_host_helpers::default_capabilities(); - assert!(caps.contains("admin"), "default must include admin"); - assert!( - caps.contains("room.manage"), - "default must include room.manage" - ); + assert!(caps.contains("state.read")); + assert!(caps.contains("send")); + assert!(!caps.contains("admin")); + assert!(!caps.contains("room.manage")); + assert!(!caps.contains("http")); } #[test] @@ -755,10 +775,15 @@ mod tests { c.call_init().unwrap(); let result = c.call_api("host.api_call", &[serde_json::json!("admin.list")]); let v = result.expect("host.api_call should return Ok value (error encoded in JSON)"); - // Default capabilities now include all permissions, so admin.list - // reaches the handler and fails with "no handler" rather than capability. + // The mock context explicitly grants all capabilities for this + // legacy fixture, so admin.list reaches the handler and fails with + // "no handler" rather than capability. assert!( - v.get("error").is_none() || !v["error"].as_str().unwrap_or("").contains("requires capability"), + v.get("error").is_none() + || !v["error"] + .as_str() + .unwrap_or("") + .contains("requires capability"), "admin method should not be rejected by capability check" ); } diff --git a/phira-mp-plus-server/src/wasm_host_helpers.rs b/phira-mp-plus-server/src/wasm_host_helpers.rs index 327f47864..3bb904728 100644 --- a/phira-mp-plus-server/src/wasm_host_helpers.rs +++ b/phira-mp-plus-server/src/wasm_host_helpers.rs @@ -52,34 +52,17 @@ pub fn validate_config_key(value: &str) -> Result<(), String> { /// Default set of capabilities for plugins without a manifest. pub fn default_capabilities() -> HashSet { - [ - "state.read", - "send", - "ext", - "config", - "http", - "room.manage", - "admin", - "crypto", - "timer", - "tcp", - "room-state", - "handler", - "file.read", - "file.write", - "plugin.call", - "plugin.register", - ] - .into_iter() - .map(str::to_string) - .collect() + ["state.read", "send", "ext", "config", "timer", "room-state"] + .into_iter() + .map(str::to_string) + .collect() } /// Load the capability grant for a plugin. /// /// A sidecar named `.capabilities.json` may contain either a JSON array -/// or `{ "capabilities": [...] }`. Missing sidecars receive only the default, -/// non-privileged set. Unknown capability names are rejected rather than +/// or `{ "capabilities": [...] }`. Missing sidecars receive only the default +/// read/messaging set. Unknown capability names are rejected rather than /// silently granted. pub fn load_manifest_capabilities(plugin_path: &str) -> Result, String> { let path = Path::new(plugin_path); @@ -493,7 +476,10 @@ mod tests { assert_eq!(required_capability("uuid.v4"), None); assert_eq!(required_capability("admin.list"), Some("admin")); assert_eq!(required_capability("room.set_lock"), Some("room.manage")); - assert_eq!(required_capability("room.set_tournament"), Some("room.manage")); + assert_eq!( + required_capability("room.set_tournament"), + Some("room.manage") + ); assert_eq!(required_capability("room.set_live"), Some("room.manage")); assert_eq!(required_capability("room.set_chart"), Some("room.manage")); assert_eq!(required_capability("ban.check"), Some("admin")); diff --git a/phira-mp-plus-server/src/wit_host/mod.rs b/phira-mp-plus-server/src/wit_host/mod.rs index c9df8a91c..6d8a4edf2 100644 --- a/phira-mp-plus-server/src/wit_host/mod.rs +++ b/phira-mp-plus-server/src/wit_host/mod.rs @@ -87,7 +87,10 @@ mod wit_trait_impls { fn api_call(&mut self, method: String, args: Vec) -> types::ApiResult { tracing::trace!(plugin = %self.plugin_name, %method, "api_call"); - let args_serde: Vec = args.iter().map(super::host::wit_json_value_to_serde).collect(); + let args_serde: Vec = args + .iter() + .map(super::host::wit_json_value_to_serde) + .collect(); let args_serde = match normalize_plugin_scoped_api_args(&method, &self.plugin_name, args_serde) { Ok(args) => args, @@ -206,7 +209,9 @@ mod wit_trait_impls { return error; } match self.block_on_async(move |ctx| async move { - ctx.extensions.set_user_extra(user_id as i32, &key, value).await + ctx.extensions + .set_user_extra(user_id as i32, &key, value) + .await }) { Ok(Ok(())) => types::ApiResult::Ok(types::JsonValue::Null), Ok(Err(e)) | Err(e) => types::ApiResult::Error(e), @@ -697,102 +702,173 @@ mod wit_trait_impls { self.require_capability("tcp")?; let tx = self.ctx.tcp.as_ref().ok_or("tcp not available")?; let (reply, rx) = std::sync::mpsc::channel(); - tx.try_send(crate::plugin_tcp::PluginTcpCommand::Connect { plugin_id: self.plugin_name.clone(), addr, reply }) - .map_err(|e| format!("tcp connect failed: {e}"))?; - rx.recv_timeout(Duration::from_secs(5)).map_err(|e| - match e { - std::sync::mpsc::RecvTimeoutError::Timeout => "tcp connect timed out".to_string(), - std::sync::mpsc::RecvTimeoutError::Disconnected => "tcp connect reply lost".to_string(), - } - )? + tx.try_send(crate::plugin_tcp::PluginTcpCommand::Connect { + plugin_id: self.plugin_name.clone(), + addr, + reply, + }) + .map_err(|e| format!("tcp connect failed: {e}"))?; + rx.recv_timeout(Duration::from_secs(5)) + .map_err(|e| match e { + std::sync::mpsc::RecvTimeoutError::Timeout => { + "tcp connect timed out".to_string() + } + std::sync::mpsc::RecvTimeoutError::Disconnected => { + "tcp connect reply lost".to_string() + } + })? } fn listen(&mut self, addr: String) -> Result { self.require_capability("tcp")?; let tx = self.ctx.tcp.as_ref().ok_or("tcp not available")?; let (reply, rx) = std::sync::mpsc::channel(); - tx.try_send(crate::plugin_tcp::PluginTcpCommand::Listen { plugin_id: self.plugin_name.clone(), addr, reply }) - .map_err(|e| format!("tcp listen failed: {e}"))?; - rx.recv_timeout(Duration::from_secs(5)).map_err(|e| - match e { - std::sync::mpsc::RecvTimeoutError::Timeout => "tcp listen timed out".to_string(), - std::sync::mpsc::RecvTimeoutError::Disconnected => "tcp listen reply lost".to_string(), - } - )? + tx.try_send(crate::plugin_tcp::PluginTcpCommand::Listen { + plugin_id: self.plugin_name.clone(), + addr, + reply, + }) + .map_err(|e| format!("tcp listen failed: {e}"))?; + rx.recv_timeout(Duration::from_secs(5)) + .map_err(|e| match e { + std::sync::mpsc::RecvTimeoutError::Timeout => { + "tcp listen timed out".to_string() + } + std::sync::mpsc::RecvTimeoutError::Disconnected => { + "tcp listen reply lost".to_string() + } + })? } fn send(&mut self, handle: u64, bytes: Vec) -> Result<(), String> { self.require_capability("tcp")?; let tx = self.ctx.tcp.as_ref().ok_or("tcp not available")?; - tx.try_send(crate::plugin_tcp::PluginTcpCommand::Send { plugin_id: self.plugin_name.clone(), handle, bytes }) - .map_err(|e| format!("tcp send failed: {e}")) + tx.try_send(crate::plugin_tcp::PluginTcpCommand::Send { + plugin_id: self.plugin_name.clone(), + handle, + bytes, + }) + .map_err(|e| format!("tcp send failed: {e}")) } fn close(&mut self, handle: u64) -> Result<(), String> { self.require_capability("tcp")?; let tx = self.ctx.tcp.as_ref().ok_or("tcp not available")?; - tx.try_send(crate::plugin_tcp::PluginTcpCommand::Close { plugin_id: self.plugin_name.clone(), handle }) - .map_err(|e| format!("tcp close failed: {e}")) + tx.try_send(crate::plugin_tcp::PluginTcpCommand::Close { + plugin_id: self.plugin_name.clone(), + handle, + }) + .map_err(|e| format!("tcp close failed: {e}")) } fn accept(&mut self, handle: u64) -> Result, String> { self.require_capability("tcp")?; let tx = self.ctx.tcp.as_ref().ok_or("tcp not available")?; let (reply, rx) = std::sync::mpsc::channel(); - tx.try_send(crate::plugin_tcp::PluginTcpCommand::Accept { plugin_id: self.plugin_name.clone(), listener_handle: handle, reply }) - .map_err(|e| format!("tcp accept failed: {e}"))?; - rx.recv_timeout(Duration::from_secs(5)).map_err(|e| - match e { - std::sync::mpsc::RecvTimeoutError::Timeout => "tcp accept timed out".to_string(), - std::sync::mpsc::RecvTimeoutError::Disconnected => "tcp accept reply lost".to_string(), - } - )? + tx.try_send(crate::plugin_tcp::PluginTcpCommand::Accept { + plugin_id: self.plugin_name.clone(), + listener_handle: handle, + reply, + }) + .map_err(|e| format!("tcp accept failed: {e}"))?; + rx.recv_timeout(Duration::from_secs(5)) + .map_err(|e| match e { + std::sync::mpsc::RecvTimeoutError::Timeout => { + "tcp accept timed out".to_string() + } + std::sync::mpsc::RecvTimeoutError::Disconnected => { + "tcp accept reply lost".to_string() + } + })? } fn recv(&mut self, handle: u64, max_bytes: u32) -> Result>, String> { self.require_capability("tcp")?; let tx = self.ctx.tcp.as_ref().ok_or("tcp not available")?; let (reply, rx) = std::sync::mpsc::channel(); - tx.try_send(crate::plugin_tcp::PluginTcpCommand::Recv { plugin_id: self.plugin_name.clone(), handle, max_bytes, reply }) - .map_err(|e| format!("tcp recv failed: {e}"))?; - rx.recv_timeout(Duration::from_secs(5)).map_err(|e| - match e { + tx.try_send(crate::plugin_tcp::PluginTcpCommand::Recv { + plugin_id: self.plugin_name.clone(), + handle, + max_bytes, + reply, + }) + .map_err(|e| format!("tcp recv failed: {e}"))?; + rx.recv_timeout(Duration::from_secs(5)) + .map_err(|e| match e { std::sync::mpsc::RecvTimeoutError::Timeout => "tcp recv timed out".to_string(), - std::sync::mpsc::RecvTimeoutError::Disconnected => "tcp recv reply lost".to_string(), - } - )? + std::sync::mpsc::RecvTimeoutError::Disconnected => { + "tcp recv reply lost".to_string() + } + })? } fn peer_addr(&mut self, handle: u64) -> Result { self.require_capability("tcp")?; let tx = self.ctx.tcp.as_ref().ok_or("tcp not available")?; let (reply, rx) = std::sync::mpsc::channel(); - tx.try_send(crate::plugin_tcp::PluginTcpCommand::PeerAddr { plugin_id: self.plugin_name.clone(), handle, reply }) - .map_err(|e| format!("tcp peer-addr failed: {e}"))?; - rx.recv_timeout(Duration::from_secs(5)).map_err(|e| - match e { - std::sync::mpsc::RecvTimeoutError::Timeout => "tcp peer-addr timed out".to_string(), - std::sync::mpsc::RecvTimeoutError::Disconnected => "tcp peer-addr reply lost".to_string(), - } - )? + tx.try_send(crate::plugin_tcp::PluginTcpCommand::PeerAddr { + plugin_id: self.plugin_name.clone(), + handle, + reply, + }) + .map_err(|e| format!("tcp peer-addr failed: {e}"))?; + rx.recv_timeout(Duration::from_secs(5)) + .map_err(|e| match e { + std::sync::mpsc::RecvTimeoutError::Timeout => { + "tcp peer-addr timed out".to_string() + } + std::sync::mpsc::RecvTimeoutError::Disconnected => { + "tcp peer-addr reply lost".to_string() + } + })? } } // ── phira-room-state ── impl wit::phira::plugin::phira_room_state::Host for WitPluginHost { - fn get_room_state(&mut self, room_id: String) -> Result { + fn get_room_state( + &mut self, + room_id: String, + ) -> Result { self.require_capability("room-state")?; - let v = self.ctx.state_query.call("rooms.by_name", &[serde_json::json!(room_id)])?; + let v = self + .ctx + .state_query + .call("rooms.by_name", &[serde_json::json!(room_id)])?; let data = extract_snapshot_data(&v)?; - let rid = data.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(); - let room_uuid = data.get("uuid").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let rid = data + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let room_uuid = data + .get("uuid") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); let host_val = data.get("host").and_then(|v| v.as_i64()).unwrap_or(-1); - let host_id = if host_val >= 0 { Some(host_val as u32) } else { None }; - let locked = data.get("locked").and_then(|v| v.as_bool()).unwrap_or(false); - let hidden = data.get("hidden").and_then(|v| v.as_bool()).unwrap_or(false); - let player_count = data.get("player_count").and_then(|v| v.as_u64()).unwrap_or(0) as u32; - let monitor_count = data.get("monitor_count").and_then(|v| v.as_u64()).unwrap_or(0) as u32; + let host_id = if host_val >= 0 { + Some(host_val as u32) + } else { + None + }; + let locked = data + .get("locked") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let hidden = data + .get("hidden") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let player_count = data + .get("player_count") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32; + let monitor_count = data + .get("monitor_count") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32; let players = build_room_players(data); @@ -800,19 +876,36 @@ mod wit_trait_impls { let current_round = extract_current_round(data); Ok(wit::phira::plugin::phira_room_state::RoomState { - room_id: rid, room_uuid, host_id, locked, hidden, - player_count, monitor_count, players, current_round, + room_id: rid, + room_uuid, + host_id, + locked, + hidden, + player_count, + monitor_count, + players, + current_round, }) } - fn get_room_players(&mut self, room_id: String) -> Result, String> { + fn get_room_players( + &mut self, + room_id: String, + ) -> Result, String> { self.require_capability("room-state")?; - let v = self.ctx.state_query.call("rooms.by_name", &[serde_json::json!(room_id)])?; + let v = self + .ctx + .state_query + .call("rooms.by_name", &[serde_json::json!(room_id)])?; let data = extract_snapshot_data(&v)?; Ok(build_room_players(data)) } - fn get_player_status(&mut self, room_id: String, user_id: u32) -> Result, String> { + fn get_player_status( + &mut self, + room_id: String, + user_id: u32, + ) -> Result, String> { let players = self.get_room_players(room_id)?; Ok(players.into_iter().find(|p| p.user_id == user_id)) } @@ -820,11 +913,12 @@ mod wit_trait_impls { fn list_rooms(&mut self) -> Result, String> { self.require_capability("room-state")?; let v = self.ctx.state_query.call("rooms.list", &[])?; - let rooms: Vec = serde_json::from_value(v) - .map_err(|e| format!("list rooms parse error: {e}"))?; + let rooms: Vec = + serde_json::from_value(v).map_err(|e| format!("list rooms parse error: {e}"))?; // Each room entry is a RoomSnapshot { name, data } — room ID is in data.id. // Hidden rooms are already filtered server-side by rooms.list. - let ids: Vec = rooms.iter() + let ids: Vec = rooms + .iter() .filter_map(|r| { r.get("data") .and_then(|d| d.get("id")) @@ -838,13 +932,19 @@ mod wit_trait_impls { // ── phira-handler ── impl wit::phira::plugin::phira_handler::Host for WitPluginHost { - fn register_handler(&mut self, desc: wit::phira::plugin::phira_handler::HandlerDescriptor) -> Result<(), String> { + fn register_handler( + &mut self, + desc: wit::phira::plugin::phira_handler::HandlerDescriptor, + ) -> Result<(), String> { self.require_capability("handler")?; let method = desc.method.clone(); if method.is_empty() || method.len() > 128 { return Err("handler method name must be 1-128 chars".to_string()); } - if !method.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '.' || c == ':') { + if !method + .chars() + .all(|c| c.is_alphanumeric() || c == '_' || c == '.' || c == ':') + { return Err("handler method name contains invalid characters".to_string()); } // Reject reserved "phira:" namespace @@ -869,18 +969,27 @@ mod wit_trait_impls { request_schema: desc.request_schema, response_schema: desc.response_schema, }; - let mut registry = self.ctx.api_handlers.lock() + let mut registry = self + .ctx + .api_handlers + .lock() .map_err(|e| format!("handler registry lock: {e}"))?; registry.insert(method.clone(), registered); // Register in shared PluginManager registry with plugin_name prefix // to avoid silent override of another plugin's handler. let shared_key = format!("{}.{}", self.plugin_name, method); - if let (Some(ref shared), Some(ref forward)) = (&self.ctx.services_handlers, &self.ctx.api_forward) { + if let (Some(ref shared), Some(ref forward)) = + (&self.ctx.services_handlers, &self.ctx.api_forward) + { { - let sh = shared.lock().map_err(|e| format!("handler registry lock: {e}"))?; + let sh = shared + .lock() + .map_err(|e| format!("handler registry lock: {e}"))?; if sh.contains_key(&shared_key) { - return Err(format!("handler method '{method}' is already registered by another plugin")); + return Err(format!( + "handler method '{method}' is already registered by another plugin" + )); } } let method_clone = method.clone(); @@ -888,9 +997,7 @@ mod wit_trait_impls { let handler: api::PluginApiHandler = Arc::new(move |_m, args| { let forward = Arc::clone(&forward_clone); let m = method_clone.clone(); - Box::pin(async move { - forward(m, args).await - }) + Box::pin(async move { forward(m, args).await }) }); if let Ok(mut sh) = shared.lock() { sh.insert(shared_key, handler); @@ -899,7 +1006,9 @@ mod wit_trait_impls { // Track handler ownership for cleanup if let Some(ref owners) = self.ctx.handler_owners { if let Ok(mut map) = owners.lock() { - map.entry(self.plugin_name.clone()).or_default().push(method.clone()); + map.entry(self.plugin_name.clone()) + .or_default() + .push(method.clone()); } } @@ -908,7 +1017,10 @@ mod wit_trait_impls { fn unregister_handler(&mut self, method: String) -> Result<(), String> { self.require_capability("handler")?; - let mut registry = self.ctx.api_handlers.lock() + let mut registry = self + .ctx + .api_handlers + .lock() .map_err(|e| format!("handler registry lock: {e}"))?; match registry.get(&method) { Some(h) if h.plugin_name == self.plugin_name => { @@ -937,10 +1049,16 @@ mod wit_trait_impls { } } - fn list_handlers(&mut self) -> Result, String> { - let registry = self.ctx.api_handlers.lock() + fn list_handlers( + &mut self, + ) -> Result, String> { + let registry = self + .ctx + .api_handlers + .lock() .map_err(|e| format!("handler registry lock: {e}"))?; - let handlers: Vec<_> = registry.values() + let handlers: Vec<_> = registry + .values() .filter(|h| h.plugin_name == self.plugin_name) .map(|h| wit::phira::plugin::phira_handler::HandlerDescriptor { method: h.method.clone(), @@ -962,6 +1080,7 @@ mod wit_trait_impls { // ── phira-timer ── impl wit::phira::plugin::phira_timer::Host for WitPluginHost { fn set_timer(&mut self, delay_ms: u64, timer_id: String) -> Result<(), String> { + self.require_capability("timer")?; let plugin_name = self.plugin_name.clone(); let ctx = Arc::clone(&self.ctx); let timer_name = timer_id.clone(); @@ -974,7 +1093,11 @@ mod wit_trait_impls { } }); - let mut registry = self.ctx.timers.lock().map_err(|e| format!("timer lock: {e}"))?; + let mut registry = self + .ctx + .timers + .lock() + .map_err(|e| format!("timer lock: {e}"))?; registry .entry(plugin_name) .or_default() @@ -983,7 +1106,12 @@ mod wit_trait_impls { } fn clear_timer(&mut self, timer_id: String) -> Result<(), String> { - let mut registry = self.ctx.timers.lock().map_err(|e| format!("timer lock: {e}"))?; + self.require_capability("timer")?; + let mut registry = self + .ctx + .timers + .lock() + .map_err(|e| format!("timer lock: {e}"))?; if let Some(timers) = registry.get_mut(&self.plugin_name) { if let Some(handle) = timers.remove(&timer_id) { handle.abort(); @@ -1000,9 +1128,16 @@ mod wit_trait_impls { Ok(self.ctx.node_key.sign(&payload)) } - fn verify(&mut self, pubkey: Vec, payload: Vec, signature: Vec) -> Result { + fn verify( + &mut self, + pubkey: Vec, + payload: Vec, + signature: Vec, + ) -> Result { self.require_capability("crypto")?; - Ok(crate::crypto::NodeKey::verify(&pubkey, &payload, &signature)) + Ok(crate::crypto::NodeKey::verify( + &pubkey, &payload, &signature, + )) } fn sha256(&mut self, data: Vec) -> Result, String> { @@ -1051,13 +1186,13 @@ mod capability_tests { } #[test] - fn default_capabilities_include_all() { + fn default_capabilities_are_least_privilege() { let caps = wasm_host_helpers::default_capabilities(); - assert!(caps.contains("admin"), "default must include admin"); - assert!( - caps.contains("room.manage"), - "default must include room.manage" - ); + assert!(caps.contains("state.read")); + assert!(caps.contains("send")); + assert!(!caps.contains("admin")); + assert!(!caps.contains("room.manage")); + assert!(!caps.contains("http")); assert!( caps.contains("state.read"), "default must include state.read" diff --git a/phira-mp-plus-server/tests/test-plugin/src/lib.rs b/phira-mp-plus-server/tests/test-plugin/src/lib.rs index bbe2abbe3..b150926e3 100644 --- a/phira-mp-plus-server/tests/test-plugin/src/lib.rs +++ b/phira-mp-plus-server/tests/test-plugin/src/lib.rs @@ -1,6 +1,6 @@ //! Minimal test WASM plugin for integration tests. //! -//! Implements the phira-plugin-v2 world with known, deterministic behavior +//! Implements the phira-plugin-v3 world with known, deterministic behavior //! that integration tests verify against. // Use wit-bindgen directly instead of the SDK macro because the test @@ -8,7 +8,7 @@ // hardcoded "../wit/phira-plugin.wit" path doesn't resolve from here. wit_bindgen::generate!({ path: "../../../wit/phira-plugin.wit", - world: "phira-plugin-v2", + world: "phira-plugin-v3", }); export!(TestPlugin); diff --git a/phira-mp-plus-server/tests/wit_abi_contracts.rs b/phira-mp-plus-server/tests/wit_abi_contracts.rs index 0316a83d9..7e9f186b9 100644 --- a/phira-mp-plus-server/tests/wit_abi_contracts.rs +++ b/phira-mp-plus-server/tests/wit_abi_contracts.rs @@ -535,10 +535,15 @@ fn default_capabilities_include_expected_set() { ); assert!(caps.contains("send"), "default should include send"); assert!(caps.contains("config"), "default should include config"); - assert!(caps.contains("admin"), "default should include admin"); + assert!(caps.contains("timer"), "default should include timer"); assert!( - caps.contains("room.manage"), - "default should include room.manage" + caps.contains("room-state"), + "default should include room-state" + ); + assert!(!caps.contains("admin"), "default must not include admin"); + assert!( + !caps.contains("room.manage"), + "default must not include room.manage" ); } @@ -550,8 +555,8 @@ fn default_capabilities_covers_basic_plugin_needs() { "send", "ext", "config", - "file.read", - "file.write", + "timer", + "room-state", ]; for cap in &essentials { assert!(caps.contains(*cap), "default should contain {cap}"); diff --git a/phira-mp/phira-mp-common/Cargo.toml b/phira-mp/phira-mp-common/Cargo.toml index c9b3061a1..6b668bb7a 100644 --- a/phira-mp/phira-mp-common/Cargo.toml +++ b/phira-mp/phira-mp-common/Cargo.toml @@ -9,7 +9,7 @@ default = ["stream"] stream = ["dep:tokio", "dep:tracing", "dep:hkdf", "dep:sha2"] [dependencies] -serde = "1.0" +serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" anyhow = { workspace = true } byteorder = "1.5.0" diff --git a/phira-mp/phira-mp-common/src/bin.rs b/phira-mp/phira-mp-common/src/bin.rs index db104a367..eec30ea9d 100644 --- a/phira-mp/phira-mp-common/src/bin.rs +++ b/phira-mp/phira-mp-common/src/bin.rs @@ -32,8 +32,12 @@ impl<'a> BinaryReader<'a> { } pub fn take(&mut self, n: usize) -> Result<&'a [u8]> { + let end = self + .1 + .checked_add(n) + .ok_or_else(|| anyhow!("binary length overflow"))?; self.0 - .get(self.1..(self.1 + n)) + .get(self.1..end) .ok_or_else(|| anyhow!("unexpected EOF")) .tap_ok(|_| self.1 += n) } @@ -46,6 +50,9 @@ impl<'a> BinaryReader<'a> { let mut result = 0; let mut shift = 0; loop { + if shift >= 64 { + return Err(anyhow!("ULEB128 value is too large")); + } let byte = self.read::()?; result |= ((byte & 0x7f) as u64) << shift; if byte & 0x80 == 0 { diff --git a/phira-mp/phira-mp-common/src/command.rs b/phira-mp/phira-mp-common/src/command.rs index c449cc8e1..f2487bd64 100644 --- a/phira-mp/phira-mp-common/src/command.rs +++ b/phira-mp/phira-mp-common/src/command.rs @@ -246,16 +246,13 @@ pub enum Message { user: i32, }, StartPlaying, + /// Official v1 packet shape. Detailed judgement counts are server-side + /// data and are intentionally not appended to this compatibility message. Played { user: i32, score: i32, accuracy: f32, full_combo: bool, - perfect: i32, - good: i32, - bad: i32, - miss: i32, - max_combo: i32, }, GameEnd, Abort { diff --git a/phira-mp/phira-mp-common/src/lib.rs b/phira-mp/phira-mp-common/src/lib.rs index f1710ac15..edfb3b1cf 100644 --- a/phira-mp/phira-mp-common/src/lib.rs +++ b/phira-mp/phira-mp-common/src/lib.rs @@ -6,6 +6,11 @@ pub use command::*; use anyhow::Result; +/// Official Phira-mp wire protocol version supported by this workspace. +/// Extensions must use a separately negotiated version; v1 keeps the exact +/// upstream packet layouts. +pub const PROTOCOL_VERSION: u8 = 1; + pub fn encode_packet(payload: &impl BinaryData, vec: &mut Vec) -> Result<()> { BinaryWriter::new(vec).write(payload)?; Ok(()) @@ -142,7 +147,13 @@ mod stream_impl { while let Some(outbound) = send_rx.recv().await { let Outbound { payload, flushed } = outbound; buffer.clear(); - encode_packet(&payload, &mut buffer).expect("encode_packet failed"); + if let Err(err) = encode_packet(&payload, &mut buffer) { + error!(?err, "failed to encode outbound packet"); + if let Some(flushed) = flushed { + let _ = flushed.send(Err(err.to_string())); + } + break; + } trace!("sending {} bytes ({payload:?}): {buffer:?}", buffer.len()); let mut x = buffer.len() as u32; @@ -197,7 +208,7 @@ mod stream_impl { #[allow(clippy::read_zero_byte_vec)] async move { let mut buffer = Vec::new(); - loop { + let result = loop { let mut len = 0u32; let mut pos = 0; loop { @@ -224,7 +235,7 @@ mod stream_impl { Ok(val) => val, Err(err) => { warn!("invalid packet: {err:?} {buffer:?}"); - break; + break Err(anyhow!("invalid packet: {err}")); } }; trace!("decodes to {payload:?}"); @@ -232,8 +243,8 @@ mod stream_impl { .send(payload) .await .map_err(|_| anyhow!("command handler stopped"))?; - } - Ok(()) + }; + result } }); diff --git a/phira-mp/phira-mp-common/tests/protocol_golden.rs b/phira-mp/phira-mp-common/tests/protocol_golden.rs index 46b81110d..bc442e7f0 100644 --- a/phira-mp/phira-mp-common/tests/protocol_golden.rs +++ b/phira-mp/phira-mp-common/tests/protocol_golden.rs @@ -113,11 +113,32 @@ fn round_trip_ok(val: &T) { fn client_command_discriminants() { assert_eq!(disc(&ClientCommand::Ping), 0); assert_eq!(disc(&ClientCommand::Authenticate { token: varc("x") }), 1); - assert_eq!(disc(&ClientCommand::Chat { message: varc("hi") }), 2); - assert_eq!(disc(&ClientCommand::Touches { frames: Arc::new(vec![touch_frame()]) }), 3); - assert_eq!(disc(&ClientCommand::Judges { judges: Arc::new(vec![judge_event()]) }), 4); + assert_eq!( + disc(&ClientCommand::Chat { + message: varc("hi") + }), + 2 + ); + assert_eq!( + disc(&ClientCommand::Touches { + frames: Arc::new(vec![touch_frame()]) + }), + 3 + ); + assert_eq!( + disc(&ClientCommand::Judges { + judges: Arc::new(vec![judge_event()]) + }), + 4 + ); assert_eq!(disc(&ClientCommand::CreateRoom { id: room("abc") }), 5); - assert_eq!(disc(&ClientCommand::JoinRoom { id: room("abc"), monitor: true }), 6); + assert_eq!( + disc(&ClientCommand::JoinRoom { + id: room("abc"), + monitor: true + }), + 6 + ); assert_eq!(disc(&ClientCommand::LeaveRoom), 7); assert_eq!(disc(&ClientCommand::LockRoom { lock: true }), 8); assert_eq!(disc(&ClientCommand::CycleRoom { cycle: true }), 9); @@ -127,20 +148,54 @@ fn client_command_discriminants() { assert_eq!(disc(&ClientCommand::CancelReady), 13); assert_eq!(disc(&ClientCommand::Played { id: 2 }), 14); assert_eq!(disc(&ClientCommand::Abort), 15); - assert_eq!(disc(&ClientCommand::ConsoleAuthenticate { token: varc("y") }), 16); - assert_eq!(disc(&ClientCommand::RoomMonitorAuthenticate { key: vec![1, 2, 3] }), 17); + assert_eq!( + disc(&ClientCommand::ConsoleAuthenticate { token: varc("y") }), + 16 + ); + assert_eq!( + disc(&ClientCommand::RoomMonitorAuthenticate { key: vec![1, 2, 3] }), + 17 + ); assert_eq!(disc(&ClientCommand::QueryRoomInfo), 18); - assert_eq!(disc(&ClientCommand::GameMonitorAuthenticate { token: varc("z") }), 19); + assert_eq!( + disc(&ClientCommand::GameMonitorAuthenticate { token: varc("z") }), + 19 + ); } #[test] fn message_discriminants() { - assert_eq!(disc(&Message::Chat { user: 7, content: "hi".into() }), 0); + assert_eq!( + disc(&Message::Chat { + user: 7, + content: "hi".into() + }), + 0 + ); assert_eq!(disc(&Message::CreateRoom { user: 1 }), 1); - assert_eq!(disc(&Message::JoinRoom { user: 1, name: "alice".into() }), 2); - assert_eq!(disc(&Message::LeaveRoom { user: 1, name: "alice".into() }), 3); + assert_eq!( + disc(&Message::JoinRoom { + user: 1, + name: "alice".into() + }), + 2 + ); + assert_eq!( + disc(&Message::LeaveRoom { + user: 1, + name: "alice".into() + }), + 3 + ); assert_eq!(disc(&Message::NewHost { user: 1 }), 4); - assert_eq!(disc(&Message::SelectChart { user: 1, name: "c".into(), id: 5 }), 5); + assert_eq!( + disc(&Message::SelectChart { + user: 1, + name: "c".into(), + id: 5 + }), + 5 + ); assert_eq!(disc(&Message::GameStart { user: 1 }), 6); assert_eq!(disc(&Message::Ready { user: 1 }), 7); assert_eq!(disc(&Message::CancelReady { user: 1 }), 8); @@ -152,11 +207,6 @@ fn message_discriminants() { score: 2, accuracy: 3.5, full_combo: true, - perfect: 4, - good: 5, - bad: 6, - miss: 7, - max_combo: 8, }), 11 ); @@ -170,7 +220,10 @@ fn message_discriminants() { fn server_command_discriminants() { assert_eq!(disc(&ServerCommand::Pong), 0); assert_eq!( - disc(&ServerCommand::Authenticate(Ok((user(1, "alice", false), None)))), + disc(&ServerCommand::Authenticate(Ok(( + user(1, "alice", false), + None + )))), 1 ); assert_eq!(disc(&ServerCommand::Chat(Ok(()))), 2); @@ -188,8 +241,17 @@ fn server_command_discriminants() { }), 4 ); - assert_eq!(disc(&ServerCommand::Message(Message::Chat { user: 7, content: "hi".into() })), 5); - assert_eq!(disc(&ServerCommand::ChangeState(RoomState::WaitingForReady)), 6); + assert_eq!( + disc(&ServerCommand::Message(Message::Chat { + user: 7, + content: "hi".into() + })), + 5 + ); + assert_eq!( + disc(&ServerCommand::ChangeState(RoomState::WaitingForReady)), + 6 + ); assert_eq!(disc(&ServerCommand::ChangeHost(true)), 7); assert_eq!(disc(&ServerCommand::CreateRoom(Ok(()))), 8); assert_eq!( @@ -200,7 +262,10 @@ fn server_command_discriminants() { }))), 9 ); - assert_eq!(disc(&ServerCommand::OnJoinRoom(user(1, "alice", false))), 10); + assert_eq!( + disc(&ServerCommand::OnJoinRoom(user(1, "alice", false))), + 10 + ); assert_eq!(disc(&ServerCommand::LeaveRoom(Ok(()))), 11); assert_eq!(disc(&ServerCommand::LockRoom(Ok(()))), 12); assert_eq!(disc(&ServerCommand::CycleRoom(Ok(()))), 13); @@ -211,10 +276,19 @@ fn server_command_discriminants() { assert_eq!(disc(&ServerCommand::Played(Ok(()))), 18); assert_eq!(disc(&ServerCommand::Abort(Ok(()))), 19); assert_eq!( - disc(&ServerCommand::RoomResponse(Ok((HashMap::new(), HashMap::new())))), + disc(&ServerCommand::RoomResponse(Ok(( + HashMap::new(), + HashMap::new() + )))), 20 ); - assert_eq!(disc(&ServerCommand::RoomEvent(RoomEvent::LeaveRoom { room: room("abc"), user: 1 })), 21); + assert_eq!( + disc(&ServerCommand::RoomEvent(RoomEvent::LeaveRoom { + room: room("abc"), + user: 1 + })), + 21 + ); assert_eq!(disc(&ServerCommand::UserVisit(1)), 22); } @@ -226,8 +300,12 @@ fn server_command_discriminants() { #[test] fn round_trip_all_client_commands() { round_trip_ok(&ClientCommand::Ping); - round_trip_ok(&ClientCommand::Authenticate { token: varc("token") }); - round_trip_ok(&ClientCommand::Chat { message: varc("hello world") }); + round_trip_ok(&ClientCommand::Authenticate { + token: varc("token"), + }); + round_trip_ok(&ClientCommand::Chat { + message: varc("hello world"), + }); round_trip_ok(&ClientCommand::Touches { frames: Arc::new(vec![touch_frame(), touch_frame()]), }); @@ -248,7 +326,9 @@ fn round_trip_all_client_commands() { round_trip_ok(&ClientCommand::CancelReady); round_trip_ok(&ClientCommand::Played { id: 999 }); round_trip_ok(&ClientCommand::Abort); - round_trip_ok(&ClientCommand::ConsoleAuthenticate { token: varc("console") }); + round_trip_ok(&ClientCommand::ConsoleAuthenticate { + token: varc("console"), + }); round_trip_ok(&ClientCommand::RoomMonitorAuthenticate { key: vec![0, 255, 128, 7], }); @@ -287,11 +367,6 @@ fn round_trip_all_messages() { score: 1234567, accuracy: 99.97, full_combo: true, - perfect: 111, - good: 22, - bad: 3, - miss: 4, - max_combo: 555, }); round_trip_ok(&Message::GameEnd); round_trip_ok(&Message::Abort { user: 13 }); @@ -302,7 +377,10 @@ fn round_trip_all_messages() { #[test] fn round_trip_all_server_commands() { round_trip_ok(&ServerCommand::Pong); - round_trip_ok(&ServerCommand::Authenticate(Ok((user(1, "alice", true), Some(client_room_state()))))); + round_trip_ok(&ServerCommand::Authenticate(Ok(( + user(1, "alice", true), + Some(client_room_state()), + )))); round_trip_ok(&ServerCommand::Authenticate(Err("bad token".to_string()))); round_trip_ok(&ServerCommand::Chat(Ok(()))); round_trip_ok(&ServerCommand::Chat(Err("no permission".to_string()))); @@ -339,37 +417,40 @@ fn round_trip_all_server_commands() { round_trip_ok(&ServerCommand::CancelReady(Ok(()))); round_trip_ok(&ServerCommand::Played(Ok(()))); round_trip_ok(&ServerCommand::Abort(Ok(()))); - round_trip_ok(&ServerCommand::RoomResponse(Ok(({ - let mut rooms = HashMap::new(); - let mut data = RoomData { - host: 1, - users: vec![1, 2], - lock: false, - cycle: true, - chart: Some(5), - state: StrippedRoomState::WaitingForReady, - rounds: vec![], - }; - data.rounds.push(RoundData { - chart: 5, - records: vec![Record { - id: 1, - player: 1, - score: 100, - perfect: 10, - good: 2, - bad: 0, - miss: 1, - max_combo: 12, - accuracy: 98.5, - full_combo: true, - std: 1.0, - std_score: 2.5, - }], - }); - rooms.insert(room("abc"), data); - rooms - }, HashMap::new())))); + round_trip_ok(&ServerCommand::RoomResponse(Ok(( + { + let mut rooms = HashMap::new(); + let mut data = RoomData { + host: 1, + users: vec![1, 2], + lock: false, + cycle: true, + chart: Some(5), + state: StrippedRoomState::WaitingForReady, + rounds: vec![], + }; + data.rounds.push(RoundData { + chart: 5, + records: vec![Record { + id: 1, + player: 1, + score: 100, + perfect: 10, + good: 2, + bad: 0, + miss: 1, + max_combo: 12, + accuracy: 98.5, + full_combo: true, + std: 1.0, + std_score: 2.5, + }], + }); + rooms.insert(room("abc"), data); + rooms + }, + HashMap::new(), + )))); round_trip_ok(&ServerCommand::RoomEvent(RoomEvent::CreateRoom { room: room("abc"), data: RoomData { @@ -404,9 +485,15 @@ fn golden_simple_packets() { assert_eq!(encode_bin(&Message::GameEnd), vec![0x0C]); // LockRoom { lock: true }: discriminant 8, then bool true. - assert_eq!(encode_bin(&ClientCommand::LockRoom { lock: true }), vec![0x08, 0x01]); + assert_eq!( + encode_bin(&ClientCommand::LockRoom { lock: true }), + vec![0x08, 0x01] + ); // bool false is a distinct byte value. - assert_eq!(encode_bin(&ClientCommand::LockRoom { lock: false }), vec![0x08, 0x00]); + assert_eq!( + encode_bin(&ClientCommand::LockRoom { lock: false }), + vec![0x08, 0x00] + ); // Authenticate { token: "x" }: disc 1, Varchar<32> = uleb(1) + 'x'. assert_eq!( @@ -416,7 +503,9 @@ fn golden_simple_packets() { // Chat { message: "hi" }: disc 2, Varchar<200> = uleb(2) + "hi". assert_eq!( - encode_bin(&ClientCommand::Chat { message: varc("hi") }), + encode_bin(&ClientCommand::Chat { + message: varc("hi") + }), vec![0x02, 0x02, 0x68, 0x69] ); @@ -500,26 +589,27 @@ fn golden_authenticate_nested() { // [0x01] is_host = true // [0x00] is_ready = false // [0x00] users HashMap len 0 - let packet = ServerCommand::Authenticate(Ok((user(1, "alice", true), Some(client_room_state())))); + let packet = + ServerCommand::Authenticate(Ok((user(1, "alice", true), Some(client_room_state())))); assert_eq!( encode_bin(&packet), vec![ - 0x01, // ServerCommand::Authenticate - 0x01, // Result::Ok + 0x01, // ServerCommand::Authenticate + 0x01, // Result::Ok 0x01, 0x00, 0x00, 0x00, // UserInfo.id = 1 0x05, 0x61, 0x6C, 0x69, 0x63, 0x65, // "alice" - 0x01, // monitor = true - 0x01, // Option::Some + 0x01, // monitor = true + 0x01, // Option::Some 0x03, 0x61, 0x62, 0x63, // RoomId "abc" - 0x00, // RoomState::SelectChart - 0x01, // Option::Some + 0x00, // RoomState::SelectChart + 0x01, // Option::Some 0x03, 0x00, 0x00, 0x00, // chart = 3 - 0x01, // live - 0x00, // locked - 0x01, // cycle - 0x01, // is_host - 0x00, // is_ready - 0x00, // users (empty HashMap) + 0x01, // live + 0x00, // locked + 0x01, // cycle + 0x01, // is_host + 0x00, // is_ready + 0x00, // users (empty HashMap) ] ); } diff --git a/phira-plugin-sdk/src/lib.rs b/phira-plugin-sdk/src/lib.rs index 364a5f1cb..5d325eac9 100644 --- a/phira-plugin-sdk/src/lib.rs +++ b/phira-plugin-sdk/src/lib.rs @@ -7,7 +7,7 @@ //! # Usage //! //! ```ignore -//! phira_plugin_sdk::wit_bindgen!("phira-plugin-v2"); +//! phira_plugin_sdk::wit_bindgen!("phira-plugin-v3"); //! //! struct MyPlugin; //! impl PhiraPluginV2 for MyPlugin { @@ -35,7 +35,7 @@ /// /// Usage: /// ```ignore -/// phira_plugin_sdk::wit_bindgen!("phira-plugin-v2"); +/// phira_plugin_sdk::wit_bindgen!("phira-plugin-v3"); /// ``` #[macro_export] macro_rules! wit_bindgen { From 11ed223604a4e0477e75a8661b440f1353ffc810 Mon Sep 17 00:00:00 2001 From: ExplodingKonjac Date: Sun, 4 Oct 2026 15:52:37 +0800 Subject: [PATCH 2/3] fix: address reviewer regressions --- .../src/persistence/wal/mod.rs | 22 +++++++++++++++---- phira-mp-plus-server/src/plugin.rs | 16 +++++--------- .../src/room_actor/handler.rs | 3 +++ phira-mp-plus-server/src/server/disconnect.rs | 8 +++++++ phira-mp-plus-server/src/session.rs | 7 ------ phira-mp-plus-server/src/session_room.rs | 11 +++++----- phira-mp/phira-mp-common/src/bin.rs | 6 +++-- phira-mp/phira-mp-common/src/lib.rs | 7 ++++++ .../phira-mp-common/tests/protocol_golden.rs | 8 +++++++ 9 files changed, 59 insertions(+), 29 deletions(-) diff --git a/phira-mp-plus-server/src/persistence/wal/mod.rs b/phira-mp-plus-server/src/persistence/wal/mod.rs index c1e125308..21df3fb29 100644 --- a/phira-mp-plus-server/src/persistence/wal/mod.rs +++ b/phira-mp-plus-server/src/persistence/wal/mod.rs @@ -1245,12 +1245,26 @@ impl PersistenceWal { if let Some(last) = lines.pop() { if !last.is_empty() { match serde_json::from_slice::(last) { - Ok(frame) if frame.verify().is_ok() && frame.ver <= WAL_FORMAT_VERSION => { - lines.push(last); - } - _ => { + Err(_) => { has_truncated = true; } + Ok(frame) => { + if frame.ver > WAL_FORMAT_VERSION { + self.mark_degraded(DEGRADED_CORRUPTION); + return Err(format!( + "WAL {} final frame uses unsupported format version {}", + self.path.display(), frame.ver + )); + } + if let Err(error) = frame.verify() { + self.mark_degraded(DEGRADED_CORRUPTION); + return Err(format!( + "corrupt WAL {} final frame: {error}", + self.path.display() + )); + } + lines.push(last); + } } } } diff --git a/phira-mp-plus-server/src/plugin.rs b/phira-mp-plus-server/src/plugin.rs index dbde0d8f0..9695c5f82 100644 --- a/phira-mp-plus-server/src/plugin.rs +++ b/phira-mp-plus-server/src/plugin.rs @@ -1055,19 +1055,13 @@ impl PluginManager { { if let Ok(tx_guard) = self.plugin_tcp_tx.try_lock() { if let Some(ref tx) = *tx_guard { - let (reply, rx) = std::sync::mpsc::channel(); - let _ = tx.try_send(crate::plugin_tcp::PluginTcpCommand::RemovePlugin { + let (reply, _rx) = std::sync::mpsc::channel(); + if tx.try_send(crate::plugin_tcp::PluginTcpCommand::RemovePlugin { plugin_id: plugin_name.clone(), reply, - }); - // The TCP actor uses a synchronous reply for WIT host - // calls. Wait on it off the async runtime so plugin - // reload cannot block a Tokio worker thread. - let _ = tokio::time::timeout( - std::time::Duration::from_secs(5), - tokio::task::spawn_blocking(move || rx.recv()), - ) - .await; + }).is_err() { + warn!(plugin = %plugin_name, "TCP cleanup command could not be queued during plugin removal"); + } } } } diff --git a/phira-mp-plus-server/src/room_actor/handler.rs b/phira-mp-plus-server/src/room_actor/handler.rs index 2703d5da2..fc780d2e2 100644 --- a/phira-mp-plus-server/src/room_actor/handler.rs +++ b/phira-mp-plus-server/src/room_actor/handler.rs @@ -1319,6 +1319,9 @@ impl RoomCommandHandler { let as_ = ctx.expect_actor_state(); as_.player_data.remove(target_id); as_.display_names.remove(target_id); + as_.state.members.users.retain(|id| *id != *target_id); + as_.state.members.monitors.retain(|id| *id != *target_id); + as_.state.progress_subscribers.remove(target_id); // Host transfer when the kicked user was the host (same // choke-point rule as RemoveUser): hand the host to the next // remaining user, or revert to the system host for an empty diff --git a/phira-mp-plus-server/src/server/disconnect.rs b/phira-mp-plus-server/src/server/disconnect.rs index bdc2112e8..f2c55669a 100644 --- a/phira-mp-plus-server/src/server/disconnect.rs +++ b/phira-mp-plus-server/src/server/disconnect.rs @@ -68,6 +68,14 @@ pub(crate) async fn run_admin_kick_user( .remove_user(state, &room.id.to_string(), target_id, None, None) .await .map_err(|error| format!("kick room removal failed: {error}"))?; + if !user.monitor.load(std::sync::atomic::Ordering::Relaxed) { + state + .dispatch_plugin_event(crate::plugin::PluginEvent::RoomLeave { + user_id: target_id, + room_id: room.id.to_string(), + }) + .await; + } } let target_session = { diff --git a/phira-mp-plus-server/src/session.rs b/phira-mp-plus-server/src/session.rs index 5f9315784..6b89242ab 100644 --- a/phira-mp-plus-server/src/session.rs +++ b/phira-mp-plus-server/src/session.rs @@ -3011,13 +3011,6 @@ impl Session { }), ) .await?; - if stream.version() != phira_mp_common::PROTOCOL_VERSION { - return Err(anyhow!( - "unsupported Phira-mp protocol version {}; expected {}", - stream.version(), - phira_mp_common::PROTOCOL_VERSION - )); - } let monitor_task_handle = tokio::spawn({ let server_clone = Arc::clone(&server_clone); async move { diff --git a/phira-mp-plus-server/src/session_room.rs b/phira-mp-plus-server/src/session_room.rs index 547b14c33..a0591ce49 100644 --- a/phira-mp-plus-server/src/session_room.rs +++ b/phira-mp-plus-server/src/session_room.rs @@ -663,17 +663,13 @@ pub async fn join_room( ) .await .map_err(|e| anyhow!("{}", tr(e)))?; - // Actor AddUser 已提交成员——从现在起,任何提前取消都必须触发 Drop 补偿。 + // Actor AddUser 已提交成员——从现在起,任何提前取消都必须触发 Drop 补偿. join_guard.mark_committed(); // NOTE: after the actor AddUser commits, `deadline`(commit)不再是预检查。 // 后续 flush 使用 `response_deadline`(PMP45 P0-I)的 remaining-budget // 超时 → close_uncertain + bail(P0-D uncertain-after-commit),绝不普通 // bail——那会让用户已提交而客户端被误导。 - // The actor command attached the connection and committed membership as a - // single serialized operation. - join_guard.disarm(); - info!( user = user.id, room = id.to_string(), @@ -693,6 +689,11 @@ pub async fn join_room( tracing::debug!(user = user.id, room = %room.id, became_host, "join became_host"); *room_guard = Some(Arc::clone(&room)); + // The actor command attached the connection and committed membership as a + // single serialized operation. Keep the compensation guard armed until + // the user-to-room binding is also installed; cancellation before this + // point must remove the actor membership and registry entry. + join_guard.disarm(); // 清除进行中游戏加入确认标记 user.join_pending_game.write().await.take(); drop(room_guard); diff --git a/phira-mp/phira-mp-common/src/bin.rs b/phira-mp/phira-mp-common/src/bin.rs index eec30ea9d..3c611ac28 100644 --- a/phira-mp/phira-mp-common/src/bin.rs +++ b/phira-mp/phira-mp-common/src/bin.rs @@ -50,10 +50,12 @@ impl<'a> BinaryReader<'a> { let mut result = 0; let mut shift = 0; loop { - if shift >= 64 { + let byte = self.read::()?; + if shift >= 64 + || (shift == 63 && (byte & 0x7e != 0 || byte & 0x80 != 0)) + { return Err(anyhow!("ULEB128 value is too large")); } - let byte = self.read::()?; result |= ((byte & 0x7f) as u64) << shift; if byte & 0x80 == 0 { break Ok(result); diff --git a/phira-mp/phira-mp-common/src/lib.rs b/phira-mp/phira-mp-common/src/lib.rs index edfb3b1cf..24969a414 100644 --- a/phira-mp/phira-mp-common/src/lib.rs +++ b/phira-mp/phira-mp-common/src/lib.rs @@ -137,6 +137,13 @@ mod stream_impl { } else { read.read_u8().await? }; + if version != crate::PROTOCOL_VERSION { + return Err(anyhow!( + "unsupported Phira-mp protocol version {}; expected {}", + version, + crate::PROTOCOL_VERSION + )); + } let (send_tx, mut send_rx) = mpsc::channel(1024); let send_tx = Arc::new(StreamSender { tx: send_tx }); diff --git a/phira-mp/phira-mp-common/tests/protocol_golden.rs b/phira-mp/phira-mp-common/tests/protocol_golden.rs index bc442e7f0..7c9bae993 100644 --- a/phira-mp/phira-mp-common/tests/protocol_golden.rs +++ b/phira-mp/phira-mp-common/tests/protocol_golden.rs @@ -470,6 +470,14 @@ fn round_trip_all_server_commands() { round_trip_ok(&ServerCommand::UserVisit(77)); } +#[test] +fn rejects_overflowing_uleb_lengths() { + // Ten-byte ULEB128 with a payload bit outside u64 must be rejected rather + // than silently truncated at the shift-63 boundary. + let malformed = [0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x02]; + assert!(decode_packet::>(&malformed).is_err()); +} + // ── 3. field layout golden (exact byte arrays) ───────────────────────────────── // Official-protocol wire-format pins (PMP44 §27/§33). Each byte is load-bearing. From 9ce52a83bb3a6f9fa71a0c7588e2e342f103a5ff Mon Sep 17 00:00:00 2001 From: ExplodingKonjac Date: Sun, 4 Oct 2026 16:17:37 +0800 Subject: [PATCH 3/3] fix(ci): skip auto-patch on pull requests --- .github/workflows/build.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 39108d17b..ca90eefd8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -30,6 +30,14 @@ jobs: shell: bash run: | set -euo pipefail + # Pull requests check the synthetic merge ref in detached HEAD. + # Version/lockfile auto-patching is a main-branch operation; on PRs + # simply pass the checked-out merge SHA to downstream jobs instead of + # attempting to rebase and push from a detached checkout. + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + echo "build_ref=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + exit 0 + fi FULL_VERSION=$(grep '^version = ' phira-mp-plus-server/Cargo.toml | sed 's/.*"\(.*\)"/\1/') MAJOR=$(echo "$FULL_VERSION" | cut -d. -f1) MINOR=$(echo "$FULL_VERSION" | cut -d. -f2) @@ -144,4 +152,3 @@ jobs: name: ${{ matrix.artifact }} path: ${{ matrix.artifact-path }} if-no-files-found: error -