From 51c1672355c143bfcf1fb77c6aa7973891bd7004 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 4 Oct 2026 19:15:12 +0800 Subject: [PATCH] =?UTF-8?q?fix(guardrail):=20=E8=BE=93=E5=85=A5=E6=8A=A4?= =?UTF-8?q?=E6=A0=8F=E4=B8=8A=E9=99=90=E6=8C=89=E6=8E=A8=E5=AF=BC=E9=A2=84?= =?UTF-8?q?=E7=AE=97=E6=B4=BE=E7=94=9F=EF=BC=88=E4=BF=AE=E3=80=8C=E5=A4=A7?= =?UTF-8?q?=E9=99=84=E4=BB=B6=E8=A2=AB=E6=8B=A6=E6=AD=BB=E3=80=8D=EF=BC=89?= =?UTF-8?q?+=20=E7=AC=AC=205=20=E6=9D=A1=20flake?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 真 bug:文档注入预算(1M 窗口 ⇒ 349525 字符)> 输入护栏硬编码上限(262144)⇒ 带 744KB 附件的 POST /chat 直接 input_length_exceeded/guardrail_input,整个回合作废(主会话活服务实测)。 - 修法:guardrailMaxInputChars → guardrailMinInputChars(256Ki 下限) + 纯函数 deriveGuardrailInputLimit(log, override, budgetTokens, docInjectChars):派生值 = max(下限, 推导预算 tokens)(1M ⇒ 699050);新增 env GUARDRAIL_MAX_INPUT_CHARS(未设/0=派生、>0=覆盖、<0=WARN 回落);预算只在 wire_context 推导一次并经 buildGuardrailPipeline(ctxBudget) 传入(避免「窗口未声明」WARN 记两遍);输出上限 65536 刻意不参与派生(有显式回归钉住)。 - 关系不变量:文档注入预算 ≤ 输入上限,人为破坏 ⇒ 一条 WARN(不静默)。 - 第 5 条 flake(exec/TestTerminal_Timeout_Killed):仅改测试、terminal.go 保持 pristine;根因不是「杀进程失效」而是旧断言的 ~200ms 精度把宿主负载下的进程创建耗时算了进去(重压下 cmd.Start 会直接返回 deadline exceeded,红行却报 process not killed)⇒ 改为「控制组证明同命令不设短超时正常退出」的确定性证明 + 把墙钟断言降级为 10s 语义判别界(子进程自然存活期拉到 30s)。 - 主会话独立验收:隔离 worktree(G2 的 10 个文件,排除 M1 在途的 wire_channels.go)build/vet 绿、全树 59 包 0 FAIL、双平台交叉编译绿、Guardrail* 全 PASS、exec -count=20 绿;变异独立复现(把派生值退回硬编码 ⇒ 4 条红,含 budget=699050 → 262144)。 - 残留缺口(已排 G2b):护栏量的是整段装配请求、注入预算是单份 ⇒ ≥2 份各撞注入预算的大附件仍可能被拦。 --- .env.example | 18 +- cmd/cyrene/persona_identity_test.go | 2 +- cmd/cyrene/wire_context_test.go | 6 +- cmd/cyrene/wire_guardrail.go | 91 ++++++-- cmd/cyrene/wire_guardrail_limit_test.go | 293 ++++++++++++++++++++++++ cmd/cyrene/wire_guardrail_test.go | 32 ++- cmd/cyrene/wire_runtime.go | 18 +- internal/config/guardrail.go | 40 +++- internal/config/guardrail_test.go | 68 ++++++ internal/runtime/exec/terminal_test.go | 57 ++++- 10 files changed, 577 insertions(+), 48 deletions(-) create mode 100644 cmd/cyrene/wire_guardrail_limit_test.go create mode 100644 internal/config/guardrail_test.go diff --git a/.env.example b/.env.example index b068f1a..1fc104c 100644 --- a/.env.example +++ b/.env.example @@ -226,11 +226,23 @@ SUBAGENT_TIMEOUT_SEC=600 # 单份附件注入模型可见上下文的字符上限(sidecar 仍存全文,只受 8 MiB 安全上限约束)。 # 留空 = 按推导出的预算算:推导预算的 1/8(按一个回合最多 5 个附件留余量),下限 64K 字符。 # 它决定「每回合往上下文里放多少」,也因此决定清单行给的续读游标从哪里开始。 +# ⚠️ 它必须**不超过**下面 GUARDRAIL_MAX_INPUT_CHARS 生效的输入上限,否则大附件会在装配完之后 +# 被输入护栏整回合拦死(input_length_exceeded)。改了本键就顺手核对那一项。 # DOCUMENT_MAX_INJECT_CHARS= +# 请求侧字符上限(输入护栏跑在**组装完成的请求**上:系统提示词 + 记忆块 + 整段对话 + 工具结果)。 +# 三种取值: +# · 未设置 / 0(默认)= 由推导出的上下文预算派生:max(262144, 推导预算 token 数) +# (1 字符 ≈ 1 token 的保守口径);1M 窗口 ⇒ 预算 699050 token ⇒ 上限 699050 字符, +# 而单份附件注入预算是 349525 字符 ⇒ 关系不变量「注入预算 ≤ 输入上限」成立, +# 「分享大文件」才走得通。小窗口部署下派生的 token 数低于 262144,行为与旧硬编码一致。 +# · > 0 = 显式覆盖(原样使用;调小到注入预算之下会让大附件被拦,届时启动日志里有 WARN) +# · < 0 = 非法:记一条 WARN 并回落派生值(绝不静默生效) +# 它只动**输入**侧;输出上限(65536 字符)防的是模型输出失控,与上下文预算无关,不开放配置。 +# 敏感信息(API key / bearer token / 密码 / 私钥)为阻断档,同样是代码内定的安全默认值。 +# (取值与关系不变量的落点在 cmd/cyrene/wire_guardrail.go) +# GUARDRAIL_MAX_INPUT_CHARS= # 内容策略关键词表(逗号分隔,大小写不敏感的子串匹配)。留空 = 不挂关键词护栏 -# (项目不内置任何词表,只有你显式声明过的词才会被拦)。敏感信息(API key / -# bearer token / 密码 / 私钥)为阻断档、请求与响应各有长度上限,这两项是代码 -# 内定的安全默认值,不对外开放配置(见 cmd/cyrene/wire_guardrail.go)。 +# (项目不内置任何词表,只有你显式声明过的词才会被拦)。 # GUARDRAIL_KEYWORDS= # ---- 主人身份说明书注入范围(design/11 §0 红线) ---- diff --git a/cmd/cyrene/persona_identity_test.go b/cmd/cyrene/persona_identity_test.go index 34e854f..df78e7b 100644 --- a/cmd/cyrene/persona_identity_test.go +++ b/cmd/cyrene/persona_identity_test.go @@ -185,7 +185,7 @@ func TestPersonaInstructions_StaticPromptHasNoIdentityKnowledge(t *testing.T) { } llm := model.NewRuntimeLLM(filepath.Join(t.TempDir(), "models.json"), config.LLMConfig{}, testLog()) - for id, ag := range a.buildPersonaAgents(llm, nil, a.buildGuardrailPipeline()) { + for id, ag := range a.buildPersonaAgents(llm, nil, a.buildGuardrailPipeline(noWindowBudget())) { if ag.Instructions() != base { t.Fatalf("角色 %q 的静态指令与人格指令不一致(含身份说明书?):\n%q", id, ag.Instructions()) } diff --git a/cmd/cyrene/wire_context_test.go b/cmd/cyrene/wire_context_test.go index 918a0ec..20dad23 100644 --- a/cmd/cyrene/wire_context_test.go +++ b/cmd/cyrene/wire_context_test.go @@ -387,8 +387,8 @@ func TestContextCompaction_ZeroBudgetKeepsTodaysBehavior(t *testing.T) { // // 组合根本身(wireRuntime → newApp)无法在单元测试里实例化:它要开库、迁移、 // 建网关并绑定端口,在主人后端正在运行时尤其不能碰。因此「组合根确实走了这些缝」 -// 只能在源码层钉住:wire_runtime.go 必须调用 a.buildGuardrailPipeline()、 -// 必须把预算取自 a.contextBudget(envPath),并且把两项预算都交给 loop。 +// 只能在源码层钉住:wire_runtime.go 必须把预算取自 a.contextBudget(envPath), +// 在这份预算上建护栏管线(buildGuardrailPipeline(ctxBudget)),并把两项预算都交给 loop。 func TestWireRuntime_UsesTheCompositionRootSeams(t *testing.T) { t.Parallel() @@ -397,7 +397,7 @@ func TestWireRuntime_UsesTheCompositionRootSeams(t *testing.T) { t.Fatalf("read wire_runtime.go: %v", err) } for _, want := range []string{ - "a.buildGuardrailPipeline()", + "a.buildGuardrailPipeline(ctxBudget)", "a.contextBudget(envPath)", "MaxContextTokens: ctxBudget.Budget.Tokens", "DocumentMaxInjectChars: ctxBudget.DocumentInjectChars", diff --git a/cmd/cyrene/wire_guardrail.go b/cmd/cyrene/wire_guardrail.go index 68a5929..e8f9624 100644 --- a/cmd/cyrene/wire_guardrail.go +++ b/cmd/cyrene/wire_guardrail.go @@ -1,12 +1,13 @@ package main -import "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail" +import ( + "log/slog" -// 组合根的默认护栏策略(design/04 §5)。本条修的就是「护栏是空壳」: -// 此前 `guardrail.New()` 不带任何 Option,管线里一个检查都没有, -// 输入/输出两侧的 guardrail span 每次都只是走过场。 -// -// 三条默认策略与它们的依据: + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail" +) + +// 本文件是组合根的默认护栏策略(design/04 §5)。三条默认策略与它们的依据: // // 1. 敏感信息 = **阻断**档。API key / bearer token / 密码 / 私钥这类东西 // 一旦进了请求或回复就是既成事实的泄露,标记档(放行 + 记一笔)救不回来, @@ -15,19 +16,29 @@ import "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail" // 3. 关键词表 = **只在配置提供时**挂。项目不内置任何词表:凭空猜的词只会 // 误伤正常对话,且没有部署方能为它的命中负责(见 config.GuardrailConfig)。 const ( - // guardrailMaxInputChars 是请求侧的字符上限。这个检查跑在**组装完成的 - // 请求**上(系统提示词 + 记忆块 + 整段对话 + 工具结果),不是单条用户 - // 消息,因此上限必须按「整个上下文」来定:默认压缩预算 32K token - // 折算成字符约 65K(中文)~131K(拉丁),取 262144(256 Ki)字符约为 - // 它的 2~4 倍 —— 正常对话(哪怕很长)永远碰不到,只有把几百 KB 的 - // 整块文本直接灌进来这种病态输入才会被拦下。 - guardrailMaxInputChars = 262144 + // guardrailMinInputChars 是请求侧字符上限的**下限**(256 Ki 字符)。这个检查 + // 跑在**组装完成的请求**上(系统提示词 + 记忆块 + 整段对话 + 工具结果), + // 不是单条用户消息,因此上限按「整个上下文」定。 + // + // 为什么是「下限」而不是「定值」(本切片修的真 bug):单份附件的注入预算随 + // 推导出的上下文预算走,1M 窗口下已是 349525 字符(T1)。上限若还是硬编码的 + // 262144,任何正文超过它的附件都会让**整个**回合以 guardrail_input 结束 + // (端到端实测:`POST /chat` 带一个 744 KB 的 txt ⇒ + // `{"reply":"input_length_exceeded","stopReason":"guardrail_input"}`), + // 也就是「护栏成了上下文预算的瓶颈」,把「分享大文件」这条主线直接打死。 + // 因此生效值由 guardrailInputLimit 派生:max(本下限, 推导预算 token 数), + // 取 1 字符 ≈ 1 token 的保守口径。小窗口部署下派生的 token 数本就小于它, + // 输入上限与接线前逐字节一致。 + guardrailMinInputChars = 262144 // guardrailMaxOutputChars 是响应侧的字符上限。一条回复正常在几十到几百字, // 长代码/长文也就几千字,65536(64 Ki)字符比正常回复高 2~3 个数量级, // 因此不会截断任何正常输出,只拦失控的生成(例如无限复读)。这一点在主 // 流式路径上尤其重要:那里输出侧检查是异步旁路(只会审计,不挡已产出的 // 回复),但在静音/非流式路径(backthink、子代理)上否判决会替换答案, // 所以上限必须留足余量。 + // + // **它不随上下文预算派生**:输出失控与「窗口有多大」无关,本切片的输入侧 + // 解耦不该顺手把它也放宽。 guardrailMaxOutputChars = 65536 ) @@ -35,14 +46,19 @@ const ( // 它是组合根里唯一一处决定「护栏里到底有什么」的地方,因此测试可以直接 // 断言这个函数的产物,而不必启动整个运行时。 // +// 取 budget 参数而不是自己去推导:输入上限是**上下文预算的函数**(关系不变量 +// 「文档注入预算 ≤ 输入上限」的两端都在这份预算里),而预算的推导只该在 +// wire_context.go 发生一次 —— 各自推导会把「窗口未声明」那条 WARN 记两遍。 +// 调用顺序因此固定为「先算预算,再建管线」(wire_runtime.go)。 +// // 选项顺序即检查顺序(guardrail.New 按序应用),且先跑开销低、语义硬的 // 敏感信息检查:命中即返回该判决,长度与关键词检查不会再跑。 -func (a *app) buildGuardrailPipeline() *guardrail.Pipeline { +func (a *app) buildGuardrailPipeline(budget ctxBudget) *guardrail.Pipeline { opts := []guardrail.Option{ // ① 敏感信息外泄必须拦:阻断档。 guardrail.WithSensitiveInfo(guardrail.SensitiveBlock), // ② 长度上限:0 会关掉那一侧,因此两侧都给上保守上限。 - guardrail.WithLengthLimits(guardrailMaxInputChars, guardrailMaxOutputChars), + guardrail.WithLengthLimits(a.guardrailInputLimit(budget), guardrailMaxOutputChars), } // ③ 关键词表仅在配置提供时挂:没有配置就不挂(KeywordGuard 空表本也 // 全放行,这里显式分支是为了让「没配置」在管线上就是没有这个检查)。 @@ -52,6 +68,51 @@ func (a *app) buildGuardrailPipeline() *guardrail.Pipeline { return guardrail.New(opts...) } +// guardrailInputLimit 返回本次运行生效的请求侧字符上限: +// 配置显式覆盖优先,否则由推导出的上下文预算派生(见 deriveGuardrailInputLimit)。 +func (a *app) guardrailInputLimit(budget ctxBudget) int { + override := 0 + if a.cfg != nil { + override = a.cfg.Guardrail.MaxInputChars + } + return deriveGuardrailInputLimit( + sourcedWiringLog(a.log), override, + budget.Budget.Tokens, budget.DocumentInjectChars, + ) +} + +// deriveGuardrailInputLimit 是 guardrailInputLimit 的可测内核:取值来源收成显式 +// 参数,两条 WARN(非法覆盖 / 关系不变量被破)都在这里,测试可按条数断言。 +// +// 派生公式:输入上限 = max(guardrailMinInputChars, 推导预算 token 数)。字符与 +// token 按 1:1 的保守口径换算 —— 上限宁可放宽一点也不该误伤正常上下文,而它仍然 +// 只拦「几百 KB 整块文本直接灌进来」这种病态载荷(真正的上下文控制由压缩预算负责)。 +func deriveGuardrailInputLimit(log *slog.Logger, override, budgetTokens, docInjectChars int) int { + derived := guardrailMinInputChars + if budgetTokens > derived { + derived = budgetTokens + } + limit := derived + switch { + case override < 0: + // 非法覆盖:记一条带原始取值的 WARN 并回落派生值(AGENTS §10:不许静默)。 + log.Warn("guardrail input limit invalid, falling back to derived", + "env", config.GuardrailMaxInputCharsEnv, "value", override, "fallback", derived) + case override > 0: + limit = override + } + if docInjectChars > limit { + // 关系不变量被破:注入预算比输入上限还大,意味着「能注入的正文」本身就会 + // 触发输入护栏 —— 大附件会在装配完之后被整回合拦下,现象是用户看到一个 + // 没有上下文的 input_length_exceeded。这里明说一句,不静默。 + log.Warn("document inject budget exceeds guardrail input limit; large attachments will be blocked", + "env", config.GuardrailMaxInputCharsEnv, "guardrail_max_input_chars", limit, + "document_inject_chars", docInjectChars, + "hint", "调大本键或调小 DOCUMENT_MAX_INJECT_CHARS,使注入预算不超过输入上限") + } + return limit +} + // guardrailKeywords 返回部署方声明的内容策略词表(GUARDRAIL_KEYWORDS); // 未配置时返回 nil,管线里就不含关键词检查。 func (a *app) guardrailKeywords() []string { diff --git a/cmd/cyrene/wire_guardrail_limit_test.go b/cmd/cyrene/wire_guardrail_limit_test.go new file mode 100644 index 0000000..ed47066 --- /dev/null +++ b/cmd/cyrene/wire_guardrail_limit_test.go @@ -0,0 +1,293 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" +) + +// 本文件锁定切片 G2 的输入侧解耦(端到端实测抓到的真 bug:大附件被输入护栏拦死): +// +// - 输入上限 = max(256 Ki, 推导预算 token 数)(1 字符 ≈ 1 token 的保守口径); +// - GUARDRAIL_MAX_INPUT_CHARS 的三态(未设/0 = 派生、>0 = 覆盖、<0 = WARN 回落); +// - 关系不变量「文档注入预算 ≤ 输入护栏上限」,人为破坏它必须**有 WARN**; +// - 回归:注入正文比旧上限(262144 字符)还大时,回合**不得**以 guardrail_input 结束; +// - 反证:显式把上限收紧后,同一个大附件回合**真的**被拦(护栏没被削弱)。 +// +// 输出上限(guardrailMaxOutputChars)不属本切片:它防的是模型输出失控,与上下文 +// 预算无关,因此保持 65536 不动(回归见 TestGuardrailPipeline_LengthCapsOnlyCatchPathologicalPayloads)。 + +// largeDocChars 是回归夹具的附件正文长度:既大于旧上限 262144(否则复现不到 bug), +// 又大于 1M 窗口下的注入预算 349525(因此走「已截断 + 续读游标」那条路径, +// 与主人实测时历史 DTO 里 `"chars":349525,"truncated":true` 的形状一致)。 +const largeDocChars = 400000 + +// TestGuardrailInputLimit_DerivedFromBudget 钉住派生公式(表驱动): +// 输入上限 = max(下限, 推导预算 token 数) —— 小窗口部署落回下限,大窗口随预算抬高。 +func TestGuardrailInputLimit_DerivedFromBudget(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + tokens int + want int + }{ + {"1M 窗口推导出的预算(699050)⇒ 随之上抬", 699050, 699050}, + {"预算正好等于下限 ⇒ 下限", guardrailMinInputChars, guardrailMinInputChars}, + {"小预算(600)⇒ 回落下限", 600, guardrailMinInputChars}, + {"关闭压缩(0)⇒ 下限", 0, guardrailMinInputChars}, + {"负预算 ⇒ 下限", -1, guardrailMinInputChars}, + } + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + t.Parallel() + log, _ := capturingLog() + if got := deriveGuardrailInputLimit(log, 0, c.tokens, 0); got != c.want { + t.Fatalf("deriveGuardrailInputLimit(budget=%d) = %d, want %d", c.tokens, got, c.want) + } + }) + } +} + +// TestGuardrailInputLimit_OverrideAndIllegalValue 钉住 env 三态:>0 覆盖、<0 记 +// **恰好一条** WARN 后回落派生值(带原始取值,绝不静默)。 +func TestGuardrailInputLimit_OverrideAndIllegalValue(t *testing.T) { + t.Parallel() + + log, buf := capturingLog() + if got := deriveGuardrailInputLimit(log, 1000, 699050, 0); got != 1000 { + t.Fatalf("显式覆盖 = %d, want 1000(覆盖优先于派生)", got) + } + if n := countRecords(logRecords(t, buf), "WARN", "guardrail input limit invalid"); n != 0 { + t.Fatalf("合法覆盖却记了 %d 条 WARN", n) + } + + log, buf = capturingLog() + if got := deriveGuardrailInputLimit(log, -1, 699050, 0); got != 699050 { + t.Fatalf("非法覆盖后的回落 = %d, want 派生值 699050", got) + } + records := logRecords(t, buf) + if n := countRecords(records, "WARN", "guardrail input limit invalid"); n != 1 { + t.Fatalf("非法覆盖的 WARN 条数 = %d, want 恰好 1(记录:%v)", n, records) + } + // WARN 必须点出「改哪个键」与原始取值,否则部署方不知道该去改哪儿。 + found := false + for _, rec := range records { + if msg, _ := rec["msg"].(string); strings.Contains(msg, "guardrail input limit invalid") { + found = true + if rec["env"] != config.GuardrailMaxInputCharsEnv { + t.Fatalf("WARN 没点出配置键: %v", rec) + } + if rec["value"] != float64(-1) || rec["fallback"] != float64(699050) { + t.Fatalf("WARN 没带原始取值与回落值: %v", rec) + } + } + } + if !found { + t.Fatal("没有找到非法覆盖的 WARN") + } +} + +// TestGuardrailInputLimit_RelationInvariantWithInjectBudget 钉住关系不变量 +// 「文档注入预算 ≤ 输入护栏上限」:对 1M 窗口的推导值成立,且人为把上限设得比 +// 注入预算小 ⇒ **记一条 WARN**(说明「大附件会被护栏拦下」),不静默。 +func TestGuardrailInputLimit_RelationInvariantWithInjectBudget(t *testing.T) { + t.Parallel() + + // 1M 窗口的推导:预算 699050 token ⇒ 单份附件注入预算 349525 字符。 + a := &app{log: testLog(), cfg: &config.Config{}, llm: envWindowStack(t, 1048576, 0)} + budget := a.contextBudget(filepath.Join(t.TempDir(), "absent.env")) + if budget.DocumentInjectChars != 349525 { + t.Fatalf("1M 窗口下的注入预算 = %d, want 349525(推导链变了,本用例的前提需要复核)", + budget.DocumentInjectChars) + } + + log, buf := capturingLog() + limit := deriveGuardrailInputLimit(log, 0, budget.Budget.Tokens, budget.DocumentInjectChars) + if budget.DocumentInjectChars > limit { + t.Fatalf("关系不变量被破:注入预算 %d > 输入上限 %d", budget.DocumentInjectChars, limit) + } + if limit != 699050 { + t.Fatalf("1M 窗口下的输入上限 = %d, want 699050(= 推导预算 token 数)", limit) + } + if n := countRecords(logRecords(t, buf), "WARN", "exceeds guardrail input limit"); n != 0 { + t.Fatalf("不变量成立却记了 %d 条 WARN", n) + } + + // 人为破坏:把上限压到注入预算之下 ⇒ 必须有一条 WARN 明说后果。 + log, buf = capturingLog() + if got := deriveGuardrailInputLimit(log, 4096, budget.Budget.Tokens, budget.DocumentInjectChars); got != 4096 { + t.Fatalf("收紧后的上限 = %d, want 4096", got) + } + records := logRecords(t, buf) + if n := countRecords(records, "WARN", "exceeds guardrail input limit"); n != 1 { + t.Fatalf("不变量被破的 WARN 条数 = %d, want 恰好 1(记录:%v)", n, records) + } + found := false + for _, rec := range records { + if msg, _ := rec["msg"].(string); strings.Contains(msg, "exceeds guardrail input limit") { + found = true + if rec["document_inject_chars"] != float64(budget.DocumentInjectChars) || + rec["guardrail_max_input_chars"] != float64(4096) { + t.Fatalf("WARN 没写清两端取值: %v", rec) + } + } + } + if !found { + t.Fatal("没有找到关系不变量被破的 WARN") + } +} + +// largeAttachmentTurn 是「一个回合 + 一份大附件」的观测结果。 +type largeAttachmentTurn struct { + // result 是该回合的循环结论(StopReason / Text / Documents 都在这里)。 + result *loop.Result + // modelCalls 是模型被调用的次数(= 捕获到的请求数)。 + modelCalls int + // assembledChars 是模型实际看到的文本总量(装配后、注入正文已在内)。 + assembledChars int + // injectChars 是本回合如实记录的注入字符数(Result.Documents[0].Chars)。 + injectChars int +} + +// runLargeAttachmentTurn 跑一个回合:1M 窗口的推导预算(注入 349525 字符)+ +// 一份 largeDocChars 字符的 txt 附件 + 真实的护栏管线。inputLimitOverride 非 0 时 +// 模拟 GUARDRAIL_MAX_INPUT_CHARS 的覆盖态。 +// +// 三个角色都是生产实现:预算取自 a.contextBudget、注入走真实的 +// loop.resolveDocuments + documents.Resolver、护栏是 a.buildGuardrailPipeline 的产物。 +// 唯一被单独钉住的是「窗口 = 1M」。 +func runLargeAttachmentTurn(t *testing.T, inputLimitOverride int) largeAttachmentTurn { + t.Helper() + + dataDir := t.TempDir() + uploads := filepath.Join(dataDir, "uploads", "documents") + if err := os.MkdirAll(uploads, 0o700); err != nil { + t.Fatalf("建上传目录: %v", err) + } + body := strings.Repeat("abcdefghij", largeDocChars/10) + if err := os.WriteFile(filepath.Join(uploads, docE2EName), []byte(body), 0o600); err != nil { + t.Fatalf("写上传件: %v", err) + } + + cfg := &config.Config{DataDir: dataDir} + cfg.Guardrail.MaxInputChars = inputLimitOverride + a := &app{log: testLog(), cfg: cfg, llm: envWindowStack(t, 1048576, 0)} + budget := a.contextBudget(filepath.Join(t.TempDir(), "absent.env")) + guards := a.buildGuardrailPipeline(budget) + + adapter := &compactProbeAdapter{} + ag, err := agent.New("你是昔涟。", nil, guards, agent.WithModel(adapter, nil)) + if err != nil { + t.Fatalf("agent.New: %v", err) + } + l := loop.New(loop.Options{ + Agent: ag, + Guards: guards, + DocumentResolver: documents.NewResolver(documents.Config{DataDir: dataDir, UploadsDir: uploads}), + DocumentMaxInjectChars: budget.DocumentInjectChars, + OnStream: func(model.StreamEvent) {}, + }) + msg := model.DocumentMessage(model.RoleUser, "看看这个附件", + model.Document{Name: docE2EName, MIME: "text/plain", Size: int64(len(body))}) + res, err := l.Run(context.Background(), "sess-large-doc", []model.Message{msg}) + if err != nil { + t.Fatalf("Run: %v", err) + } + + turn := largeAttachmentTurn{result: res, modelCalls: len(adapter.reqs)} + if len(res.Documents) > 0 { + turn.injectChars = res.Documents[0].Chars + } + if len(adapter.reqs) > 0 { + req := adapter.reqs[len(adapter.reqs)-1] + for _, m := range req.Messages { + turn.assembledChars += len(loopText(m)) + } + } + return turn +} + +// TestGuardrailWiring_LargeAttachmentPassesInputGuard 是**回归**,对准主人端到端 +// 实测抓到的那个真 bug:`POST /api/v1/chat` 带一个 744 KB 的 txt 附件 ⇒ +// `{"reply":"input_length_exceeded","stopReason":"guardrail_input"}` —— 历史 DTO 里 +// 已如实写下 `"chars":349525,"truncated":true`,工具 document-read 也正常, +// 是回合在**输入护栏**这一层就没被放进去,「分享大文件」这条主线因此被打死。 +// +// 这里在进程内复现同一形状,断言它不再发生:注入正文比旧上限还大,但回合照常 +// 抵达模型(adapter 被调用一次)并以正常回答结束。 +func TestGuardrailWiring_LargeAttachmentPassesInputGuard(t *testing.T) { + t.Parallel() + + turn := runLargeAttachmentTurn(t, 0) + + // ① bug 的症状先说清楚:回合不得以 guardrail_input 结束,也不得被拦在模型之前。 + if turn.result.StopReason == agent.StopReasonGuardrailInput { + t.Fatalf("大附件回合仍以 guardrail_input 结束(这正是被修的 bug):%q", turn.result.Text) + } + if turn.modelCalls != 1 { + t.Fatalf("大附件回合的模型调用次数 = %d, want 1(护栏把回合拦在模型之前了)", turn.modelCalls) + } + // ② 前提自证:这一回合真的注入了 349525 字符(= 主人实测 DTO 里那个数), + // 比旧上限 262144 大,所以上面那条断言不是碰巧成立。 + if turn.injectChars != 349525 { + t.Fatalf("注入字符数 = %d, want 349525(1M 窗口的注入预算;形状变了要复核本用例)", + turn.injectChars) + } + if turn.assembledChars <= guardrailMinInputChars { + t.Fatalf("装配后的请求只有 %d 字符(≤ 旧上限 %d),复现不到「大附件被拦死」", + turn.assembledChars, guardrailMinInputChars) + } + if !turn.result.Documents[0].Truncated { + t.Fatal("清单没有如实标注注入被截断(design/24 §4.3)") + } +} + +// TestGuardrailWiring_TightInputLimitStillBlocks 是**护栏没被削弱**的自证: +// 同一个大附件回合,只要显式把输入上限压到 1000 字符(GUARDRAIL_MAX_INPUT_CHARS +// 的覆盖态),回合必须真的被拦成 guardrail_input,且**在模型调用之前**拦下。 +// +// 没有这条,上面那条回归有可能是「把输入侧长度检查放空了」才通过的。 +func TestGuardrailWiring_TightInputLimitStillBlocks(t *testing.T) { + t.Parallel() + + turn := runLargeAttachmentTurn(t, 1000) + + if turn.result.StopReason != agent.StopReasonGuardrailInput { + t.Fatalf("收紧上限后 StopReason = %q, want %q", turn.result.StopReason, agent.StopReasonGuardrailInput) + } + if !strings.Contains(turn.result.Text, "input_length_exceeded") { + t.Fatalf("收紧上限后的回答 = %q, want 含 input_length_exceeded", turn.result.Text) + } + if turn.modelCalls != 0 { + t.Fatalf("被拦的回合仍调用了模型 %d 次 —— 输入侧不是前置门", turn.modelCalls) + } + // 注入本身照旧发生(记录如实),被拦是**上限**那一侧的判决。 + if turn.injectChars != 349525 { + t.Fatalf("注入字符数 = %d, want 349525(注入事实不因上限收紧而改写)", turn.injectChars) + } +} + +// TestGuardrailInputLimit_NotAFunctionOfInjectBudget 是变异反证:注入预算单独变化 +// **不影响**输入上限(上限只由上下文预算与显式覆盖决定),因此「注入预算 ≤ 上限」 +// 不可能靠两边同源凑出来 —— 两端确实各自有出处。 +func TestGuardrailInputLimit_NotAFunctionOfInjectBudget(t *testing.T) { + t.Parallel() + + log, _ := capturingLog() + if got := deriveGuardrailInputLimit(log, 0, 699050, 0); got != 699050 { + t.Fatalf("注入预算为 0 时的输入上限 = %d, want 699050", got) + } + if got := deriveGuardrailInputLimit(log, 0, 699050, 349525); got != 699050 { + t.Fatalf("注入预算 349525 时的输入上限 = %d, want 699050(不受注入预算影响)", got) + } +} diff --git a/cmd/cyrene/wire_guardrail_test.go b/cmd/cyrene/wire_guardrail_test.go index 9e332c2..5704377 100644 --- a/cmd/cyrene/wire_guardrail_test.go +++ b/cmd/cyrene/wire_guardrail_test.go @@ -39,6 +39,11 @@ func (a *guardProbeAdapter) ChatStream(ctx context.Context, req model.Request, o return a.Chat(ctx, req, model.Options{}) } +// noWindowBudget 是「没声明窗口的小部署」的预算形状:压缩预算与注入预算两项 +// 推导值都是 0,于是派生的输入上限落回下限 guardrailMinInputChars —— 也就是 +// 接线前那个硬编码值的等价场景。只关心「护栏里有什么」的用例传它即可。 +func noWindowBudget() ctxBudget { return ctxBudget{} } + // guardrailApp 返回带给定关键词表的组合根切片;cfg 为 nil 表示「一点配置都没有」。 func guardrailApp(t *testing.T, keywords []string) *app { t.Helper() @@ -84,7 +89,7 @@ func truncate(s string, n int) string { func TestGuardrailPipeline_NormalConversationPasses(t *testing.T) { t.Parallel() - p := guardrailApp(t, nil).buildGuardrailPipeline() + p := guardrailApp(t, nil).buildGuardrailPipeline(noWindowBudget()) req := model.Request{Messages: []model.Message{ model.TextMessage(model.RoleSystem, "你是昔涟,温柔俏皮的小女友。"), @@ -108,7 +113,7 @@ func TestGuardrailPipeline_NormalConversationPasses(t *testing.T) { func TestGuardrailPipeline_BlocksSensitiveInfo(t *testing.T) { t.Parallel() - p := guardrailApp(t, nil).buildGuardrailPipeline() + p := guardrailApp(t, nil).buildGuardrailPipeline(noWindowBudget()) secret := "sk-abcdefghijklmnopqrstuvwxyz0123456789" cases := []struct { @@ -145,10 +150,21 @@ func TestGuardrailPipeline_BlocksSensitiveInfo(t *testing.T) { // TestGuardrailPipeline_LengthCapsOnlyCatchPathologicalPayloads 锁定长度上限的 // 边界:恰好等于上限放行、超一个字符就被拦;并且「很长但正常」的上下文不被误伤。 +// +// 这里用的是 noWindowBudget(小窗口部署):派生的输入上限落回下限 +// guardrailMinInputChars,也就是接线前那个 262144 的行为。 func TestGuardrailPipeline_LengthCapsOnlyCatchPathologicalPayloads(t *testing.T) { t.Parallel() - p := guardrailApp(t, nil).buildGuardrailPipeline() + p := guardrailApp(t, nil).buildGuardrailPipeline(noWindowBudget()) + if limit := guardrailApp(t, nil).guardrailInputLimit(noWindowBudget()); limit != guardrailMinInputChars { + t.Fatalf("小窗口部署的输入上限 = %d, want 下限 %d", limit, guardrailMinInputChars) + } + // 输出上限是**另一个**数:它防的是模型输出失控,与上下文预算无关, + // 因此本切片只动输入侧,输出侧不得顺手放宽(回归钉住取值本身)。 + if guardrailMaxOutputChars != 65536 { + t.Fatalf("输出上限 = %d, want 65536(本切片只动输入侧)", guardrailMaxOutputChars) + } if v := checkOutput(t, p, strings.Repeat("a", guardrailMaxOutputChars)); !v.Allow { t.Fatalf("恰好到上限的输出被拦:%+v", v) @@ -170,7 +186,7 @@ func TestGuardrailPipeline_LengthCapsOnlyCatchPathologicalPayloads(t *testing.T) } // 超过上限则被拦。 over := model.Request{Messages: []model.Message{ - model.TextMessage(model.RoleUser, strings.Repeat("a", guardrailMaxInputChars+1)), + model.TextMessage(model.RoleUser, strings.Repeat("a", guardrailMinInputChars+1)), }} if v, _ := p.CheckInput(context.Background(), &over); v.Allow { t.Fatal("超出输入上限的载荷却放行") @@ -185,11 +201,11 @@ func TestGuardrailPipeline_KeywordsOnlyWhenConfigured(t *testing.T) { t.Parallel() text := "这段文本里出现了违禁词。" - if v := checkInput(t, guardrailApp(t, nil).buildGuardrailPipeline(), text); !v.Allow { + if v := checkInput(t, guardrailApp(t, nil).buildGuardrailPipeline(noWindowBudget()), text); !v.Allow { t.Fatalf("未配置词表却拦下了文本:%+v", v) } - configured := guardrailApp(t, []string{"违禁词"}).buildGuardrailPipeline() + configured := guardrailApp(t, []string{"违禁词"}).buildGuardrailPipeline(noWindowBudget()) v := checkInput(t, configured, text) if v.Allow { t.Fatal("配置了词表却没拦下命中文本") @@ -235,7 +251,7 @@ func TestGuardrailPipeline_RealPromptPasses(t *testing.T) { t.Fatalf("测试前提不成立:真实身份说明书没被读进来:%q", instructions) } - pipeline := a.buildGuardrailPipeline() + pipeline := a.buildGuardrailPipeline(noWindowBudget()) req := model.Request{Messages: []model.Message{ model.TextMessage(model.RoleSystem, instructions), model.TextMessage(model.RoleUser, "亲爱的,今天想我了没?"), @@ -256,7 +272,7 @@ func TestGuardrailPipeline_RealPromptPasses(t *testing.T) { func TestGuardrailWiring_EndToEnd(t *testing.T) { t.Parallel() - guards := guardrailApp(t, nil).buildGuardrailPipeline() + guards := guardrailApp(t, nil).buildGuardrailPipeline(noWindowBudget()) adapter := &guardProbeAdapter{reply: "我在呢♪"} ag, err := agent.New("你是昔涟。", nil, guards, agent.WithModel(adapter, nil)) if err != nil { diff --git a/cmd/cyrene/wire_runtime.go b/cmd/cyrene/wire_runtime.go index a96715d..76a08de 100644 --- a/cmd/cyrene/wire_runtime.go +++ b/cmd/cyrene/wire_runtime.go @@ -152,9 +152,19 @@ func (a *app) wireRuntime() error { } a.wireVision() + // 上下文溢出压缩:预算按模型窗口推导(CONTEXT_MAX_TOKENS 未设置时; + // 显式 >0 = 覆盖、0/负 = 关闭),推导与「窗口未声明」的 WARN 都在 + // wire_context.go 一处完成。Compactor 不注入,由 loop 选用内置的结构化 + // compactor(离线、不调模型),并按同一份注入预算估算文档部件的代价。 + // + // 它必须在护栏**之前**算出来:请求侧字符上限是这份预算的函数(切片 G2: + // 输入上限 = max(256 Ki, 推导预算 token 数)),否则 1M 窗口下 349525 字符的 + // 附件注入会先被硬编码的 262144 上限整回合拦死。 + ctxBudget := a.contextBudget(envPath) + // 默认护栏策略在 buildGuardrailPipeline 里集中定义(敏感信息阻断 + // 长度上限 + 仅当配置提供时的关键词表,见 wire_guardrail.go)。 - guards := a.buildGuardrailPipeline() + guards := a.buildGuardrailPipeline(ctxBudget) instructions := a.defaultInstructions() // 默认角色的 agent 也要绑它自己的 persona 视图:models.json 的 personaModels / // personaProviders 可能给默认角色单独指定模型或提供商,用裸栈会漏掉这一层 @@ -252,12 +262,6 @@ func (a *app) wireRuntime() error { approver := audit.NewApprover(a.log) approvalGate := a.buildApprovalGate(approver) - // 上下文溢出压缩:预算按模型窗口推导(CONTEXT_MAX_TOKENS 未设置时; - // 显式 >0 = 覆盖、0/负 = 关闭),推导与「窗口未声明」的 WARN 都在 - // wire_context.go 一处完成。Compactor 不注入,由 loop 选用内置的结构化 - // compactor(离线、不调模型),并按同一份注入预算估算文档部件的代价。 - ctxBudget := a.contextBudget(envPath) - a.loop = loop.New(loop.Options{ Agent: defaultAgent, Agents: agents, diff --git a/internal/config/guardrail.go b/internal/config/guardrail.go index 93a42a7..97e3245 100644 --- a/internal/config/guardrail.go +++ b/internal/config/guardrail.go @@ -1,11 +1,24 @@ package config -// GuardrailConfig 承载内容护栏里**由部署方声明**的那一部分(design/04 §5)。 +// 本文件承载内容护栏里**由部署方声明**的那一部分(design/04 §5)。 // -// 敏感信息阻断档与长度上限是代码内定的默认策略(见组合根的 -// buildGuardrailPipeline),不对外开放配置:它们是安全默认值,不该被 -// 一次误配置关掉。这里只放内容策略词表 —— 它取决于部署方的场景, -// 项目不内置任何词表。 +// 敏感信息阻断档是代码内定的安全默认值(见组合根的 buildGuardrailPipeline), +// 不对外开放配置:它不该被一次误配置关掉。这里放的是两项**与部署环境相关**的 +// 取值 —— 内容策略词表,以及请求侧字符上限的显式覆盖。 +// +// 请求侧字符上限为什么必须能配:它的默认值由**推导出的上下文预算**派生 +// (预算被抬高到 1M 窗口能吃的量级后,硬编码的 256 Ki 字符反而成了瓶颈, +// 会把「分享大文件」整条主线打成 guardrail_input 结束,见 +// cmd/cyrene/wire_guardrail.go 的说明)。派生值已经能自适应窗口,本键只是 +// 给需要收紧或放宽的部署留一个显式落点。 + +// GuardrailMaxInputCharsEnv 是请求侧字符上限的配置键名。 +// +// 它必须与 GuardrailConfig.MaxInputChars 的 env 标签**逐字一致**:WARN 文案要能 +// 直接点出「改哪个键」,因此键名只能有一处出处(一致性由 guardrail_test.go 钉住)。 +const GuardrailMaxInputCharsEnv = "GUARDRAIL_MAX_INPUT_CHARS" + +// GuardrailConfig 承载内容护栏里由部署方声明的取值(design/04 §5)。 type GuardrailConfig struct { // Keywords 是内容策略关键词表(GUARDRAIL_KEYWORDS,逗号分隔)。 // @@ -13,4 +26,21 @@ type GuardrailConfig struct { // 只会误伤正常对话,且没有任何部署方能对它的命中负责。只有显式声明过 // 的词才会被拦(大小写不敏感的子串匹配)。 Keywords []string `env:"GUARDRAIL_KEYWORDS"` + + // MaxInputChars 是**请求侧**字符上限的显式覆盖(GUARDRAIL_MAX_INPUT_CHARS)。 + // 三态: + // + // - 未设置 / 0(默认)= 按推导出的上下文预算派生: + // 输入上限 = max(256 Ki, 推导预算 token 数)(1 字符 ≈ 1 token 的保守口径); + // - > 0 = 显式覆盖(原样使用); + // - < 0 = 非法:记一条 WARN 并回落派生值,绝不静默生效(AGENTS §10)。 + // + // 派生态下「文档注入预算 ≤ 输入上限」这条关系不变量恒成立(1M 窗口 ⇒ + // 注入 349525 字符 ≤ 上限 699050 字符);显式覆盖把它设得比注入预算小时, + // 组合根记一条 WARN 明说「大附件会被护栏拦下」,而不是等到用户上传大文件 + // 时才以一个没有上下文的 input_length_exceeded 表现出来。 + // + // 它只动**输入**侧:输出上限(65536)防的是模型输出失控,与上下文预算无关, + // 因此不随本键变化、也不开放配置。 + MaxInputChars int `env:"GUARDRAIL_MAX_INPUT_CHARS"` } diff --git a/internal/config/guardrail_test.go b/internal/config/guardrail_test.go new file mode 100644 index 0000000..2c68a1d --- /dev/null +++ b/internal/config/guardrail_test.go @@ -0,0 +1,68 @@ +package config + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +// 本文件锁定 GUARDRAIL_MAX_INPUT_CHARS 的配置侧契约(切片 G2):键名一致、三态可解。 +// 派生与两条 WARN 的落点在组合根(cmd/cyrene/wire_guardrail.go 的 +// deriveGuardrailInputLimit),这里只保证「键真的能配上、值真的能进来」。 + +// TestGuardrailMaxInputCharsEnvMatchesTag 钉住键名与 env 标签**逐字一致**: +// 组合根的 WARN 用 GuardrailMaxInputCharsEnv 点出「改哪个键」,两处一旦漂移, +// 日志就会把部署方指到不存在的键上。 +func TestGuardrailMaxInputCharsEnvMatchesTag(t *testing.T) { + t.Parallel() + + field, ok := reflect.TypeOf(GuardrailConfig{}).FieldByName("MaxInputChars") + if !ok { + t.Fatal("GuardrailConfig 里没有 MaxInputChars 字段") + } + if got := field.Tag.Get("env"); got != GuardrailMaxInputCharsEnv { + t.Fatalf("MaxInputChars 的 env 标签 = %q, want %q", got, GuardrailMaxInputCharsEnv) + } +} + +// TestLoad_GuardrailMaxInputCharsTriState 钉住三态取值真的能配进来: +// 未设置 ⇒ 0(= 按推导预算派生);进程环境或 .env 给出正数 ⇒ 覆盖值; +// 负数 ⇒ **原样**进来(非法判定在组合根,那里负责记 WARN 并回落,不在这一层报错, +// 免得一个笔误让整个进程起不来)。 +func TestLoad_GuardrailMaxInputCharsTriState(t *testing.T) { + dir := t.TempDir() + t.Setenv("JWT_SECRET", "0123456789012345678901234567890123456789") + unsetenvForTest(t, GuardrailMaxInputCharsEnv) + + cfg, err := Load(filepath.Join(dir, "absent.env")) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Guardrail.MaxInputChars != 0 { + t.Fatalf("未配置时 = %d, want 0(0 = 按推导预算派生)", cfg.Guardrail.MaxInputChars) + } + + // .env 里写了也算配上(与进程环境同一优先级链)。 + envFile := filepath.Join(dir, "test.env") + if err := os.WriteFile(envFile, []byte(GuardrailMaxInputCharsEnv+"=1000\n"), 0o600); err != nil { + t.Fatalf("写 .env: %v", err) + } + cfg, err = Load(envFile) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Guardrail.MaxInputChars != 1000 { + t.Fatalf(".env 配置时 = %d, want 1000", cfg.Guardrail.MaxInputChars) + } + + // 负数原样进来:非法值的处理在组合根(WARN + 回落派生值),这一层不拦。 + t.Setenv(GuardrailMaxInputCharsEnv, "-1") + cfg, err = Load(envFile) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Guardrail.MaxInputChars != -1 { + t.Fatalf("负数配置时 = %d, want -1(原样进来,判定在组合根)", cfg.Guardrail.MaxInputChars) + } +} diff --git a/internal/runtime/exec/terminal_test.go b/internal/runtime/exec/terminal_test.go index 4c13779..0b50478 100644 --- a/internal/runtime/exec/terminal_test.go +++ b/internal/runtime/exec/terminal_test.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" "testing" "time" @@ -205,21 +206,65 @@ func TestTerminal_WorkDir_OutsideRejected(t *testing.T) { } } +// TestTerminal_Timeout_Killed 校验超时到点后**子进程真的被杀**,而不是被等到自然结束。 +// +// 为什么不能拿「~200ms 的精度」当判据(改动前基线:三进程并行整树 2/9 红, +// `timeout took 3.8202774s, want ~200ms (process not killed)`):那条断言测的是 +// 「起进程 → 到点杀进程 → 收尸」的墙钟总和,而宿主重压之下**进程创建**这一段本身就 +// 能吃掉整个时限(本切片勘察实测:重压下 cmd.Start 直接返回 context deadline +// exceeded,且已到「启动即超时」的量级),于是耗时断言退化成「宿主此刻有多忙」的 +// 代理变量 —— 红行却报「process not killed」,指向一个并不存在的生产缺陷。 +// +// 现在按语义分两层断言,墙钟只负责区分「被杀」与「被等到自然结束」: +// - ① 控制组:同一个 sleep 子命令在不设短超时时**自己跑完并以 0 退出**; +// 因此超时组那次拿到的非 nil waitErr 只可能来自「到点被杀」——这是与宿主 +// 负载无关的确定性判别,也正是本用例名字要断言的事实。 +// - ② 超时组:Timeout 到点 ⇒ ErrTimeout,且耗时远小于子进程的自然存活期 +// (判别界 10s ≪ 30s:宿主即便卡到秒级也不会误判成红;而「没被杀」会一路 +// 跑到 30s,落界之外必红)。 func TestTerminal_Timeout_Killed(t *testing.T) { t.Parallel() requireBin(t, "sleep") ex := newEnabledExecutor(t, enabledCfg("sleep", t.TempDir())) - start := time.Now() - _, err := ex.Execute(context.Background(), ExecRequest{ - Command: []string{"sleep", "5"}, - Timeout: 200 * time.Millisecond, + const ( + // childSleepSeconds 是子进程的自然存活期(秒):必须远大于任何宿主负载噪声, + // 「进程没被杀」才会落进判别界之外。 + childSleepSeconds = 30 + // killDeadline 是超时时间:远小于自然存活期,到点必须被杀。 + killDeadline = 200 * time.Millisecond + // controlTimeout 是控制组的超时:只要求子命令能跑完,故留足余量。 + controlTimeout = 30 * time.Second + // killProofBound 是「被杀」与「被等到自然结束」的判别界:耗时超过它, + // 就说明这一回合是等到了子进程自己结束,而不是被超时杀掉。 + killProofBound = 10 * time.Second + ) + + // ① 控制组:确认这个子命令本来就以 0 正常退出,下面的推论才成立。 + control, err := ex.Execute(context.Background(), ExecRequest{ + Command: []string{"sleep", "0.05"}, + Timeout: controlTimeout, }) + if err != nil { + t.Fatalf("控制组 Execute: %v(子命令自己都跑不完,超时组的结论便不成立)", err) + } + if control.ExitCode != 0 { + t.Fatalf("控制组 ExitCode = %d, want 0(sleep 正常结束本就该是 0)", control.ExitCode) + } + + // ② 超时组:到点必须被杀,绝不等到它自己结束。 + start := time.Now() + _, err = ex.Execute(context.Background(), ExecRequest{ + Command: []string{"sleep", strconv.Itoa(childSleepSeconds)}, + Timeout: killDeadline, + }) + elapsed := time.Since(start) if !errors.Is(err, ErrTimeout) { t.Fatalf("err = %v, want ErrTimeout", err) } - if elapsed := time.Since(start); elapsed > 3*time.Second { - t.Errorf("timeout took %v, want ~200ms (process not killed)", elapsed) + if elapsed >= killProofBound { + t.Fatalf("超时回合耗时 %v(判别界 %v,子进程自然存活期 %ds):进程没有被杀,而是被等到了自然结束", + elapsed, killProofBound, childSleepSeconds) } }