crates\nfarms-core\src\dsh.rs¶
源码路径:
crates\nfarms-core\src\dsh.rs· 603 行
//! DSH-Starter —— 一键 `dsh web --port <端口>`。对应 C# `Dsh.cs`。
//!
//! 原版踩过的一个坑必须照抄:**`dsh web` 打印的根地址带一次性 token**,
//! 不带 token 打开只会 401。所以地址要从它自己的 stdout 里**抓**,
//! 不能自己拼 `http://127.0.0.1:<port>/`。
//!
//! 输出走的是「重定向到日志文件 + 定时读文件尾部」——和原版一样,
//! 不去接管道:不用起读线程、不用担心管道满了把子进程卡死、进程崩了日志还在。
use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use crate::{Log, tools};
/// dsh 的默认端口(本机 harness 用的就是它)。
pub const DEFAULT_PORT: i32 = 7909;
/// 日志文件名。
pub const LOG_NAME: &str = "dsh-web.log";
/// 找 `dsh`。它通常是个 npm 全局命令(`dsh.cmd`),所以走 `where`。
///
/// 返回的路径**只用于显示**(界面上的「dsh」一行)。真正启动用的是写死的
/// `dsh web --port N`(见 [`start`]),不依赖这里的值 —— 所以「返回 `None`」
/// 恰恰是「没装」该有的结果。
pub fn find() -> Option<String> {
let out = tools::capture("where dsh", 15_000);
parse_where_output(&out)
}
/// 从 `where` 的输出里挑出第一个**真的存在的文件**。
///
/// **为什么不能是「取第一个非空行」**:`tools::capture` 把 stdout 和 stderr
/// **合并**成一个串(`tools.rs` 里 `format!("{}\n{}", …)`),而 `where` 找不到
/// 东西时正是往 stderr 写一行提示语、stdout 为空、退出码 1。本机实测:
///
/// ```text
/// > cmd /d /c "where <不存在的名字>"
/// INFO: Could not find files for the given pattern(s). ← stderr,exit=1
/// ```
///
/// `tools.rs` 的噪音过滤表只认 `"is not recognized"` 那一类,**不含这句**。
/// 于是旧实现返回
/// `Some("INFO: Could not find files for the given pattern(s).")`,而
/// `nfarms-gui/src/plugins/core_pages.rs` 里这个值有两个去处:显示成「dsh 的
/// 路径」(假路径),以及当成「启动」按钮的使能条件(`!running && exe.is_some()`)。
/// 结果就是:**没装 dsh 时按钮可点**,点下去只会起一个不存在的命令。
///
/// 这里改成**按文件系统判**:候选行必须指向一个存在的文件。
/// 不按错误文案判 —— 那句提示语随系统语言变(中文 Windows 上是
/// 「信息: 用提供的模式无法找到文件。」),补一个字符串只是补一个漏一个;
/// 而「文件到底在不在」不随语言变。
///
/// 顺序保持旧行为:取**第一个**合格行(装了两份时,取 `where` 排在前面的
/// 那个 —— 通常是无扩展名的 shim;这里不动它,免得改掉界面上显示的路径)。
fn parse_where_output(out: &str) -> Option<String> {
out.lines()
.map(|l| l.trim())
.find(|l| !l.is_empty() && Path::new(l).is_file())
.map(|s| s.to_string())
}
/// 起一个后台 `dsh web --port N`,输出重定向到 `log`。
///
/// 返回 `Child`,由调用方持有并负责 kill。**不接管道**:接管道就要起读线程,
/// 而且管道满了会把子进程卡死。
///
/// **起进程之前先探端口**。界面判断"跑没跑"只看内存里的 `Child`
/// (`nfarms-gui` 的 `DshState::child`),而上一次的 dsh 完全可以活着却没有
/// 对应的 `Child`:直接关窗口、程序崩了、或上一次是别处启动的。这时「启动」
/// 按钮依然可点,点下去新进程只会 bind 失败,**而失败信息藏在日志文件里** ——
/// 界面显示的是「已启动,监听 :N」外加一个永远转的圈,用户以为好了。
/// 所以在这里挡住:端口有主就直接报错(见 `occupied_message`),不建日志、
/// 不起进程 —— 也就不会顺手把上一次的日志轮转掉(轮转见 [`open_log`])。
pub fn start(port: i32, log: &Path) -> Result<Child, String> {
if port_listening(port) {
return Err(occupied_message(port));
}
if let Some(parent) = log.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("建日志目录失败: {e}"))?;
}
let out = open_log(log)?;
let err = out.try_clone().map_err(|e| format!("复制文件句柄失败: {e}"))?;
// 走 cmd /c:在 Windows 上 `dsh` 是 .cmd 包装器,直接 CreateProcess 起不来
let comspec = std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string());
let mut cmd = Command::new(comspec);
cmd.arg("/c")
.arg(format!("dsh web --port {port}"))
.stdin(Stdio::null())
.stdout(Stdio::from(out))
.stderr(Stdio::from(err));
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
// CREATE_NO_WINDOW:这是常驻服务,不该弹黑框出来
cmd.creation_flags(0x0800_0000);
}
cmd.spawn().map_err(|e| format!("起 dsh 失败: {e}"))
}
/// 上一轮日志的落点:`dsh-web.log` → `dsh-web.log.1`。
fn rotated_path(log: &Path) -> PathBuf {
// 直接往 `OsString` 上追加,不走 `with_extension` —— 后者会把 `.log` **换掉**
// (得到 `dsh-web.1`),那样它就落进 `logs\` 里其它按扩展名认人的地方眼里了。
let mut name = log.as_os_str().to_os_string();
name.push(".1");
PathBuf::from(name)
}
/// 建出本次要写的日志文件:上一轮**非空**就先改名成 `.1`,再建一个空文件。
///
/// **为什么不用 `File::create` 直接截断**:这个文件是「dsh 到底起没起来、为什么
/// 没起来」的现场 —— 端口被别的东西占着、卡在交互里没打印地址、进程刚崩,全在
/// 它里面。而文件名是固定的([`LOG_NAME`],没有时间戳后缀),截断就等于把唯一的
/// 证据删掉:用户点第二次「启动」时,上一次为什么失败已经查不到了。
///
/// **为什么是改名,不是 `append(true)`**:`parse_root_url` 抓的是**带 token 的
/// 地址**,而 token 是一次性的、上一轮那个早就作废了。追加的话旧地址还留在文件
/// 尾部,新进程还没打印出来,主循环从尾部 80 行里就会先抓到那个死 token
/// (`lib.rs` 的「还在等地址」分支只看 `url.is_none()`,不认新旧),
/// 界面于是给出一个打开就 401 的地址 —— 比"看不到历史日志"更糟。
/// 改名两头都占:文件从空开始,旧内容还在盘上。
///
/// **为什么改名失败不报错**:备份是尽力而为。目录只读、文件被别处占着都可能让
/// rename 失败;那时宁可退化成截断,也不能因为"备份不了"就让「启动」按钮点不动。
fn open_log(log: &Path) -> Result<std::fs::File, String> {
// 只在真的有内容时轮转:给空文件改名只会让 `logs\` 里多一个空的 `.1`
let has_content = std::fs::metadata(log).map(|m| m.len() > 0).unwrap_or(false);
if has_content {
let prev = rotated_path(log);
// 只留一代。`.1`→`.2`→`.3` 一路堆下去,就是另一种"只增不减"。
let _ = std::fs::remove_file(&prev);
let _ = std::fs::rename(log, &prev);
}
std::fs::File::create(log).map_err(|e| format!("建日志文件失败: {e}"))
}
/// 端口被占时给用户看的那句话。
///
/// **为什么还要花一两个子进程去查"是谁"**:这条路径一次点击最多走一次
/// (用户点了「启动」、而且端口真的有主),不在每帧路径上 —— 而"上一次的 dsh
/// 没退干净"正是**最常见**的原因。只说"端口被占"用户还得自己去翻 `netstat`;
/// 把进程名和 PID 报出来,他才有个能下手的地方。
///
/// 查不到(端口号越界、`netstat` 拿不到输出)就退回纯文字,绝不因此报第二种错。
fn occupied_message(port: i32) -> String {
let head = format!(
"端口 {port} 已被占用,再起一个 dsh 只会绑定失败 —— 多半是上一次的 dsh 没退干净。\
先结束它,或到 Setting › Proxy and Port 换个端口。"
);
match u16::try_from(port).ok().and_then(tools::port_holder) {
Some((pid, name)) if !name.is_empty() => format!("{head} 占用者:{name}(PID {pid})"),
Some((pid, _)) => format!("{head} 占用者:PID {pid}"),
None => head,
}
}
/// 从 `dsh web` 的输出里抓根地址。
///
/// 抓的是**带 token 的那一行**。自己拼 `http://127.0.0.1:<port>/` 只会拿到 401 ——
/// 这是原版专门记过的坑。
pub fn parse_root_url(text: &str) -> Option<String> {
for line in text.lines() {
let line = line.trim();
let mut idx = 0usize;
while let Some(p) = line[idx..].find("http://") {
let start = idx + p;
let rest = &line[start..];
// URL 到空白或引号为止
let end = rest
.find(|c: char| c.is_whitespace() || c == '"' || c == '\'' || c == '>' || c == ']')
.unwrap_or(rest.len());
let url = &rest[..end];
// 只要带 token 的那个(没有 token 的地址打开是 401,抓它没意义)
if url.contains("token=") {
return Some(url.trim_end_matches(['.', ',', ')']).to_string());
}
idx = start + end.max(1);
if idx >= line.len() {
break;
}
}
}
None
}
/// 尾部读的起始窗口。40 行(本页要的行数)一般不到 4 KiB;超长行就按 ×4
/// 往上翻,翻到需要覆盖整个文件为止 —— 那时交回 [`Log::tail`],不多读也不漏。
const TAIL_CHUNK: u64 = 16 * 1024;
/// 读日志尾部。
///
/// **为什么不直接转 [`Log::tail`]**:那个实现先把**整个文件**读进内存,而本页的
/// 调用点在 `update()` 里(`core_pages.rs` 的「控制台输出」卡片,每帧一次),
/// `dsh web` 的日志又是只增不减的 —— 开着这一页放几小时,就是每帧全量读一遍。
/// 批次 D 的修法本来也是两句:尾部读 + 卡片跨帧缓存;缓存那一半在卡片里
/// (不在本文件),尾部读这一半在这里。
///
/// 输出与 [`Log::tail`] **逐字节一致**:小文件(不超过 `TAIL_CHUNK`)以及"窗口不得不
/// 覆盖整个文件"两种情形都直接转给它,所以「文件不存在 → 空串」「读失败 →
/// `(读取日志失败: …)`」「吞 BOM」这些边界一个都没变(有对照测试守)。
pub fn tail(log: &Path, lines: usize) -> String {
if lines == 0 {
// `Log::tail(f, 0)` 也是空串 —— 没必要为它去开文件
return String::new();
}
tail_from_end(log, lines).unwrap_or_else(|| Log::tail(log, lines))
}
/// 从文件尾反向读 `lines` 行;返回 `None` = 这次不走尾部读,调用方转 [`Log::tail`]。
///
/// 窗口从尾部往前翻,**只有当窗口要覆盖整个文件时才交回去** —— 这样 BOM 与
/// 非 UTF-8 的处理与原实现完全一致(那两种情形都得看文件开头)。
/// 大文件上有一处**刻意**的差别:窗口是从中间切进去的,第一段是上一行的半截
/// (丢掉),解码用 `from_utf8_lossy`。日志本应是 UTF-8;真混进坏字节时,
/// 「按替换字符显示尾部」也比「因为开头一个坏字节就整屏报读取失败」有用。
fn tail_from_end(file: &Path, lines: usize) -> Option<String> {
let size = std::fs::metadata(file).ok()?.len();
if size <= TAIL_CHUNK {
return None; // 小文件全读也不亏,还白拿原实现的全部边界行为
}
let mut window = TAIL_CHUNK;
loop {
let start = size.saturating_sub(window);
if start == 0 {
return None; // 要覆盖整个文件了 → 交回全读那条路(BOM / 非 UTF-8 一样处理)
}
let mut f = std::fs::File::open(file).ok()?;
f.seek(SeekFrom::Start(start)).ok()?;
let mut buf = Vec::new();
f.take(window).read_to_end(&mut buf).ok()?;
let text = String::from_utf8_lossy(&buf);
let mut got: Vec<&str> = text.lines().collect();
// 窗口不是从文件头开始的,所以第一段是**上一行的尾巴**,不是一整行
if !got.is_empty() {
got.remove(0);
}
if got.len() >= lines {
let from = got.len() - lines;
// 拼接口径与 `Log::tail` 一样:行间 CRLF,末尾 trim_end
return Some(got[from..].join("\r\n").trim_end().to_string());
}
window = window.checked_mul(4)?; // 涨不动(理论上不可能)就交回去
}
}
/// 日志文件路径(程序根\logs\dsh-web.log)。
pub fn log_path(paths: &crate::Paths) -> PathBuf {
paths.logs().join(LOG_NAME)
}
/// 端口上有没有东西在监听(判断 dsh 起没起来 / 端口是不是被上一次的实例占着)。
///
/// 越界端口(设置文件被手改成 `-1` / `70000` 这种)直接判「没人听」:
/// 旧实现解析失败后会退化成去连 **1 号端口** —— 那既不是用户问的端口,
/// 还可能让 [`start`] 平白拒掉一次正常启动。注意这里**只**回答"能不能连上",
/// 不负责校验端口本身合不合法(`start` 不替调用方做这件事)。
pub fn port_listening(port: i32) -> bool {
use std::net::TcpStream;
let Some(addr) = local_addr(port) else {
return false;
};
TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(400)).is_ok()
}
/// `127.0.0.1:<port>`。端口不在 `u16` 范围内(含 0)时返回 `None`。
fn local_addr(port: i32) -> Option<std::net::SocketAddr> {
let p = u16::try_from(port).ok()?;
if p == 0 {
return None; // 0 是"随便挑一个",不是一个能被连上的端口
}
Some(std::net::SocketAddr::new(
std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
p,
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scrapes_the_tokenized_root_url() {
let out = "info: dsh web listening\n\
info: open http://127.0.0.1:7909/?token=abc123def to continue\n\
ready\n";
assert_eq!(
parse_root_url(out).unwrap(),
"http://127.0.0.1:7909/?token=abc123def"
);
}
#[test]
fn ignores_urls_without_a_token() {
// 没有 token 的地址打开是 401 —— 抓它没意义,必须继续找
let out = "see http://127.0.0.1:7909/ for status\n\
open http://127.0.0.1:7909/?token=zzz\n";
assert_eq!(
parse_root_url(out).unwrap(),
"http://127.0.0.1:7909/?token=zzz"
);
}
#[test]
fn strips_trailing_punctuation() {
let out = "open http://127.0.0.1:7909/?token=abc).";
assert_eq!(parse_root_url(out).unwrap(), "http://127.0.0.1:7909/?token=abc");
}
#[test]
fn no_token_anywhere_is_none() {
assert!(parse_root_url("nothing here").is_none());
assert!(parse_root_url("").is_none());
assert!(parse_root_url("http://x/?q=1").is_none());
}
/// **守的是本文件最要命的那条:没装 dsh 时,`where` 的提示语绝不能被当成路径。**
///
/// 旧实现取「第一个非空行」。触发它不需要造假数据 —— `where` 找不到东西时
/// stdout 空、stderr 恰好一行 `INFO: Could not find files for the given
/// pattern(s).`,而 `tools::capture` 会把 stderr 混进结果。那句话被当成
/// 「dsh 的路径」返回后,界面显示假路径、并把「启动」按钮点亮。
#[test]
fn where_not_found_message_is_never_a_path() {
// 本机实测的那一行
assert_eq!(
parse_where_output("INFO: Could not find files for the given pattern(s)."),
None
);
// 中文系统上的同一句 —— 靠"补一个错误字符串"的修法会漏掉它,
// 靠"文件存不存在"判就不会
assert_eq!(parse_where_output("信息: 用提供的模式无法找到文件。"), None);
// 空输出 / 全空白:同样是"没找到"
assert_eq!(parse_where_output(""), None);
assert_eq!(parse_where_output(" \r\n\t\r\n"), None);
// 长得像路径、但文件不存在 —— 这就是"假路径"的定义
assert_eq!(parse_where_output(r"C:\nfarms-no-such-dir\dsh.cmd"), None);
}
/// 端到端那一半:真的起一次 `where`(名字一定不存在),把它的原始输出喂给
/// 解析器。无论 `capture` 将来是返回空串(噪音表补全了)还是返回那句提示语,
/// 都必须得不到路径。
#[test]
fn real_where_failure_output_parses_to_none() {
let raw = tools::capture("where nfarms-no-such-exe-xyz", 15_000);
assert_eq!(
parse_where_output(&raw),
None,
"`where` 失败时的输出被当成了 dsh 的路径:{raw:?}"
);
}
/// 反方向:**真的有文件时必须照常返回**,别为了修上面那条把正常情况修坏。
#[test]
fn where_output_still_returns_a_real_file() {
// 用测试程序自己的 exe 当"存在且可执行"的文件,不依赖本机装没装 dsh
let me = std::env::current_exe().expect("取当前测试程序路径");
let shown = me.display().to_string();
// 提示语混在前面时,取到的必须是真路径那一行
let out = format!("INFO: noise\n{shown}\n");
assert_eq!(parse_where_output(&out).as_deref(), Some(shown.as_str()));
}
/// **顺序是刻意保留的旧行为:两个候选都存在时取 `where` 排在前面的那一行。**
///
/// 核实报告里那条「`find()` 取的是第一行,而本机 `where dsh` 返回两行
/// (`...\npm\dsh` 和 `...\npm\dsh.cmd`),拿到的是无扩展名的 Unix shim」
/// 判为成立;结论是**不动它**(那个值只用于显示,启动走的是写死的
/// `dsh web --port N`),但这条"第一行优先"的取舍必须钉住 —— 它要是被
/// 谁改成"优先 `.cmd`",界面上显示的路径就跟着变了,那是另一种行为改动。
#[test]
fn where_output_takes_the_first_real_file() {
let dir = scratch("where-order");
let first = dir.join("dsh");
let second = dir.join("dsh.cmd");
std::fs::write(&first, "").expect("写第一个候选");
std::fs::write(&second, "").expect("写第二个候选");
let a = first.display().to_string();
let b = second.display().to_string();
assert_eq!(
parse_where_output(&format!("{a}\r\n{b}\r\n")).as_deref(),
Some(a.as_str())
);
assert_eq!(
parse_where_output(&format!("{b}\r\n{a}\r\n")).as_deref(),
Some(b.as_str())
);
let _ = std::fs::remove_dir_all(&dir);
}
// ---------------------------------------------------------------- 端口预探
// 清单里「`start()` 不查端口占用/已有实例,界面只认内存里的 `Child`」
// 那一条的护栏。
//
// 这里原本还有一条 `port_probe_is_safe`,断言写成
// `assert!(!port_listening(59999) || true)` —— 恒真,等于没有断言
// (核实报告点名的就是它),所以删掉。两个方向现在都由下面
// `port_probe_sees_a_listening_socket` 钉住:有监听者必须为真,
// 没人听 / 越界端口必须为假。
/// 造一个只属于本测试的临时目录(先清掉上一轮的残留,免得断言读到旧文件)。
fn scratch(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("nfarms-dsh-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("建临时目录");
dir
}
/// **端口被占时必须在起进程之前拒绝。**
///
/// 顺带钉住第二件事:拒绝时**不碰日志文件**。启动路径上第一件事就是把日志
/// 轮转掉(`open_log`;再早是 `File::create` 直接截断),所以"被拒绝"与
/// "把上一次 dsh 的输出挪走"必须分得开 —— 端口有主往往正是因为上一次的 dsh
/// 还活着,它还在往这个文件里写。
#[test]
fn start_refuses_when_the_port_is_already_taken() {
let dir = scratch("start-busy");
let log = dir.join(LOG_NAME);
std::fs::write(&log, "上一次 dsh 的输出\r\n").expect("写旧日志");
// 自己占住一个临时端口,扮演"上一次没退干净的 dsh"
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("绑定临时端口");
let port = listener.local_addr().expect("取临时端口号").port() as i32;
let err = start(port, &log).expect_err("端口被占时必须拒绝启动");
assert!(
err.contains(&port.to_string()),
"错误里要带上是哪个端口:{err}"
);
assert!(err.contains("占用"), "错误要说清是端口被占:{err}");
// 拒绝发生在建/轮转日志之前 —— 旧日志必须原样还在原处
assert_eq!(
std::fs::read_to_string(&log).expect("读旧日志"),
"上一次 dsh 的输出\r\n",
"被拒绝的启动不该动上一次的日志"
);
assert!(
!rotated_path(&log).exists(),
"被拒绝的启动不该留下 `.1`"
);
drop(listener);
let _ = std::fs::remove_dir_all(&dir);
}
// ---------------------------------------------------------------- 日志轮转
// dsh 核实报告里「`start()` 用 `File::create` 打开日志,会清空上一轮输出」
// 那一条的护栏。
/// **上一轮的日志必须留在盘上,新一轮必须从空文件开始。**
///
/// 两件事缺一不可:只测"新文件是空的"会放过"顺手把旧内容删掉"的实现;
/// 只测"旧内容还在"会放过 `append(true)` 那种 —— 后者更糟:`parse_root_url`
/// 抓的是带 token 的地址,而 token 一次性,上一轮那个早失效了,追加会让界面
/// 在新进程还没打印之前就抓到那个死地址。
///
/// 直接测 `open_log` 而不走 `start()`:真的走通 `start()` 要起一个 `dsh`
/// 进程,测试机上装没装 dsh、起没起来都不该决定这条断言的红绿。
#[test]
fn open_log_rotates_the_previous_output_instead_of_destroying_it() {
let dir = scratch("log-rotate");
let log = dir.join(LOG_NAME);
// ① 上一轮有内容 → 内容进 `.1`,新文件是空的
std::fs::write(&log, "上一轮 dsh 的输出\r\n").expect("写旧日志");
drop(open_log(&log).expect("建日志"));
assert_eq!(
std::fs::read_to_string(&log).expect("读新日志"),
"",
"新一轮必须在空文件上写,否则旧 token 会被 parse_root_url 抓到"
);
assert_eq!(
std::fs::read_to_string(rotated_path(&log)).expect("读 `.1`"),
"上一轮 dsh 的输出\r\n",
"上一轮的输出是失败现场,不许删"
);
// ② 只留一代:再轮转一次,`.1` 是最近一轮,不会堆出 `.1.1`
std::fs::write(&log, "第二轮\r\n").expect("写第二轮");
drop(open_log(&log).expect("建日志"));
assert_eq!(
std::fs::read_to_string(rotated_path(&log)).expect("读 `.1`"),
"第二轮\r\n"
);
assert!(
!rotated_path(&rotated_path(&log)).exists(),
"轮转只留一代,不能一路堆下去"
);
// ③ 文件不存在 / 空文件:不轮转(免得在 logs\\ 里留一个空的 `.1`)
let fresh = dir.join("fresh.log");
drop(open_log(&fresh).expect("建日志"));
assert!(!rotated_path(&fresh).exists(), "没有旧内容就不该有 `.1`");
std::fs::write(&fresh, "").expect("写空文件");
drop(open_log(&fresh).expect("建日志"));
assert!(!rotated_path(&fresh).exists(), "空文件也不该有 `.1`");
let _ = std::fs::remove_dir_all(&dir);
}
/// 预探得真的能看见监听者,否则上面那条只是"永远拒绝"。
#[test]
fn port_probe_sees_a_listening_socket() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("绑定临时端口");
let port = listener.local_addr().expect("取临时端口号").port() as i32;
assert!(port_listening(port), "有监听者的端口必须探得出来");
drop(listener);
assert!(!port_listening(port), "没人监听的端口不该报占用");
// 越界端口:旧实现会退化成去连 1 号端口
assert!(!port_listening(-1));
assert!(!port_listening(70_000));
assert!(!port_listening(0));
}
// ---------------------------------------------------------------- 尾部读
// 清单里「`tail()` 每帧整文件进内存」那一条的护栏。
/// 尾部读的输出必须与"全读一遍"**逐字节相同** —— 省内存不能把界面上的
/// 日志改样。文件故意撑到 [`TAIL_CHUNK`] 之外,并且掺入超长行让窗口边界
/// 落在某一行中间(半截行必须被丢掉)。
#[test]
fn tail_of_a_big_file_matches_the_full_read() {
let dir = scratch("tail-big");
let log = dir.join(LOG_NAME);
let mut text = String::new();
for i in 0..4000 {
if i % 7 == 0 {
text.push_str(&"x".repeat(300)); // 超长行:窗口很可能从它中间切进去
}
text.push_str(&format!("line {i}\r\n"));
}
std::fs::write(&log, &text).expect("写大日志");
assert!(
std::fs::metadata(&log).expect("读元数据").len() > TAIL_CHUNK,
"这个文件必须比一次读取窗口大,否则测不到尾部读"
);
for n in [1usize, 2, 40, 400, 4000, 9999] {
assert_eq!(tail(&log, n), Log::tail(&log, n), "max_lines={n}");
}
let _ = std::fs::remove_dir_all(&dir);
}
/// **"没有把整个文件读进来"的可观测证据。**
///
/// 文件开头塞坏字节时,**全读**那条路(`Log::tail` 的 `read_to_string`)会
/// 直接返回 `(读取日志失败: …)`;尾部读根本不碰开头,所以照常显示尾部。
/// 把 `tail` 改回 `Log::tail(log, lines)` 这一条立刻红 —— 这正是它要抓的复发。
#[test]
fn tail_of_a_big_file_does_not_read_the_whole_file() {
let dir = scratch("tail-head-bad-utf8");
let log = dir.join(LOG_NAME);
let mut bytes = vec![0xffu8; 64]; // 开头 64 个非法 UTF-8 字节
bytes.extend_from_slice("first\r\n".as_bytes());
for i in 0..4000 {
bytes.extend_from_slice(format!("line {i}\r\n").as_bytes());
}
std::fs::write(&log, &bytes).expect("写日志");
let size = std::fs::metadata(&log).expect("读元数据").len();
assert!(size > TAIL_CHUNK + 64, "坏字节要落在窗口之外");
// 全读那条路:开头一个坏字节就整屏报错
assert!(
Log::tail(&log, 2).starts_with("(读取日志失败"),
"前提变了:全读居然没报错"
);
// 尾部读:看不见开头,拿到最后两行
assert_eq!(tail(&log, 2), "line 3998\r\nline 3999");
let _ = std::fs::remove_dir_all(&dir);
}
/// 小文件(含"文件不存在"、空文件、带 BOM)走的是原实现,行为必须一字不差。
#[test]
fn tail_edges_are_unchanged() {
let dir = scratch("tail-small");
let missing = dir.join("nope.log");
assert_eq!(tail(&missing, 5), "");
let log = dir.join(LOG_NAME);
std::fs::write(&log, "").expect("写空文件");
assert_eq!(tail(&log, 5), "");
assert_eq!(tail(&log, 0), "");
// 带 BOM + CRLF:行间拼 CRLF、末尾 trim_end、BOM 被吞 —— 全照原样
std::fs::write(&log, "\u{feff}hello\r\nworld\r\n").expect("写带 BOM 的文件");
assert_eq!(tail(&log, 1), "world");
assert_eq!(tail(&log, 5), "hello\r\nworld");
assert_eq!(tail(&log, 999), "hello\r\nworld");
for n in [0usize, 1, 2, 5, 999] {
assert_eq!(tail(&log, n), Log::tail(&log, n), "max_lines={n}");
}
let _ = std::fs::remove_dir_all(&dir);
}
}