From ea3a5026ad76028562ee4aa6b70c79c94fb17d4b Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 21 Sep 2026 03:03:59 +0800 Subject: fix(api): 加固行情采集子进程超时、取消与进程组清理并脱敏错误 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MarketDataService: setsid 独立会话启动采集器,硬超时覆盖子进程退出与管道 EOF 两个阶段;超时、取消与异常路径按进程组杀灭(含持有管道的后台子进程),不遗留孤儿进程 - App.ts/Search/RefreshNav 传入 ctx.RequestAborted,客户端断开即取消采集 - 对外错误仅返回统一短消息(503 MARKET_DATA_UNAVAILABLE 等),不泄露路径、stderr 或密钥;stderr 仅记录服务端日志 - 新增 9 项子进程回归(成功/stderr 洪泛/超时杀灭/取消杀灭/孤儿管道恢复/畸形载荷/非零退出/启动失败/缺脚本)与 HTTP 脱敏回归;全文套件 66 通过(Domain 19 / Web 17 / API 30) - 实机验证:挂起采集器 503 2.4s 无进程残留;真实 akshare 1.18.96 搜索 000001 返回 200,净值刷新持久化 6010 条观测 - README 与 qa/README 补充环境变量、回归命令、G1 过滤断言口径与真实建档标签辨析 --- tests/FundLab.Api.Tests/ProcessCollectorTests.fs | 232 +++++++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 tests/FundLab.Api.Tests/ProcessCollectorTests.fs (limited to 'tests/FundLab.Api.Tests/ProcessCollectorTests.fs') diff --git a/tests/FundLab.Api.Tests/ProcessCollectorTests.fs b/tests/FundLab.Api.Tests/ProcessCollectorTests.fs new file mode 100644 index 0000000..32009bf --- /dev/null +++ b/tests/FundLab.Api.Tests/ProcessCollectorTests.fs @@ -0,0 +1,232 @@ +namespace FundLab.Api.Tests + +module ProcessCollectorTests = + + open System + open System.Diagnostics + open System.IO + open System.Threading + open Xunit + open FundLab.Api + + let private secretSentinel = "fund-lab-SECRET-SENTINEL" + + let private fixtureDir () = + let dir = Path.Combine(Path.GetTempPath(), "fund-lab-collector-" + Guid.NewGuid().ToString("N")) + + Directory.CreateDirectory(dir) |> ignore + + dir + + let private writeScript (dir: string) (name: string) (body: string) = + let path = Path.Combine(dir, name) + File.WriteAllText(path, body) + path + + let private searchEnvelope = + "{\"schema_version\":\"fund-lab.akshare.v1\",\"operation\":\"search\",\"source\":\"fixture\",\"source_revision\":\"fixture/1\",\"collected_at\":\"2026-09-21T00:00:00Z\",\"_synthetic\":true,\"instruments\":[{\"code\":\"000001\",\"name\":\"fixture fund\",\"fund_type\":null}]}" + + let private hangScript dir = + writeScript dir "hang.sh" ("sleep 60 & child=$!\necho $child > pidfile\nwait $child\n") + + let private orphanScript dir = + writeScript + dir + "orphan.sh" + ("sleep 30 & child=$!\necho $child > pidfile\nprintf '%s' '" + searchEnvelope + "'\nexit 0\n") + + let private recordedPid (dir: string) = + Int32.Parse(File.ReadAllText(Path.Combine(dir, "pidfile")).Trim()) + + let private pidAlive (pid: int) = Directory.Exists(sprintf "/proc/%d" pid) + + let private waitForPidDeath (pid: int) = + let mutable dead = not (pidAlive pid) + let mutable waited = 0 + + while not dead && waited < 10000 do + Thread.Sleep(100) + waited <- waited + 100 + dead <- not (pidAlive pid) + + dead + + [] + let ``collector captures stdout from a successful subprocess`` () = + let dir = fixtureDir () + + try + let script = writeScript dir "success.sh" ("printf '%s' '" + searchEnvelope + "'") + let collector = ProcessMarketDataCollector("/bin/sh", script, None, 30) :> IMarketDataCollector + + match collector.Search("000001", CancellationToken.None) with + | Ok raw -> + Assert.Contains("000001", raw) + Assert.Contains("\"_synthetic\":true", raw) + | Error message -> Assert.Fail(sprintf "expected success, got %s" message) + finally + Directory.Delete(dir, true) + + [] + let ``collector drains concurrent stderr flood without deadlocking`` () = + let dir = fixtureDir () + + try + let body = "yes 'flood padding padding padding padding' | head -n 5000 >&2\nprintf '%s' '" + searchEnvelope + "'" + + let script = writeScript dir "flood.sh" body + + let collector = ProcessMarketDataCollector("/bin/sh", script, None, 30) :> IMarketDataCollector + + match collector.Search("000001", CancellationToken.None) with + | Ok raw -> Assert.Contains("000001", raw) + | Error message -> Assert.Fail(sprintf "expected success, got %s" message) + finally + Directory.Delete(dir, true) + + [] + let ``collector enforces deadline, kills owned processes and sanitizes error`` () = + let dir = fixtureDir () + + try + let script = hangScript dir + let collector = ProcessMarketDataCollector("/bin/sh", script, None, 1) :> IMarketDataCollector + let stopwatch = Stopwatch.StartNew() + + match collector.Search("000001", CancellationToken.None) with + | Error message -> + stopwatch.Stop() + Assert.Contains("did not finish within 1 seconds", message) + Assert.DoesNotContain(dir, message) + Assert.DoesNotContain(secretSentinel, message) + | Ok raw -> Assert.Fail(sprintf "expected timeout error, got %s" raw) + + Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(20.0)) + Assert.True(waitForPidDeath (recordedPid dir), "backgrounded subprocess survived the deadline kill") + finally + Directory.Delete(dir, true) + + [] + let ``collector reports cancellation and kills owned processes`` () = + let dir = fixtureDir () + + try + let script = hangScript dir + let collector = ProcessMarketDataCollector("/bin/sh", script, None, 30) :> IMarketDataCollector + use cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500.0)) + + match collector.Search("000001", cts.Token) with + | Error message -> + Assert.Equal("collector run was cancelled", message) + Assert.DoesNotContain(dir, message) + Assert.DoesNotContain(secretSentinel, message) + | Ok raw -> Assert.Fail(sprintf "expected cancellation, got %s" raw) + + Assert.True(waitForPidDeath (recordedPid dir), "backgrounded subprocess survived cancellation") + finally + Directory.Delete(dir, true) + + [] + let ``collector recovers payload and reaps pipe-holding orphan after parent exit`` () = + let dir = fixtureDir () + + try + let script = orphanScript dir + let collector = ProcessMarketDataCollector("/bin/sh", script, None, 5) :> IMarketDataCollector + let stopwatch = Stopwatch.StartNew() + + let result = collector.Search("000001", CancellationToken.None) + stopwatch.Stop() + + match result with + | Ok raw -> + Assert.Equal(searchEnvelope, raw) + Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(20.0)) + | Error message -> + Assert.Fail(sprintf "expected recovered payload, got error %s" message) + + Assert.True(waitForPidDeath (recordedPid dir), "pipe-holding orphan survived parent exit") + finally + Directory.Delete(dir, true) + + [] + let ``collector surfaces malformed payload for parser to reject`` () = + let dir = fixtureDir () + + try + let script = writeScript dir "malformed.sh" "printf '%s' '{ not json at all'" + let collector = ProcessMarketDataCollector("/bin/sh", script, None, 30) :> IMarketDataCollector + + match collector.Search("000001", CancellationToken.None) with + | Ok raw -> + Assert.Equal("{ not json at all", raw) + + match MarketData.parseSearchPayload raw with + | Error _ -> () + | Ok _ -> Assert.Fail("expected malformed payload to fail parsing") + | Error message -> Assert.Fail(sprintf "expected raw output, got error %s" message) + finally + Directory.Delete(dir, true) + + [] + let ``nonzero exit keeps stderr sentinel and paths out of the public error`` () = + let dir = fixtureDir () + + try + let body = + "printf 'secret=%s\\n' '" + + secretSentinel + + "' >&2\nprintf 'script lives under " + + dir + + "\\n' >&2\nexit 3\n" + + let script = writeScript dir "failing.sh" body + let collector = ProcessMarketDataCollector("/bin/sh", script, None, 30) :> IMarketDataCollector + + match collector.Search("000001", CancellationToken.None) with + | Error message -> + Assert.Equal("collector exited with code 3", message) + Assert.DoesNotContain(secretSentinel, message) + Assert.DoesNotContain(dir, message) + | Ok raw -> Assert.Fail(sprintf "expected failure, got %s" raw) + finally + Directory.Delete(dir, true) + + [] + let ``start failure keeps executable path out of the public error`` () = + let dir = fixtureDir () + + try + let script = writeScript dir "success.sh" ("printf '%s' '" + searchEnvelope + "'") + let missingExecutable = "/nonexistent-" + secretSentinel + "/python3" + + let collector = ProcessMarketDataCollector(missingExecutable, script, None, 30) :> IMarketDataCollector + + match collector.Search("000001", CancellationToken.None) with + | Error message -> + let sanitized = + message = "could not run the AKShare collector" + || message.StartsWith("collector exited with code") + + Assert.True(sanitized, sprintf "unexpected error message: %s" message) + Assert.DoesNotContain(secretSentinel, message) + Assert.DoesNotContain(dir, message) + | Ok raw -> Assert.Fail(sprintf "expected failure, got %s" raw) + finally + Directory.Delete(dir, true) + + [] + let ``missing collector script yields sanitized error without paths`` () = + let dir = fixtureDir () + + try + let script = Path.Combine(dir, "missing.py") + let collector = ProcessMarketDataCollector("/bin/sh", script, None, 30) :> IMarketDataCollector + + match collector.Search("000001", CancellationToken.None) with + | Error message -> + Assert.Equal("collector script is not deployed", message) + Assert.DoesNotContain(dir, message) + | Ok raw -> Assert.Fail(sprintf "expected error, got %s" raw) + finally + Directory.Delete(dir, true) -- cgit v1.2.3