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)