mirror of
https://github.com/pjh456/OxideJS.git
synced 2026-10-23 18:21:13 +08:00
feat: 远程分片加绑定门与认证门,worker 无特权口径落文档
This commit is contained in:
Generated
+20
-20
@@ -1160,7 +1160,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "oxide_builtins"
|
||||
version = "0.15.19"
|
||||
version = "0.15.20"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"fast-float",
|
||||
@@ -1185,7 +1185,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "oxide_bytecode"
|
||||
version = "0.15.19"
|
||||
version = "0.15.20"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"oxide_log",
|
||||
@@ -1193,7 +1193,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "oxide_cfg"
|
||||
version = "0.15.19"
|
||||
version = "0.15.20"
|
||||
dependencies = [
|
||||
"oxide_bytecode",
|
||||
"oxide_emit",
|
||||
@@ -1204,7 +1204,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "oxide_cli"
|
||||
version = "0.15.19"
|
||||
version = "0.15.20"
|
||||
dependencies = [
|
||||
"ansi_term",
|
||||
"bumpalo",
|
||||
@@ -1228,7 +1228,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "oxide_code_cache"
|
||||
version = "0.15.19"
|
||||
version = "0.15.20"
|
||||
dependencies = [
|
||||
"lru",
|
||||
"oxide_bytecode",
|
||||
@@ -1237,7 +1237,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "oxide_compiler"
|
||||
version = "0.15.19"
|
||||
version = "0.15.20"
|
||||
dependencies = [
|
||||
"oxide_bytecode",
|
||||
"oxide_cfg",
|
||||
@@ -1257,7 +1257,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "oxide_dce"
|
||||
version = "0.15.19"
|
||||
version = "0.15.20"
|
||||
dependencies = [
|
||||
"oxide_bytecode",
|
||||
"oxide_cfg",
|
||||
@@ -1271,7 +1271,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "oxide_emit"
|
||||
version = "0.15.19"
|
||||
version = "0.15.20"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"oxc_allocator",
|
||||
@@ -1286,7 +1286,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "oxide_engine"
|
||||
version = "0.15.19"
|
||||
version = "0.15.20"
|
||||
dependencies = [
|
||||
"oxide_bytecode",
|
||||
"oxide_compiler",
|
||||
@@ -1298,7 +1298,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "oxide_ir"
|
||||
version = "0.15.19"
|
||||
version = "0.15.20"
|
||||
dependencies = [
|
||||
"oxide_bytecode",
|
||||
"oxide_log",
|
||||
@@ -1307,7 +1307,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "oxide_kernel"
|
||||
version = "0.15.19"
|
||||
version = "0.15.20"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"dashmap",
|
||||
@@ -1321,7 +1321,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "oxide_liveness"
|
||||
version = "0.15.19"
|
||||
version = "0.15.20"
|
||||
dependencies = [
|
||||
"oxide_bytecode",
|
||||
"oxide_cfg",
|
||||
@@ -1333,7 +1333,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "oxide_log"
|
||||
version = "0.15.19"
|
||||
version = "0.15.20"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
@@ -1342,11 +1342,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "oxide_net"
|
||||
version = "0.15.19"
|
||||
version = "0.15.20"
|
||||
|
||||
[[package]]
|
||||
name = "oxide_parser"
|
||||
version = "0.15.19"
|
||||
version = "0.15.20"
|
||||
dependencies = [
|
||||
"oxc_allocator",
|
||||
"oxc_ast",
|
||||
@@ -1361,7 +1361,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "oxide_regalloc"
|
||||
version = "0.15.19"
|
||||
version = "0.15.20"
|
||||
dependencies = [
|
||||
"oxide_bytecode",
|
||||
"oxide_cfg",
|
||||
@@ -1377,7 +1377,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "oxide_runtime_api"
|
||||
version = "0.15.19"
|
||||
version = "0.15.20"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
@@ -1390,7 +1390,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "oxide_test262"
|
||||
version = "0.15.19"
|
||||
version = "0.15.20"
|
||||
dependencies = [
|
||||
"oxide_bytecode",
|
||||
"oxide_compiler",
|
||||
@@ -1410,7 +1410,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "oxide_types"
|
||||
version = "0.15.19"
|
||||
version = "0.15.20"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"dashmap",
|
||||
@@ -1423,7 +1423,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "oxide_vm"
|
||||
version = "0.15.19"
|
||||
version = "0.15.20"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"chrono",
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.15.19"
|
||||
version = "0.15.20"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
rust-version = "1.80"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! test262 runner 运行配置:`RunConfig` 结构、CLI 解析(`parse`)与用法输出(`usage`)。
|
||||
//! 8 字段与 3 关联函数放宽为 `pub(crate)`;`usage` 全文即 `--help` 用户可见输出,逐字保留。
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 运行配置:test262 根目录、路径过滤器及各选项开关。
|
||||
@@ -46,8 +47,41 @@ impl RunConfig {
|
||||
(self.supervise || self.remote_supervise.is_some()) && !is_chunk_child
|
||||
}
|
||||
|
||||
/// 解析 `--remote-supervise` 端点:缺省补 loopback、解析期拒绝非 loopback。
|
||||
///
|
||||
/// # 步骤
|
||||
/// 1. `:port` 形态补 `127.0.0.1` 前缀,裸端口(无冒号)补 `127.0.0.1:` 前缀。
|
||||
/// 2. 解析为 `SocketAddr`;失败(含主机名形态)返回错误。
|
||||
/// 3. 非 loopback 地址(0.0.0.0、局域网 IP)返回错误。
|
||||
///
|
||||
/// # 边界与前提
|
||||
/// - 端点只接受 `[IP]:port` 形态;主机名不解析(无法在解析期验证 loopback)。
|
||||
/// - 比 server 的「0.0.0.0 可配置但不受支持」更严:worker 执行不可信测试
|
||||
/// 内容,非 loopback 绑定会把窗口指派面(含本地路径执行)暴露到网络。
|
||||
///
|
||||
/// # 副作用
|
||||
/// - 无(纯函数,返回归一化后的端点文本)。
|
||||
fn parse_remote_supervise_endpoint(raw: &str) -> Result<String, String> {
|
||||
let normalized = if raw.starts_with(':') {
|
||||
format!("127.0.0.1{raw}")
|
||||
} else if !raw.contains(':') {
|
||||
format!("127.0.0.1:{raw}")
|
||||
} else {
|
||||
raw.to_string()
|
||||
};
|
||||
let addr: SocketAddr = normalized
|
||||
.parse()
|
||||
.map_err(|_| format!("invalid remote-supervise endpoint: {raw} (expected [IP]:port, loopback only)"))?;
|
||||
if !addr.ip().is_loopback() {
|
||||
return Err(format!(
|
||||
"remote-supervise endpoint must bind to loopback only: {raw} (workers execute untrusted test content; non-loopback binding exposes the window assignment surface to the network)"
|
||||
));
|
||||
}
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
/// 解析命令行参数为运行配置;未知选项、参数过多或 `--remote-supervise`
|
||||
/// 缺实参 / worker 数非法返回错误。
|
||||
/// 缺实参 / 端点非 loopback / worker 数非法返回错误。
|
||||
pub(crate) fn parse(args: &[String]) -> Result<Self, String> {
|
||||
let mut config = Self::new();
|
||||
let mut positional = Vec::new();
|
||||
@@ -62,17 +96,20 @@ impl RunConfig {
|
||||
"--worker" => config.worker = true,
|
||||
"--no-fail-list" => config.no_fail_list = true,
|
||||
// --remote-supervise 消费后两个实参:端点文本与 worker 数。
|
||||
// 端点解析期归一化:缺省补 loopback,非 loopback 拒绝。
|
||||
"--remote-supervise" => {
|
||||
let endpoint = args
|
||||
let endpoint_raw = args
|
||||
.get(i + 1)
|
||||
.ok_or_else(|| format!("--remote-supervise requires an endpoint\n\n{}", Self::usage()))?;
|
||||
let endpoint = Self::parse_remote_supervise_endpoint(endpoint_raw)
|
||||
.map_err(|e| format!("{e}\n\n{}", Self::usage()))?;
|
||||
let workers_str = args
|
||||
.get(i + 2)
|
||||
.ok_or_else(|| format!("--remote-supervise requires a worker count\n\n{}", Self::usage()))?;
|
||||
let workers = workers_str
|
||||
.parse::<usize>()
|
||||
.map_err(|_| format!("invalid worker count: {workers_str}\n\n{}", Self::usage()))?;
|
||||
config.remote_supervise = Some((endpoint.clone(), workers));
|
||||
config.remote_supervise = Some((endpoint, workers));
|
||||
i += 2;
|
||||
}
|
||||
"--help" | "-h" => return Err(Self::usage()),
|
||||
@@ -122,8 +159,10 @@ impl RunConfig {
|
||||
\x20 Combines with [path-filter]: the filter is applied per-test inside each child window.\n\
|
||||
--worker Run as a remote shard worker: connect to the supervisor endpoint and execute assigned\n\
|
||||
\x20 test windows in-process, sending heartbeat/fail/scene/DONE frames over the connection.\n\
|
||||
\x20 Mutually exclusive with --supervise.\n\
|
||||
--remote-supervise Run as a remote supervisor: bind <endpoint> (host:port) and execute test\n\
|
||||
\x20 Authenticates with the token from OXIDE_TEST262_WORKER_TOKEN (out-of-band from the\n\
|
||||
\x20 supervisor's foreground print). Mutually exclusive with --supervise.\n\
|
||||
--remote-supervise Run as a remote supervisor: bind <endpoint> (loopback [IP]:port; a bare port\n\
|
||||
\x20 defaults to 127.0.0.1, non-loopback addresses are rejected) and execute test\n\
|
||||
\x20 windows on <workers> remote workers, sending assign frames and receiving\n\
|
||||
\x20 heartbeat/fail/scene/DONE frames over the accepted connections. A worker\n\
|
||||
\x20 disconnect is recovered as a child-process death. Mutually exclusive with\n\
|
||||
@@ -143,6 +182,7 @@ impl RunConfig {
|
||||
\x20 OXIDE_TEST262_CHILD_CHUNK marks a spawned child as chunk worker (any value)\n\
|
||||
\x20 OXIDE_TEST262_ALLOW_FAIL_EXIT child may exit 1 on fail (any value)\n\
|
||||
\x20 OXIDE_TEST262_WORKER_ENDPOINT supervisor endpoint for --worker mode (host:port)\n\
|
||||
\x20 OXIDE_TEST262_WORKER_TOKEN worker auth token for --worker mode (out-of-band from the supervisor's foreground print)\n\
|
||||
\x20 OXIDE_TEST262_KERNEL_BATCH kernel rebuilds every N tests (default 256, clamped to the 512 realm bound)\n\
|
||||
\x20 OXIDE_TEST262_LOG_LEVEL runner log level (default info)\n\
|
||||
\x20 OXIDE_SKIP_UNTIL skip test indexes below N (chunking/supervise resume)\n\
|
||||
@@ -217,6 +257,34 @@ mod tests {
|
||||
assert!(err.contains("invalid worker count"), "应提示 worker 数非法:{err}");
|
||||
}
|
||||
|
||||
/// --remote-supervise 端点缺省补 loopback:裸端口与 `:port` 形态归一化为
|
||||
/// 127.0.0.1 前缀,显式 loopback 地址原样保留。
|
||||
#[test]
|
||||
fn parse_remote_supervise_endpoint_defaults_loopback() {
|
||||
let cfg = RunConfig::parse(&args(&["--remote-supervise", ":4243", "2"])).expect(":port 形态应补 loopback");
|
||||
assert_eq!(cfg.remote_supervise, Some(("127.0.0.1:4243".to_string(), 2)));
|
||||
|
||||
let cfg = RunConfig::parse(&args(&["--remote-supervise", "4243", "2"])).expect("裸端口应补 loopback");
|
||||
assert_eq!(cfg.remote_supervise, Some(("127.0.0.1:4243".to_string(), 2)));
|
||||
|
||||
let cfg =
|
||||
RunConfig::parse(&args(&["--remote-supervise", "127.0.0.1:4243", "2"])).expect("显式 loopback 应保留");
|
||||
assert_eq!(cfg.remote_supervise, Some(("127.0.0.1:4243".to_string(), 2)));
|
||||
}
|
||||
|
||||
/// --remote-supervise 端点解析期拒绝非 loopback(0.0.0.0、局域网 IP)与
|
||||
/// 非法形态(主机名、非端口文本)。
|
||||
#[test]
|
||||
fn parse_remote_supervise_endpoint_rejects_non_loopback() {
|
||||
for raw in ["0.0.0.0:4243", "192.168.1.5:4243", "localhost:4243", "notanendpoint"] {
|
||||
let err = RunConfig::parse(&args(&["--remote-supervise", raw, "2"])).expect_err("应拒绝:{raw}");
|
||||
assert!(
|
||||
err.contains("loopback") || err.contains("invalid remote-supervise endpoint"),
|
||||
"拒绝文案应说明 loopback 口径或端点非法:{err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// --remote-supervise 与 --worker、--supervise 互斥。
|
||||
#[test]
|
||||
fn parse_remote_supervise_exclusive() {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
//!
|
||||
//! 帧约定:每帧一行 JSON,以换行符(U+000A)终止,UTF-8 编码。
|
||||
//! 两个方向的帧均由 `type` 字段区分类型(tagged 枚举,snake_case 取值):
|
||||
//! `ShardCommand` 是监督者到 worker 方向(assign / kill),`ShardEvent` 是
|
||||
//! worker 到监督者方向(heartbeat / fail / scene / done)。
|
||||
//! `ShardCommand` 是监督者到 worker 方向(assign / kill / auth_ok),
|
||||
//! `ShardEvent` 是 worker 到监督者方向(auth / heartbeat / fail / scene / done)。
|
||||
//! 帧长上限 `MAX_FRAME_BYTES`;超限帧读侧提前停止,避免无界内存增长。
|
||||
//! 畸形帧(非法 JSON、未知类型、载荷不匹配、空帧)统一返回 `ShardProtocolError`,
|
||||
//! 任何路径不 panic。
|
||||
@@ -12,12 +12,14 @@
|
||||
//! assign 帧携带窗口路径清单切片:worker 读不到监督者本机临时文件,窗口内容
|
||||
//! 必须随帧携带。
|
||||
//!
|
||||
//! 本模块是纯协议定义层,调用方(worker 模式与远程监督者)尚未接线,
|
||||
//! 豁免死代码告警。
|
||||
#![allow(dead_code)]
|
||||
//! 认证门契约:worker 连接后首帧必须是 `Auth` 事件帧(携带 token),监督者
|
||||
//! 异或判等匹配后写回 `AuthOk` 命令帧才进入窗口循环;不匹配或首帧非 `Auth`
|
||||
//! 直接断连、不回帧。读首帧带 `AUTH_READ_TIMEOUT` 读超时,半开连接不永久
|
||||
//! 占线程。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::BufRead;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -25,6 +27,9 @@ use serde::{Deserialize, Serialize};
|
||||
/// 帧长上限(字节,1 MiB):超过即判 `FrameTooLarge`,调用方应关闭连接。
|
||||
pub(crate) const MAX_FRAME_BYTES: usize = 1024 * 1024;
|
||||
|
||||
/// 认证门读超时(30 秒):半开连接不发认证帧不永久占线程或进程。
|
||||
pub(crate) const AUTH_READ_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// 协议错误:畸形帧或未知类型,统一返回、不 panic。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum ShardProtocolError {
|
||||
@@ -69,6 +74,8 @@ pub(crate) enum ShardCommand {
|
||||
},
|
||||
/// kill:无载荷。
|
||||
Kill,
|
||||
/// 认证通过确认:无载荷。监督者认证门匹配后写回,worker 据此进入主循环。
|
||||
AuthOk,
|
||||
}
|
||||
|
||||
/// 事件帧(worker 到监督者):心跳、失败记录、现场、窗口结束。
|
||||
@@ -103,13 +110,16 @@ pub(crate) enum ShardEvent {
|
||||
},
|
||||
/// 窗口结束:无载荷(窗口结束下标由 assign 帧的 wend 隐含)。
|
||||
Done,
|
||||
/// 认证:worker 连接后的首帧,携带共享 token(监督者前台打印、
|
||||
/// 用户带外注入,不经网络分发)。
|
||||
Auth { token: String },
|
||||
}
|
||||
|
||||
/// 命令帧可取的 `type` 值(须与 `ShardCommand` 变体保持同步)。
|
||||
const COMMAND_TAGS: &[&str] = &["assign", "kill"];
|
||||
const COMMAND_TAGS: &[&str] = &["assign", "kill", "auth_ok"];
|
||||
|
||||
/// 事件帧可取的 `type` 值(须与 `ShardEvent` 变体保持同步)。
|
||||
const EVENT_TAGS: &[&str] = &["heartbeat", "fail", "scene", "done"];
|
||||
const EVENT_TAGS: &[&str] = &["heartbeat", "fail", "scene", "done", "auth"];
|
||||
|
||||
/// 序列化命令为帧字符串(含结尾换行)。
|
||||
///
|
||||
@@ -194,6 +204,15 @@ impl<R: BufRead> FrameReader<R> {
|
||||
FrameReader { inner, pending: Vec::new() }
|
||||
}
|
||||
|
||||
/// 暴露底层读流(供调用方设读超时或克隆写句柄)。
|
||||
///
|
||||
/// # 注意事项
|
||||
/// - 调用方不得绕过读取器直接读底层流(余量缓冲会丢数据);
|
||||
/// 只允许经此取句柄做超时设置与写侧克隆。
|
||||
pub(crate) fn get_mut(&mut self) -> &mut R {
|
||||
&mut self.inner
|
||||
}
|
||||
|
||||
/// 读取一帧(跳过空行)。
|
||||
///
|
||||
/// # 步骤
|
||||
@@ -293,6 +312,7 @@ mod tests {
|
||||
timeout_secs: 10,
|
||||
});
|
||||
roundtrip_command(&ShardCommand::Kill);
|
||||
roundtrip_command(&ShardCommand::AuthOk);
|
||||
}
|
||||
|
||||
/// 事件 round-trip:心跳(带失败类别)、失败记录、现场(带与不带日志尾)、窗口结束。
|
||||
@@ -324,6 +344,9 @@ mod tests {
|
||||
log_tail: Some("line a\nline b".into()),
|
||||
});
|
||||
roundtrip_event(&ShardEvent::Done);
|
||||
roundtrip_event(&ShardEvent::Auth {
|
||||
token: "0123456789abcdef".into(),
|
||||
});
|
||||
}
|
||||
|
||||
/// 序列化形态:钉住精确 JSON 字符串,含 categories 键带冒号与空格的形态。
|
||||
@@ -340,6 +363,7 @@ mod tests {
|
||||
r#"{"type":"assign","wstart":0,"wend":10,"paths":["a/b.js","c/d.js"],"timeout_secs":10}"#
|
||||
);
|
||||
assert_eq!(serde_json::to_string(&ShardCommand::Kill).unwrap(), r#"{"type":"kill"}"#);
|
||||
assert_eq!(serde_json::to_string(&ShardCommand::AuthOk).unwrap(), r#"{"type":"auth_ok"}"#);
|
||||
|
||||
let mut categories = HashMap::new();
|
||||
categories.insert("vm: not callable".to_string(), 1);
|
||||
@@ -383,6 +407,13 @@ mod tests {
|
||||
r#"{"type":"scene","line":"pc=1 op=ADD frames=1","log_tail":"a\nb"}"#
|
||||
);
|
||||
assert_eq!(serde_json::to_string(&ShardEvent::Done).unwrap(), r#"{"type":"done"}"#);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&ShardEvent::Auth {
|
||||
token: "0123456789abcdef".into()
|
||||
})
|
||||
.unwrap(),
|
||||
r#"{"type":"auth","token":"0123456789abcdef"}"#
|
||||
);
|
||||
}
|
||||
|
||||
/// 非法 JSON:判 `InvalidJson`,不 panic。
|
||||
|
||||
@@ -12,10 +12,43 @@ use crate::stats::RunStats;
|
||||
use crate::tcp_window_transport::TcpWindowTransport;
|
||||
use crate::window_transport::{LocalFileWindowTransport, PollResult, WindowTransport};
|
||||
use oxide_net::Listener;
|
||||
use std::io::Read;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// 生成 worker 认证 token:读 `/dev/urandom` 32 字节转 64 位小写十六进制。
|
||||
///
|
||||
/// # 边界与前提
|
||||
/// - 非 Linux 宿主(`/dev/urandom` 缺位)或读取失败时回落时间加进程号混合熵
|
||||
/// (本宿主恒为 Linux,回落臂仅保编译)。
|
||||
fn generate_token() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
let ok = std::fs::File::open("/dev/urandom")
|
||||
.and_then(|mut file| file.read_exact(&mut bytes))
|
||||
.is_ok();
|
||||
if !ok {
|
||||
bytes = fallback_entropy();
|
||||
}
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// 非 Linux 熵回落:纳秒时间加进程号经 xorshift 混合出 32 字节。
|
||||
fn fallback_entropy() -> [u8; 32] {
|
||||
let ns = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos() as u64;
|
||||
let pid = std::process::id() as u64;
|
||||
let mut seed = ns ^ (pid << 32);
|
||||
let mut out = [0u8; 32];
|
||||
for byte in out.iter_mut() {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
*byte = (seed & 0xff) as u8;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 超时/崩溃后把 sidecar 日志的末 ~20 行打到父进程 stderr,供现场重建。
|
||||
fn print_log_tail(window_id: usize, transport: &dyn WindowTransport) {
|
||||
@@ -266,6 +299,20 @@ pub(crate) fn run_supervised(
|
||||
Some(endpoint) => {
|
||||
// 远程分支:bind 监听端点,每窗口 accept 一条 worker 连接,worker 数控制
|
||||
// 并发线程数。路径随 assign 帧携带,不写 paths_file。
|
||||
// 绑定门:与 config 解析期拒绝双保险,非 loopback 拒绝 bind——worker
|
||||
// 执行不可信测试内容,非 loopback 绑定会把窗口指派面(含本地路径
|
||||
// 执行)暴露到网络。
|
||||
let addr: SocketAddr = match endpoint.parse() {
|
||||
Ok(a) => a,
|
||||
Err(_) => {
|
||||
eprintln!("failed to parse remote supervisor endpoint {endpoint}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if !addr.ip().is_loopback() {
|
||||
eprintln!("refusing to bind non-loopback remote supervisor endpoint {endpoint}");
|
||||
return false;
|
||||
}
|
||||
let listener = match std::net::TcpListener::bind(endpoint) {
|
||||
Ok(l) => Arc::new(Listener::Tcp(l)),
|
||||
Err(err) => {
|
||||
@@ -273,6 +320,10 @@ pub(crate) fn run_supervised(
|
||||
return false;
|
||||
}
|
||||
};
|
||||
// 认证 token:启动时生成、打印前台 stderr,用户带外注入 worker 进程
|
||||
// (token 不经网络分发,与 server 独立模式同型)。
|
||||
let token = generate_token();
|
||||
eprintln!("remote supervisor worker token: {token} (inject into workers via OXIDE_TEST262_WORKER_TOKEN)");
|
||||
let bound = listener.local_addr().unwrap_or_else(|_| endpoint.clone());
|
||||
eprintln!(
|
||||
"remote supervised mode: {} window(s) of up to {window} test(s), {supervisors} worker(s), endpoint {bound}, per-test timeout {}s",
|
||||
@@ -285,6 +336,7 @@ pub(crate) fn run_supervised(
|
||||
let timeout_secs = timeout.as_secs();
|
||||
let listener_ref = &listener;
|
||||
let paths_vec_ref = &paths_vec;
|
||||
let token_ref = &token;
|
||||
std::thread::scope(|scope| {
|
||||
let handles: Vec<_> = (0..supervisors)
|
||||
.map(|_| {
|
||||
@@ -309,6 +361,7 @@ pub(crate) fn run_supervised(
|
||||
Arc::clone(listener_ref),
|
||||
(**paths_vec_ref).clone(),
|
||||
timeout_secs,
|
||||
token_ref.clone(),
|
||||
),
|
||||
);
|
||||
stats.merge(window_stats);
|
||||
|
||||
@@ -14,14 +14,19 @@
|
||||
//! 非阻塞读陷阱:`set_read_timeout(0)` 后 `WouldBlock` 必须判「无数据、仍存活」
|
||||
//! 而非断连,否则正常窗口被误杀重启;仅真实 io 错误与 EOF 判断连。残行留在
|
||||
//! `FrameReader` 余量缓冲,跨 poll 不丢。
|
||||
//!
|
||||
//! 认证门:`accept` 后、发 assign 帧前,读首帧(带读超时,半开连接不永久占
|
||||
//! 线程),必须是 `Auth` 事件帧且 token 异或判等匹配,才写回 `AuthOk` 帧进
|
||||
//! 窗口循环;不匹配或首帧非 `Auth` 直接断连、不回帧(不向攻击者泄露差异)。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::BufReader;
|
||||
use std::io::ErrorKind;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::heartbeat::Heartbeat;
|
||||
use crate::shard_protocol::{encode_command, parse_event, FrameReader, ShardCommand, ShardEvent};
|
||||
use crate::shard_protocol::{encode_command, parse_event, FrameReader, ShardCommand, ShardEvent, AUTH_READ_TIMEOUT};
|
||||
use crate::stats::RunStats;
|
||||
use crate::window_transport::{PollResult, SpawnError, WindowTransport};
|
||||
use oxide_net::{Conn, Listener};
|
||||
@@ -34,6 +39,10 @@ pub(crate) struct TcpWindowTransport {
|
||||
writer: Option<Conn>,
|
||||
/// 读侧(含余量缓冲,跨 poll 保留残行)。
|
||||
reader: Option<FrameReader<BufReader<Conn>>>,
|
||||
/// worker 认证 token(监督者启动时生成、前台打印,用户带外注入 worker)。
|
||||
token: String,
|
||||
/// 认证门读超时(缺省 30 秒,半开连接不永久占线程)。
|
||||
auth_read_timeout: Duration,
|
||||
/// 最近一次心跳帧(中间 COMPLETED 只跟踪不合并;Done 帧改 phase 为 DONE)。
|
||||
last_heartbeat: Option<Heartbeat>,
|
||||
/// 待并入的失败记录帧(一测试失败一帧,按序消费,无偏移态,天然无双计)。
|
||||
@@ -48,8 +57,9 @@ pub(crate) struct TcpWindowTransport {
|
||||
|
||||
impl TcpWindowTransport {
|
||||
/// 构造传输:`listener` 是监督者绑定端点后的监听句柄(跨重启 `accept` 用),
|
||||
/// `paths` 是全量路径文本,`timeout_secs` 随 assign 帧携带。
|
||||
pub(crate) fn new(listener: Arc<Listener>, paths: Vec<String>, timeout_secs: u64) -> Self {
|
||||
/// `paths` 是全量路径文本,`timeout_secs` 随 assign 帧携带,`token` 是 worker
|
||||
/// 认证 token(认证门异或判等基准)。
|
||||
pub(crate) fn new(listener: Arc<Listener>, paths: Vec<String>, timeout_secs: u64, token: String) -> Self {
|
||||
Self {
|
||||
listener,
|
||||
writer: None,
|
||||
@@ -59,6 +69,8 @@ impl TcpWindowTransport {
|
||||
last_scene: None,
|
||||
paths,
|
||||
timeout_secs,
|
||||
token,
|
||||
auth_read_timeout: AUTH_READ_TIMEOUT,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,19 +102,62 @@ impl TcpWindowTransport {
|
||||
}
|
||||
}
|
||||
|
||||
/// 共享 token 判等:逐字节异或累加,避免逐字节短路带来的时序侧信道。
|
||||
///
|
||||
/// # 边界与前提
|
||||
/// - 长度不等直接判不等(生产 token 长度固定 64 位十六进制)。
|
||||
fn tokens_match(expected: &str, given: &str) -> bool {
|
||||
if expected.len() != given.len() {
|
||||
return false;
|
||||
}
|
||||
let mut diff = 0u8;
|
||||
for (x, y) in expected.bytes().zip(given.bytes()) {
|
||||
diff |= x ^ y;
|
||||
}
|
||||
diff == 0
|
||||
}
|
||||
|
||||
impl WindowTransport for TcpWindowTransport {
|
||||
fn assign(&mut self, wstart: usize, wend: usize) -> Result<(), SpawnError> {
|
||||
// 无连接时阻塞 `accept` 取一条 worker 连接(io 错误包成 SpawnError)。
|
||||
if self.writer.is_none() {
|
||||
let base = self.listener.accept().map_err(SpawnError)?;
|
||||
// 读侧置非阻塞:std 拒绝 0 时长读超时,改用 `set_nonblocking` 使无数据
|
||||
// 读返回 `WouldBlock`(判「无数据、仍存活」,不误杀正常窗口)。
|
||||
match &base {
|
||||
// 认证门:读超时内读首帧(半开连接不永久占线程),必须是 Auth 事件
|
||||
// 帧且 token 异或判等匹配,才写回 AuthOk;不匹配或首帧非 Auth 直接
|
||||
// 断连、不回帧(reader 连同底层连接一起 drop,连接即关)。
|
||||
base.set_read_timeout(Some(self.auth_read_timeout)).map_err(SpawnError)?;
|
||||
let mut reader = FrameReader::new(BufReader::new(base));
|
||||
let first = match reader.read_frame() {
|
||||
Ok(Some(frame)) => frame,
|
||||
_ => {
|
||||
return Err(SpawnError(std::io::Error::new(
|
||||
ErrorKind::ConnectionAborted,
|
||||
"worker auth failed or timed out",
|
||||
)))
|
||||
}
|
||||
};
|
||||
let auth_ok = match parse_event(&first) {
|
||||
Ok(ShardEvent::Auth { token }) => tokens_match(&self.token, &token),
|
||||
_ => false,
|
||||
};
|
||||
if !auth_ok {
|
||||
return Err(SpawnError(std::io::Error::new(
|
||||
ErrorKind::ConnectionAborted,
|
||||
"worker auth token mismatch",
|
||||
)));
|
||||
}
|
||||
// 写回 AuthOk:worker 据以继续收发。
|
||||
let mut writer = reader.get_mut().get_mut().try_clone().map_err(SpawnError)?;
|
||||
let frame = encode_command(&ShardCommand::AuthOk);
|
||||
writer.write_all(frame.as_bytes()).map_err(SpawnError)?;
|
||||
// 握手完成:清读超时,读侧置非阻塞(主循环 poll 用非阻塞读,
|
||||
// std 拒绝 0 时长读超时,改用 `set_nonblocking` 使无数据读返回
|
||||
// `WouldBlock`,判「无数据、仍存活」,不误杀正常窗口)。
|
||||
reader.get_mut().get_mut().set_read_timeout(None).map_err(SpawnError)?;
|
||||
match reader.get_mut().get_mut() {
|
||||
Conn::Tcp(s) => s.set_nonblocking(true).map_err(SpawnError)?,
|
||||
Conn::Unix(s) => s.set_nonblocking(true).map_err(SpawnError)?,
|
||||
}
|
||||
let writer = base.try_clone().map_err(SpawnError)?;
|
||||
let reader = FrameReader::new(BufReader::new(base));
|
||||
self.writer = Some(writer);
|
||||
self.reader = Some(reader);
|
||||
}
|
||||
@@ -199,6 +254,12 @@ impl WindowTransport for TcpWindowTransport {
|
||||
self.mark_done();
|
||||
return PollResult::Exited(1);
|
||||
}
|
||||
Ok(ShardEvent::Auth { .. }) => {
|
||||
// Auth 只应作首帧且在认证门消费;此处到达是协议违例,
|
||||
// 判断连(同畸形帧同构恢复)。
|
||||
self.drop_conn();
|
||||
return PollResult::Exited(1);
|
||||
}
|
||||
Err(_) => {
|
||||
// 畸形帧:对端状态不定,判断连(同 EOF 同构恢复)。
|
||||
self.drop_conn();
|
||||
@@ -247,59 +308,95 @@ mod tests {
|
||||
use std::net::TcpStream;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::shard_protocol::encode_event;
|
||||
use crate::shard_protocol::{encode_event, parse_command};
|
||||
|
||||
/// TcpWindowTransport 回环:bind 动态端口,客户端连接后发 Heartbeat / Fail /
|
||||
/// Scene 帧,断言 heartbeat / scene / log_tail / merge_fail 读回正确;
|
||||
/// 客户端断连后 poll 返回 Exited(合成码)。
|
||||
#[test]
|
||||
fn tcp_transport_loopback() {
|
||||
const TEST_TOKEN: &str = "test-token-0123456789abcdef";
|
||||
|
||||
/// 回环测试建监听:bind 动态端口,返回监听句柄与本地地址。
|
||||
fn bind_test_listener() -> (Arc<Listener>, std::net::SocketAddr) {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind 失败");
|
||||
let addr = listener.local_addr().expect("local_addr 失败");
|
||||
let listener = Arc::new(Listener::Tcp(listener));
|
||||
(Arc::new(Listener::Tcp(listener)), addr)
|
||||
}
|
||||
|
||||
let mut transport =
|
||||
TcpWindowTransport::new(Arc::clone(&listener), vec!["a/b.js".to_string(), "c/d.js".to_string()], 10);
|
||||
/// 派生客户端线程过认证门:客户端线程连接、发 Auth 帧、阻塞读 AuthOk 帧,
|
||||
/// 主线程调用 `assign`(accept 加读 Auth 加写回 AuthOk)使其解除阻塞,
|
||||
/// 客户端线程把读侧经通道送回(assign 帧留在余量缓冲,测试无需消费)。
|
||||
///
|
||||
/// # 注意事项
|
||||
/// - 客户端必须在独立线程:主线程阻塞读 AuthOk 时 `assign` 无人调用即死锁。
|
||||
fn spawn_authed_client(addr: std::net::SocketAddr) -> std::sync::mpsc::Receiver<FrameReader<BufReader<TcpStream>>> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let mut client = TcpStream::connect(addr).expect("客户端连接失败");
|
||||
client
|
||||
.write_all(encode_event(&ShardEvent::Auth { token: TEST_TOKEN.to_string() }).as_bytes())
|
||||
.expect("写 Auth 帧失败");
|
||||
let mut reader = FrameReader::new(BufReader::new(client));
|
||||
let first = reader.read_frame().expect("读帧失败").expect("应读到帧");
|
||||
assert!(matches!(parse_command(&first), Ok(ShardCommand::AuthOk)), "首帧应是 AuthOk:{first}");
|
||||
tx.send(reader).expect("送回读侧失败");
|
||||
});
|
||||
rx
|
||||
}
|
||||
|
||||
// assign:accept 客户端连接并发 assign 帧。
|
||||
let mut client = TcpStream::connect(addr).expect("客户端连接失败");
|
||||
/// 经客户端读侧句柄写事件帧(写侧与读侧共享底层套接字)。
|
||||
fn client_write(reader: &mut FrameReader<BufReader<TcpStream>>, event: &ShardEvent) {
|
||||
reader
|
||||
.get_mut()
|
||||
.get_mut()
|
||||
.write_all(encode_event(event).as_bytes())
|
||||
.expect("写事件帧失败");
|
||||
}
|
||||
|
||||
/// TcpWindowTransport 回环:bind 动态端口,客户端连接过认证门后发
|
||||
/// Heartbeat / Fail / Scene 帧,断言 heartbeat / scene / log_tail /
|
||||
/// merge_fail 读回正确;客户端断连后 poll 返回 Exited(合成码)。
|
||||
#[test]
|
||||
fn tcp_transport_loopback() {
|
||||
let (listener, addr) = bind_test_listener();
|
||||
|
||||
let mut transport = TcpWindowTransport::new(
|
||||
Arc::clone(&listener),
|
||||
vec!["a/b.js".to_string(), "c/d.js".to_string()],
|
||||
10,
|
||||
TEST_TOKEN.to_string(),
|
||||
);
|
||||
|
||||
// assign:accept 客户端连接、过认证门、发 assign 帧。
|
||||
let client_rx = spawn_authed_client(addr);
|
||||
transport.assign(0, 2).expect("assign 应成功");
|
||||
let mut client = client_rx.recv().expect("收回客户端读侧失败");
|
||||
|
||||
// 发 Heartbeat / Fail / Scene 帧(按序,一测试失败一帧)。
|
||||
client
|
||||
.write_all(
|
||||
encode_event(&ShardEvent::Heartbeat {
|
||||
phase: "COMPLETED".into(),
|
||||
index: 0,
|
||||
pass: 1,
|
||||
fail: 0,
|
||||
skip: 0,
|
||||
categories: HashMap::new(),
|
||||
hb_write_errors: 0,
|
||||
})
|
||||
.as_bytes(),
|
||||
)
|
||||
.expect("写心跳帧失败");
|
||||
client
|
||||
.write_all(
|
||||
encode_event(&ShardEvent::Fail {
|
||||
index: 0,
|
||||
category: "vm: not callable".into(),
|
||||
subkey: "x".into(),
|
||||
message: "x is not callable".into(),
|
||||
})
|
||||
.as_bytes(),
|
||||
)
|
||||
.expect("写失败帧失败");
|
||||
client
|
||||
.write_all(
|
||||
encode_event(&ShardEvent::Scene {
|
||||
line: "pc=1 op=ADD frames=1".into(),
|
||||
log_tail: Some("log line".into()),
|
||||
})
|
||||
.as_bytes(),
|
||||
)
|
||||
.expect("写现场帧失败");
|
||||
client_write(
|
||||
&mut client,
|
||||
&ShardEvent::Heartbeat {
|
||||
phase: "COMPLETED".into(),
|
||||
index: 0,
|
||||
pass: 1,
|
||||
fail: 0,
|
||||
skip: 0,
|
||||
categories: HashMap::new(),
|
||||
hb_write_errors: 0,
|
||||
},
|
||||
);
|
||||
client_write(
|
||||
&mut client,
|
||||
&ShardEvent::Fail {
|
||||
index: 0,
|
||||
category: "vm: not callable".into(),
|
||||
subkey: "x".into(),
|
||||
message: "x is not callable".into(),
|
||||
},
|
||||
);
|
||||
client_write(
|
||||
&mut client,
|
||||
&ShardEvent::Scene {
|
||||
line: "pc=1 op=ADD frames=1".into(),
|
||||
log_tail: Some("log line".into()),
|
||||
},
|
||||
);
|
||||
|
||||
// 读帧:poll 逐帧消费,直到 Scene(末帧)读回。
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
@@ -343,37 +440,33 @@ mod tests {
|
||||
/// Done 前无 Heartbeat 时合成零计数 DONE。
|
||||
#[test]
|
||||
fn tcp_transport_done_frame_keeps_counts() {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind 失败");
|
||||
let addr = listener.local_addr().expect("local_addr 失败");
|
||||
let listener = Arc::new(Listener::Tcp(listener));
|
||||
let (listener, addr) = bind_test_listener();
|
||||
|
||||
let mut transport = TcpWindowTransport::new(Arc::clone(&listener), vec!["a/b.js".to_string()], 10);
|
||||
let mut client = TcpStream::connect(addr).expect("客户端连接失败");
|
||||
let mut transport =
|
||||
TcpWindowTransport::new(Arc::clone(&listener), vec!["a/b.js".to_string()], 10, TEST_TOKEN.to_string());
|
||||
let client_rx = spawn_authed_client(addr);
|
||||
transport.assign(0, 1).expect("assign 应成功");
|
||||
let mut client = client_rx.recv().expect("收回客户端读侧失败");
|
||||
|
||||
// 先发一条带计数的心跳,再发 Done:Done 应把 phase 改 DONE 并保留计数。
|
||||
client
|
||||
.write_all(
|
||||
encode_event(&ShardEvent::Heartbeat {
|
||||
phase: "COMPLETED".into(),
|
||||
index: 0,
|
||||
pass: 5,
|
||||
fail: 2,
|
||||
skip: 1,
|
||||
categories: HashMap::new(),
|
||||
hb_write_errors: 0,
|
||||
})
|
||||
.as_bytes(),
|
||||
)
|
||||
.expect("写心跳帧失败");
|
||||
client_write(
|
||||
&mut client,
|
||||
&ShardEvent::Heartbeat {
|
||||
phase: "COMPLETED".into(),
|
||||
index: 0,
|
||||
pass: 5,
|
||||
fail: 2,
|
||||
skip: 1,
|
||||
categories: HashMap::new(),
|
||||
hb_write_errors: 0,
|
||||
},
|
||||
);
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while transport.heartbeat().is_none() && Instant::now() < deadline {
|
||||
let _ = transport.poll();
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
client
|
||||
.write_all(encode_event(&ShardEvent::Done).as_bytes())
|
||||
.expect("写 Done 帧失败");
|
||||
client_write(&mut client, &ShardEvent::Done);
|
||||
|
||||
// poll 读到 Done 帧应返回 Exited,且心跳 phase 改 DONE、计数保留。
|
||||
let mut exited = false;
|
||||
@@ -395,17 +488,15 @@ mod tests {
|
||||
/// Done 帧前无 Heartbeat:合成零计数 DONE(不 panic、计数全零)。
|
||||
#[test]
|
||||
fn tcp_transport_done_without_heartbeat_synthesizes_zero() {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind 失败");
|
||||
let addr = listener.local_addr().expect("local_addr 失败");
|
||||
let listener = Arc::new(Listener::Tcp(listener));
|
||||
let (listener, addr) = bind_test_listener();
|
||||
|
||||
let mut transport = TcpWindowTransport::new(Arc::clone(&listener), vec!["a/b.js".to_string()], 10);
|
||||
let mut client = TcpStream::connect(addr).expect("客户端连接失败");
|
||||
let mut transport =
|
||||
TcpWindowTransport::new(Arc::clone(&listener), vec!["a/b.js".to_string()], 10, TEST_TOKEN.to_string());
|
||||
let client_rx = spawn_authed_client(addr);
|
||||
transport.assign(0, 1).expect("assign 应成功");
|
||||
let mut client = client_rx.recv().expect("收回客户端读侧失败");
|
||||
|
||||
client
|
||||
.write_all(encode_event(&ShardEvent::Done).as_bytes())
|
||||
.expect("写 Done 帧失败");
|
||||
client_write(&mut client, &ShardEvent::Done);
|
||||
let mut exited = false;
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while !exited && Instant::now() < deadline {
|
||||
@@ -422,4 +513,63 @@ mod tests {
|
||||
assert_eq!(hb.skip, 0);
|
||||
assert!(hb.categories.is_empty());
|
||||
}
|
||||
|
||||
/// 认证门:token 不匹配时 assign 报错,客户端读首帧得 EOF(断连不回帧)。
|
||||
#[test]
|
||||
fn tcp_transport_auth_mismatch_disconnects() {
|
||||
let (listener, addr) = bind_test_listener();
|
||||
|
||||
let mut transport =
|
||||
TcpWindowTransport::new(Arc::clone(&listener), vec!["a/b.js".to_string()], 10, TEST_TOKEN.to_string());
|
||||
let mut client = TcpStream::connect(addr).expect("客户端连接失败");
|
||||
client
|
||||
.write_all(
|
||||
encode_event(&ShardEvent::Auth {
|
||||
token: "wrong-token".to_string(),
|
||||
})
|
||||
.as_bytes(),
|
||||
)
|
||||
.expect("写 Auth 帧失败");
|
||||
let err = transport.assign(0, 1).expect_err("token 不匹配应报错");
|
||||
assert!(err.0.to_string().contains("mismatch"), "错误应说明 token 不匹配:{err:?}");
|
||||
|
||||
// 断连不回帧:客户端读首帧得 EOF。
|
||||
let mut reader = FrameReader::new(BufReader::new(client));
|
||||
assert_eq!(reader.read_frame().expect("读帧应成功"), None, "断连后应得 EOF");
|
||||
}
|
||||
|
||||
/// 认证门:首帧非 Auth(发心跳帧)时 assign 报错,客户端读首帧得 EOF。
|
||||
#[test]
|
||||
fn tcp_transport_auth_first_frame_not_auth_disconnects() {
|
||||
let (listener, addr) = bind_test_listener();
|
||||
|
||||
let mut transport =
|
||||
TcpWindowTransport::new(Arc::clone(&listener), vec!["a/b.js".to_string()], 10, TEST_TOKEN.to_string());
|
||||
let mut client = TcpStream::connect(addr).expect("客户端连接失败");
|
||||
client
|
||||
.write_all(encode_event(&ShardEvent::Done).as_bytes())
|
||||
.expect("写首帧失败");
|
||||
let err = transport.assign(0, 1).expect_err("首帧非 Auth 应报错");
|
||||
assert!(err.0.to_string().contains("mismatch"), "错误应说明认证失败:{err:?}");
|
||||
|
||||
let mut reader = FrameReader::new(BufReader::new(client));
|
||||
assert_eq!(reader.read_frame().expect("读帧应成功"), None, "断连后应得 EOF");
|
||||
}
|
||||
|
||||
/// 认证门:静默连接(不发首帧)在读超时后 assign 报错,客户端读首帧得 EOF。
|
||||
#[test]
|
||||
fn tcp_transport_auth_silence_times_out() {
|
||||
let (listener, addr) = bind_test_listener();
|
||||
|
||||
let mut transport =
|
||||
TcpWindowTransport::new(Arc::clone(&listener), vec!["a/b.js".to_string()], 10, TEST_TOKEN.to_string());
|
||||
// 测试用短读超时:半开连接 200 ms 内即断,不占满 30 秒缺省值。
|
||||
transport.auth_read_timeout = Duration::from_millis(200);
|
||||
let client = TcpStream::connect(addr).expect("客户端连接失败");
|
||||
let err = transport.assign(0, 1).expect_err("静默连接应读超时");
|
||||
assert!(err.0.kind() == ErrorKind::ConnectionAborted, "错误应是连接中止:{err:?}");
|
||||
|
||||
let mut reader = FrameReader::new(BufReader::new(client));
|
||||
assert_eq!(reader.read_frame().expect("读帧应成功"), None, "断连后应得 EOF");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
//! Worker 模式:远程分片的执行半区。连监督者端点收窗口指派帧,in-process
|
||||
//! 执行测试,发心跳/失败/现场/DONE 帧。
|
||||
//!
|
||||
//! 认证门:连接后首帧发 `Auth` 事件帧(token 经 `OXIDE_TEST262_WORKER_TOKEN`
|
||||
//! 带外注入),读回 `AuthOk` 命令帧才进主循环;缺失 token、读超时、首帧非
|
||||
//! `AuthOk` 均报错退出。握手必须在看门狗线程启动前完成,否则看门狗会把
|
||||
//! `AuthOk` 帧当命令帧消费。
|
||||
//!
|
||||
//! 看门狗线程独立读控制帧(kill → 置标志),主线程在测试间隙检查标志后从
|
||||
//! 主线程上下文发现场帧再退出。`KernelCore` 是 `!Send`,看门狗不得触引擎;
|
||||
//! 现场帧内容来自主线程上下文(last-pc 采样行)。断连按死亡处理(恢复由
|
||||
//! 监督者侧同构完成)。
|
||||
|
||||
use std::io::BufReader;
|
||||
use std::net::TcpStream;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -15,7 +21,7 @@ use crate::config::RunConfig;
|
||||
use crate::harness::{HarnessModuleCache, HarnessPrefixCache, HarnessSources, HARNESS};
|
||||
use crate::judge::{categorize_fail, TestOutcome};
|
||||
use crate::runner::{build_runner_engine, process_path, CURRENT_TEST_PATH};
|
||||
use crate::shard_protocol::{encode_event, parse_command, FrameReader, ShardCommand, ShardEvent};
|
||||
use crate::shard_protocol::{encode_event, parse_command, FrameReader, ShardCommand, ShardEvent, AUTH_READ_TIMEOUT};
|
||||
use crate::stats::RunStats;
|
||||
use oxide_net::Conn;
|
||||
|
||||
@@ -34,11 +40,12 @@ enum ChannelMsg {
|
||||
Kill,
|
||||
}
|
||||
|
||||
/// Worker 模式入口:连监督者端点,收窗口指派帧,in-process 执行,发帧。
|
||||
/// Worker 模式入口:连监督者端点,过认证门,收窗口指派帧,in-process 执行,发帧。
|
||||
///
|
||||
/// 端点经 `OXIDE_TEST262_WORKER_ENDPOINT` 环境变量传入(host:port 形态)。
|
||||
/// 看门狗线程独立读控制帧(kill → 置标志),主线程在测试间隙检查标志后
|
||||
/// 发现场帧再退出。断连按死亡处理(恢复由监督者侧完成)。
|
||||
/// 端点经 `OXIDE_TEST262_WORKER_ENDPOINT` 环境变量传入(host:port 形态),
|
||||
/// token 经 `OXIDE_TEST262_WORKER_TOKEN` 带外注入(监督者前台打印,不经网络
|
||||
/// 分发)。看门狗线程独立读控制帧(kill → 置标志),主线程在测试间隙检查
|
||||
/// 标志后发现场帧再退出。断连按死亡处理(恢复由监督者侧完成)。
|
||||
pub(crate) fn run_worker(config: &RunConfig) -> bool {
|
||||
let endpoint = match std::env::var("OXIDE_TEST262_WORKER_ENDPOINT") {
|
||||
Ok(ep) => ep,
|
||||
@@ -47,6 +54,16 @@ pub(crate) fn run_worker(config: &RunConfig) -> bool {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
// token 带外注入:缺失或为空在连接前报错退出,不占连接。
|
||||
let token = match std::env::var("OXIDE_TEST262_WORKER_TOKEN") {
|
||||
Ok(t) if !t.is_empty() => t,
|
||||
_ => {
|
||||
eprintln!(
|
||||
"worker mode requires OXIDE_TEST262_WORKER_TOKEN (out-of-band from the supervisor's foreground print)"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
let stream = match TcpStream::connect(&endpoint) {
|
||||
Ok(s) => s,
|
||||
@@ -57,17 +74,58 @@ pub(crate) fn run_worker(config: &RunConfig) -> bool {
|
||||
};
|
||||
let mut conn = Conn::Tcp(stream);
|
||||
|
||||
// 看门狗线程:独立读控制帧。指派帧经通道转主线程(主线程持引擎,
|
||||
// 看门狗不得触引擎);kill 帧置标志并经通道发 kill 信号(主线程阻塞
|
||||
// 在通道时也能收到,不依赖标志轮询)。
|
||||
// 认证门:读超时 30 秒(半开连接不永久占进程),发 Auth 帧后读回首帧,
|
||||
// 必须是 AuthOk,否则报错退出。握手必须在看门狗线程启动前完成:看门狗
|
||||
// 独立读命令帧,提前启动会消费 AuthOk 帧。
|
||||
if conn.set_read_timeout(Some(AUTH_READ_TIMEOUT)).is_err() {
|
||||
eprintln!("worker: failed to set read timeout");
|
||||
return false;
|
||||
}
|
||||
let auth_frame = encode_event(&ShardEvent::Auth { token });
|
||||
if conn.write_all(auth_frame.as_bytes()).is_err() {
|
||||
eprintln!("worker: failed to send auth frame");
|
||||
return false;
|
||||
}
|
||||
let mut reader = FrameReader::new(BufReader::new(conn));
|
||||
let first = match reader.read_frame() {
|
||||
Ok(Some(frame)) => frame,
|
||||
Ok(None) => {
|
||||
eprintln!("worker: supervisor closed the connection before auth ok");
|
||||
return false;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("worker: failed to read auth ok frame: {e}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if !matches!(parse_command(&first), Ok(ShardCommand::AuthOk)) {
|
||||
eprintln!("worker: supervisor did not confirm auth (first frame is not authok)");
|
||||
return false;
|
||||
}
|
||||
// 握手完成:清读超时(看门狗阻塞读不得超时),克隆写句柄供主线程发
|
||||
// 事件帧,读句柄交给看门狗。
|
||||
if reader.get_mut().get_mut().set_read_timeout(None).is_err() {
|
||||
eprintln!("worker: failed to clear read timeout");
|
||||
return false;
|
||||
}
|
||||
let writer = match reader.get_mut().get_mut().try_clone() {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
eprintln!("worker: failed to clone conn for writer: {e}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let mut conn = writer;
|
||||
|
||||
// 看门狗线程:独立读控制帧(读句柄移入)。指派帧经通道转主线程
|
||||
// (主线程持引擎,看门狗不得触引擎);kill 帧置标志并经通道发 kill
|
||||
// 信号(主线程阻塞在通道时也能收到,不依赖标志轮询)。
|
||||
let kill_flag = Arc::new(AtomicBool::new(false));
|
||||
let (msg_tx, msg_rx) = mpsc::channel::<ChannelMsg>();
|
||||
let watchdog_kill = Arc::clone(&kill_flag);
|
||||
let watchdog_tx = msg_tx;
|
||||
let watchdog_conn = conn.try_clone().expect("failed to clone conn for watchdog");
|
||||
let watchdog_buf = std::io::BufReader::new(watchdog_conn);
|
||||
std::thread::spawn(move || {
|
||||
let mut reader = FrameReader::new(watchdog_buf);
|
||||
let mut reader = reader;
|
||||
loop {
|
||||
match reader.read_frame() {
|
||||
Ok(Some(frame)) => match parse_command(&frame) {
|
||||
@@ -79,6 +137,11 @@ pub(crate) fn run_worker(config: &RunConfig) -> bool {
|
||||
}) => {
|
||||
let _ = watchdog_tx.send(ChannelMsg::Assign(AssignWindow { wstart, paths }));
|
||||
}
|
||||
Ok(ShardCommand::AuthOk) => {
|
||||
// AuthOk 只出现一次且已被主线程握手消费;重复到达
|
||||
// 忽略(协议上监督者不会重发)。
|
||||
continue;
|
||||
}
|
||||
Ok(ShardCommand::Kill) => {
|
||||
watchdog_kill.store(true, Ordering::SeqCst);
|
||||
let _ = watchdog_tx.send(ChannelMsg::Kill);
|
||||
@@ -235,3 +298,17 @@ fn send_scene_frame(conn: &mut Conn) {
|
||||
eprintln!("worker: failed to send scene frame: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// token 缺失时 worker 在连接前报错退出(不占连接)。
|
||||
#[test]
|
||||
fn worker_missing_token_exits_before_connect() {
|
||||
std::env::set_var("OXIDE_TEST262_WORKER_ENDPOINT", "127.0.0.1:1");
|
||||
std::env::remove_var("OXIDE_TEST262_WORKER_TOKEN");
|
||||
let config = RunConfig::new();
|
||||
assert!(!run_worker(&config), "token 缺失应报错退出");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,8 +66,49 @@ token 生命周期与 server 进程同长:启动时(认领 sidecar 之前)
|
||||
|
||||
执行上限:eval 请求带 `max_steps` 步数上限;控制请求不占 worker,不被长 eval 阻塞。
|
||||
|
||||
## 分片安全边界
|
||||
|
||||
远程分片(test262 runner 的 `--remote-supervise` 监督者与 `--worker` 执行半区)
|
||||
的安全边界定稿为三个口径,与 server 子系统同型双闸门:loopback 绑定加 token
|
||||
认证门。与 server 的差别在威胁模型与分发渠道:worker 执行不可信测试内容,
|
||||
威胁对象是远程指派者而非本机邻居;token 不经网络分发,靠前台打印加带外注入。
|
||||
|
||||
### 缺省绑定地址
|
||||
|
||||
`--remote-supervise` 端点只允许 loopback 绑定:用户只给端口(裸端口或
|
||||
`:port` 形态)时解析期补缺省 127.0.0.1;显式非 loopback 地址(0.0.0.0、
|
||||
局域网 IP)在解析期报错拒绝,bind 处另有双保险拒绝非 loopback。该姿态比
|
||||
server 的「0.0.0.0 可配置但不受支持」更严:server 的威胁模型是本机邻居,
|
||||
worker 执行不可信测试内容,非 loopback 绑定会把窗口指派面(含本地路径
|
||||
执行)暴露到网络,防护责任无法转给使用方。端点只接受 `[IP]:port` 形态,
|
||||
主机名不解析(解析期无法验证 loopback)。
|
||||
|
||||
### worker 认证 token
|
||||
|
||||
监督者启动时生成 64 位十六进制 token(读 `/dev/urandom` 32 字节,非 Linux
|
||||
回落时间加进程号混合熵)并打印到前台 stderr;worker 进程经
|
||||
`OXIDE_TEST262_WORKER_TOKEN` 环境变量取得(带外注入,与 server `--rm`
|
||||
模式同型:token 不经网络分发)。worker 连接后首帧必须是 `Auth` 事件帧,
|
||||
监督者逐字节异或累加判等(长度不等直接判不等,避免逐字节短路的时序
|
||||
侧信道),匹配才写回 `AuthOk` 命令帧并进窗口循环;不匹配或首帧非 `Auth`
|
||||
直接断连、不回帧,不向攻击者泄露差异。读首帧带 30 秒读超时,半开连接
|
||||
不永久占线程。
|
||||
|
||||
token 生命周期与监督者进程同长:启动时生成、进程退出即销毁,无轮换机制。
|
||||
|
||||
### worker 无特权口径
|
||||
|
||||
worker 是最小单向面:
|
||||
|
||||
- 只能发协议内事件帧(`Auth` / `Heartbeat` / `Fail` / `Scene` / `Done`),
|
||||
未知类型在解析层拒绝,连接按死亡处理;
|
||||
- 连接数受 worker 数上限约束(`--remote-supervise` 的 worker 数旗标),
|
||||
每窗口一条连接;
|
||||
- 不读监督者本机文件:窗口路径清单随 `Assign` 帧携带,worker 不读监督者
|
||||
本机临时文件;
|
||||
- 无文件系统写权限面:worker 不写监督者侧任何文件,失败记录与现场都经
|
||||
帧回传。
|
||||
|
||||
## 扩展位
|
||||
|
||||
本文档只定稿持久 server 现状。分片安全边界(分片启用时的缺省绑定地址、worker
|
||||
认证 token、worker 无特权口径)属 963.17 范围,本文档不预写;963.17 实施时在本
|
||||
文档追加相应章节。
|
||||
本文档定稿持久 server 现状与远程分片安全边界(见上「分片安全边界」一节)。
|
||||
|
||||
Reference in New Issue
Block a user