Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .spec/knowledge/features/rust-entity-chat-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ ADR-056:Rust 宿主是接力交付面,只托管与传输。Room 世界是 Ru
- **Room 网线**:loopback WebSocket。准入/重连发送 Runtime `BuildFullSnapshot`(含 `stateBlocks`);每 Tick 把 `BuildDelta` 字节广播给本 Room 连接;同一 `connectionId` 可有多个观察者(Playwright + harness),后连者不得顶掉先连者的 egress。顶号先发 `ConnectionSuperseded` 再关旧连接。S8 证据 `connectionSupersededReceived` 只来自旧 `RoomClient` 收帧,不得用宿主 `takeover` 布尔冒充。S3 在 101 条 `chat.input` 之前挂上 `c-browser` Room WS;`playwrightRan` 只在浏览器真正从网线收到 Room 帧时为 true。
- **解析 / 查询**:`ResolveByNetEntityId` 接受 Runtime 32-hex 与 C-1 u64;HostEntry 把 Runtime `OkEntity`(无 Binding)补成列出的五元组。S5 unauthorized 走声明过的 claim-scoped `EntityIdentity.claimedMark`(`restrictedFlag` 未声明 → `RequestError`,不得冒充 Unauthorized)。
- **Tick 分批**:Runtime `ChatCommandRuntime.RunTick` 经 `ChatIngressWorld` 默认 `EcsBudget.MaxChangeEntries=128`。每条 `chat.input` 写两个 ChatComponent 字段,单 Tick 最多 64 条;超过则 `Command reservation budget exceeded`、Runtime `_faulted`、`BuildDelta` 为 `changedBlocks:[]`。宿主按 `MAX_CHAT_INPUTS_PER_TICK` 穿插 `tickFrame`,不自建第二份事件队列。
- **Client Bot**:S6 发言由 suite spawn `Lumio.Client.Bot.Host`(`LUMIO_BOT_HOST` / `LUMIO_CLIENT_ROOT` 或仓根相对 `LumioClient` 兄弟,缺失 BLOCKED)。Bot 进程经 Client Timer Manager drain NativeCore `tickFrame`(`native-kernel/tickFrame`,utteranceTicks 含 5/10/15),再把 `chat.input` 发上 Room WS。禁止 `host.admit_chat_input` 冒充 101 条 Bot 发言,禁止把常量 `[5,10,15]` 写成证据。生成的 hook 工程写在证据目录时必须自带 `Directory.Build.props`(`TreatWarningsAsErrors=false`),不得继承 Game 仓 analyzer;编译失败要把 **stdout+stderr** 写入 BLOCKED。
- **Client Bot**:S6 发言由 suite spawn `Lumio.Client.Bot.Host`(`LUMIO_BOT_HOST` / `LUMIO_CLIENT_ROOT` 或仓根相对 `LumioClient` 兄弟,缺失 BLOCKED)。Bot 进程经 Client Timer Manager drain NativeCore `tickFrame`(`native-kernel/tickFrame`,utteranceTicks 含 5/10/15),再把 `chat.input` 发上 Room WS。写出 `timer-trace.json` 后仍保持 Room 套接字,直到 suite 在 Room 上观察到 submitted `chat.event`(或显式写 `release.flag`)才 Dispose / `Environment.Exit`。suite 按宿主 `pending_wire_chat_inputs`(Room 网线入站,不是 `sent.txt`)穿插 `tickFrame`,余量另 tick,不得把 `sent.txt` / `Sleep(400)` / 提前 Exit 当成 SUCCESS。禁止 `host.admit_chat_input` 冒充 101 条 Bot 发言,禁止把常量 `[5,10,15]` 写成证据。生成的 hook 工程写在证据目录时必须自带 `Directory.Build.props`(`TreatWarningsAsErrors=false`),不得继承 Game 仓 analyzer;编译失败要把 **stdout+stderr** 写入 BLOCKED。
- **Persist**:`CapturePersist` / `RestorePersist` 走 Runtime 公开面(`RestorePersist` 的第二参是 `ReadOnlyMemory<byte>`)。默认 `MaxSnapshotBytes=4096` 只能装下约 6 个聊天实体;101 实体 Capture 为 Retryable 时不得把 `restoredWindow: 0` / `processB=null` 写成 S7 ok。
- **发现**:外部产物经 `LUMIO_*` 环境变量与仓根相对路径;缺失即 BLOCKED,不硬编码开发机绝对路径。
- **复跑**:`lumio-entity-chat-replay` 两轮;`manifest.conclusion=SUCCESS` 只在 Game `verify-evidence.mjs` oracle 通过之后写。`--restore-snapshot` 供 S7 进程 B 单独启 CLR 恢复。
Expand Down
16 changes: 16 additions & 0 deletions modules/process/src/entity_chat/bot_startup_hook/StartupHook.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,20 @@ private static int Run(string specPath)
}

WriteTrace(spec, invoked && sent == n, source, ticks, sent, null);
if (!string.IsNullOrWhiteSpace(spec.ReleasePath))
{
DateTime deadline = DateTime.UtcNow.AddMinutes(2);
while (!File.Exists(spec.ReleasePath))
{
if (DateTime.UtcNow >= deadline)
{
break;
}

Thread.Sleep(50);
}
}

return invoked && sent == n ? 0 : 2;
}
finally
Expand Down Expand Up @@ -198,6 +212,8 @@ private sealed class FleetSpec

public string SentPath { get; set; } = "";

public string ReleasePath { get; set; } = "";

public ulong AdvanceToTick { get; set; }

public List<BotSpec> Bots { get; set; } = new List<BotSpec>();
Expand Down
90 changes: 76 additions & 14 deletions modules/process/src/entity_chat/bots.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

use std::fs::File;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::process::{Child, Command, Stdio};
use std::thread;
use std::time::{Duration, Instant};

Expand All @@ -21,6 +21,45 @@ pub struct ClientBotTrace {
pub blocked: Option<String>,
}

/// Live Bot.Host process whose Room sockets stay open until [`ClientBotFleet::release`].
pub struct ClientBotFleet {
pub trace: ClientBotTrace,
child: Option<Child>,
release_path: PathBuf,
}

impl ClientBotFleet {
/// Lets the hook dispose Room sockets and exit after Room observed chat.event.
pub fn release(mut self) {
self.release_mut();
}

fn release_mut(&mut self) {
let _ = std::fs::write(&self.release_path, "release\n");
let Some(mut child) = self.child.take() else {
return;
};
let deadline = Instant::now() + Duration::from_secs(15);
loop {
match child.try_wait() {
Ok(None) if Instant::now() >= deadline => {
let _ = child.kill();
let _ = child.wait();
break;
}
Ok(None) => thread::sleep(Duration::from_millis(50)),
Ok(Some(_)) | Err(_) => break,
}
}
}
}

impl Drop for ClientBotFleet {
fn drop(&mut self) {
self.release_mut();
}
}

/// Env lookup used by discovery. Process env in production; map in unit tests.
pub trait BotHostEnv {
/// Reads one environment variable.
Expand Down Expand Up @@ -126,9 +165,9 @@ pub fn run_client_bot_fleet<F>(
out_dir: &Path,
dotnet: &str,
mut on_progress: F,
) -> Result<ClientBotTrace, String>
) -> Result<ClientBotFleet, String>
where
F: FnMut(u32),
F: FnMut(),
{
std::fs::create_dir_all(out_dir).map_err(|error| error.to_string())?;
let host = ensure_bot_host_executable(bot_host, dotnet)?;
Expand All @@ -137,11 +176,13 @@ where
let spec_path = out_dir.join("fleet-spec.json");
let trace_path = out_dir.join("timer-trace.json");
let sent_path = out_dir.join("sent.txt");
let release_path = out_dir.join("release.flag");
let spec = json!({
"roomUri": room_uri,
"engineNative": engine_native.display().to_string(),
"tracePath": trace_path.display().to_string(),
"sentPath": sent_path.display().to_string(),
"releasePath": release_path.display().to_string(),
"advanceToTick": 15,
"bots": envelopes.iter().map(|(connection, envelope)| {
json!({
Expand Down Expand Up @@ -174,7 +215,7 @@ where
.map_err(|error| format!("BLOCKED: spawn Lumio.Client.Bot.Host: {error}"))?;
let deadline = Instant::now() + Duration::from_secs(60);
loop {
on_progress(read_sent(&sent_path));
on_progress();
if trace_path.is_file() {
break;
}
Expand Down Expand Up @@ -202,9 +243,20 @@ where
}
thread::sleep(Duration::from_millis(50));
}
let _ = child.wait();
on_progress(read_sent(&sent_path));
parse_trace(&trace_path)
on_progress();
let trace = match parse_trace(&trace_path) {
Ok(trace) => trace,
Err(reason) => {
let _ = child.kill();
let _ = child.wait();
return Err(reason);
}
};
Ok(ClientBotFleet {
trace,
child: Some(child),
release_path,
})
}

fn process_repo_root() -> PathBuf {
Expand Down Expand Up @@ -384,13 +436,6 @@ fn bot_host_command(dotnet: &str, host: &Path) -> Command {
}
}

fn read_sent(path: &Path) -> u32 {
std::fs::read_to_string(path)
.ok()
.and_then(|text| text.trim().parse().ok())
.unwrap_or(0)
}

fn tail_logs(stdout_path: &Path, stderr_path: &Path) -> String {
let stdout = std::fs::read_to_string(stdout_path).unwrap_or_default();
let stderr = std::fs::read_to_string(stderr_path).unwrap_or_default();
Expand Down Expand Up @@ -449,6 +494,7 @@ fn parse_trace(path: &Path) -> Result<ClientBotTrace, String> {
mod tests {
use super::{
discover_bot_host_in, hook_compile_failure_text, write_hook_isolation_files, BotHostEnv,
ClientBotFleet, ClientBotTrace,
};
use std::collections::HashMap;
use std::fs;
Expand Down Expand Up @@ -608,6 +654,22 @@ internal static class Warn
);
}

#[test]
fn fleet_release_writes_release_path() {
let tmp = tempfile::tempdir().expect("tmp");
let release_path = tmp.path().join("release.flag");
let fleet = ClientBotFleet {
trace: ClientBotTrace::default(),
child: None,
release_path: release_path.clone(),
};
fleet.release();
assert!(
release_path.is_file(),
"suite release must create the hook wait file"
);
}

#[test]
fn hook_compile_blocked_text_includes_stdout_when_stderr_empty() {
let text = hook_compile_failure_text(b"error CA1869: cache JsonSerializerOptions\n", b"");
Expand Down
18 changes: 15 additions & 3 deletions modules/process/src/entity_chat/host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ use super::envelope::{
};
use super::runtime::BoundEntityKind;
use super::runtime::{
AttributeQueryScope, ChatOperation, PersistRecord, QueryResult, RebindMode, RuntimeAdmit,
RuntimeBinding, RuntimeQuery, RuntimeSurface, RuntimeTick,
AttributeQueryScope, ChatOpKind, ChatOperation, PersistRecord, QueryResult, RebindMode,
RuntimeAdmit, RuntimeBinding, RuntimeQuery, RuntimeSurface, RuntimeTick,
};
use super::wire::{RoomListener, WireEvent, WireSender};

Expand Down Expand Up @@ -154,6 +154,7 @@ struct Inner {
expire_watch: HashMap<KernelHandle, String>,
pending_egress: HashMap<String, Vec<WireSender>>,
tick_id: u64,
wire_chat_pending: u64,
}

enum OwnerWork {
Expand Down Expand Up @@ -213,6 +214,7 @@ impl EntityChatHost {
expire_watch: HashMap::new(),
pending_egress: HashMap::new(),
tick_id: 0,
wire_chat_pending: 0,
};
if inner
.kernel
Expand Down Expand Up @@ -358,6 +360,12 @@ impl EntityChatHost {
self.on_owner(move |inner| inner.try_resolve_by_net_entity_id(&room_id, &net_entity_id))
}

/// Chat.input frames admitted from Room WS and not yet applied by a tick.
#[must_use]
pub fn pending_wire_chat_inputs(&self) -> usize {
self.on_owner(move |inner| usize::try_from(inner.wire_chat_pending).unwrap_or(usize::MAX))
}

/// Count live Room WS observers for a connection (harness wait).
#[must_use]
pub fn wire_observer_count(&self, connection_id: String) -> usize {
Expand Down Expand Up @@ -625,6 +633,7 @@ impl Inner {
if !tick.ok {
return tick;
}
self.wire_chat_pending = 0;
let frames = self
.runtime
.build_delta(room_id, tick.applied_tick, tick.revision);
Expand Down Expand Up @@ -699,7 +708,10 @@ impl Inner {
text,
} => {
if let Ok(envelope) = parse_input_command_json(&text) {
let _ = self.admit_chat_input(&connection_id, &envelope);
let admitted = self.admit_chat_input(&connection_id, &envelope);
if admitted.kind == ChatOpKind::Admitted {
self.wire_chat_pending = self.wire_chat_pending.saturating_add(1);
}
}
}
WireEvent::Closed { .. } => {
Expand Down
2 changes: 1 addition & 1 deletion modules/process/src/entity_chat/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ pub use admission::{
generate_keys, issue_admission_credential, issue_bot_tool_credential, verify_admission,
AdmissionPayload, Ed25519KeyPair,
};
pub use bots::{discover_bot_host, run_client_bot_fleet, ClientBotTrace};
pub use bots::{discover_bot_host, run_client_bot_fleet, ClientBotFleet, ClientBotTrace};
pub use clr::{ClrGameplay, ClrGameplayConfig};
pub use discover::{discover, ReplayArtifacts};
pub use envelope::{
Expand Down
Loading
Loading