diff --git a/backend/tests/MoShiro.Backend.Tests/AddressTests.cs b/backend/tests/MoShiro.Backend.Tests/AddressTests.cs
index b08b4e8..b7c6730 100644
--- a/backend/tests/MoShiro.Backend.Tests/AddressTests.cs
+++ b/backend/tests/MoShiro.Backend.Tests/AddressTests.cs
@@ -8,8 +8,7 @@ using MoShiro.TestSupport;
namespace MoShiro.Backend.Tests;
/// public_host 和 ipv6_host 自动识别:Agent 上报公网地址,手填的优先,可以撤销手填。
-[Collection("probe-e2e")]
-public sealed class AddressTests
+public sealed class AddressTests : IClassFixture
{
private const string V4 = "8.8.4.4";
private const string V6 = "2606:4700:4700::1111";
diff --git a/backend/tests/MoShiro.Backend.Tests/AgentEndToEndTests.cs b/backend/tests/MoShiro.Backend.Tests/AgentEndToEndTests.cs
index b55c441..09746c8 100644
--- a/backend/tests/MoShiro.Backend.Tests/AgentEndToEndTests.cs
+++ b/backend/tests/MoShiro.Backend.Tests/AgentEndToEndTests.cs
@@ -6,8 +6,7 @@ using MoShiro.TestSupport;
namespace MoShiro.Backend.Tests;
/// 真实的后端 + 真实的 Agent(进程内)+ 假 Probe:覆盖 Agent 与 Backend 的链路、Probe 与 Agent 的点对点会话和数据面。
-[Collection("backend")]
-public sealed class AgentEndToEndTests
+public sealed class AgentEndToEndTests : IClassFixture
{
private readonly BackendFixture _backend;
diff --git a/backend/tests/MoShiro.Backend.Tests/ApiBasicsTests.cs b/backend/tests/MoShiro.Backend.Tests/ApiBasicsTests.cs
index b7a7625..20b4f0a 100644
--- a/backend/tests/MoShiro.Backend.Tests/ApiBasicsTests.cs
+++ b/backend/tests/MoShiro.Backend.Tests/ApiBasicsTests.cs
@@ -4,8 +4,7 @@ using System.Text.Json.Nodes;
namespace MoShiro.Backend.Tests;
-[Collection("backend")]
-public sealed class ApiBasicsTests
+public sealed class ApiBasicsTests : IClassFixture
{
private readonly BackendFixture _backend;
@@ -226,9 +225,3 @@ public sealed class ApiBasicsTests
Assert.Contains("moshiro_analysis_queue_length", text);
}
}
-
-[CollectionDefinition("backend")]
-public sealed class BackendCollection : ICollectionFixture;
-
-[CollectionDefinition("probe-e2e")]
-public sealed class ProbeEndToEndCollection : ICollectionFixture;
diff --git a/backend/tests/MoShiro.Backend.Tests/AuditTests.cs b/backend/tests/MoShiro.Backend.Tests/AuditTests.cs
index 9d1f5ec..2554294 100644
--- a/backend/tests/MoShiro.Backend.Tests/AuditTests.cs
+++ b/backend/tests/MoShiro.Backend.Tests/AuditTests.cs
@@ -4,8 +4,7 @@ using System.Text.Json.Nodes;
namespace MoShiro.Backend.Tests;
/// 审计日志:写操作、登录成败都会记一行;谁做的(用户名或 key:令牌名)、结果、细节。
-[Collection("probe-e2e")]
-public sealed class AuditTests
+public sealed class AuditTests : IClassFixture
{
private readonly BackendFixture _backend;
diff --git a/backend/tests/MoShiro.Backend.Tests/CapabilityTests.cs b/backend/tests/MoShiro.Backend.Tests/CapabilityTests.cs
index e2a7393..e24a04e 100644
--- a/backend/tests/MoShiro.Backend.Tests/CapabilityTests.cs
+++ b/backend/tests/MoShiro.Backend.Tests/CapabilityTests.cs
@@ -9,8 +9,7 @@ using MoShiro.Backend.Application;
namespace MoShiro.Backend.Tests;
/// 能力模型(docs/security/access.md 第 3 节):每个管理端点声明能力;密钥只能拿到自己拥有能力的子集;写蕴含读。
-[Collection("probe-e2e")]
-public sealed class CapabilityTests
+public sealed class CapabilityTests : IClassFixture
{
private readonly BackendFixture _backend;
diff --git a/backend/tests/MoShiro.Backend.Tests/EnrollmentAndTaskSetTests.cs b/backend/tests/MoShiro.Backend.Tests/EnrollmentAndTaskSetTests.cs
index f6134b6..37b9210 100644
--- a/backend/tests/MoShiro.Backend.Tests/EnrollmentAndTaskSetTests.cs
+++ b/backend/tests/MoShiro.Backend.Tests/EnrollmentAndTaskSetTests.cs
@@ -5,8 +5,7 @@ using MoShiro.TestSupport;
namespace MoShiro.Backend.Tests;
-[Collection("backend")]
-public sealed class EnrollmentAndTaskSetTests
+public sealed class EnrollmentAndTaskSetTests : IClassFixture
{
private readonly BackendFixture _backend;
diff --git a/backend/tests/MoShiro.Backend.Tests/HostTests.cs b/backend/tests/MoShiro.Backend.Tests/HostTests.cs
index fe4b2db..70036aa 100644
--- a/backend/tests/MoShiro.Backend.Tests/HostTests.cs
+++ b/backend/tests/MoShiro.Backend.Tests/HostTests.cs
@@ -115,8 +115,7 @@ public sealed class HostSamplerTests
}
}
-[Collection("backend")]
-public sealed class HostStatusTests
+public sealed class HostStatusTests : IClassFixture
{
private readonly BackendFixture _backend;
diff --git a/backend/tests/MoShiro.Backend.Tests/IngestAndQueryTests.cs b/backend/tests/MoShiro.Backend.Tests/IngestAndQueryTests.cs
index 3a4b065..b4453d4 100644
--- a/backend/tests/MoShiro.Backend.Tests/IngestAndQueryTests.cs
+++ b/backend/tests/MoShiro.Backend.Tests/IngestAndQueryTests.cs
@@ -7,8 +7,7 @@ using MoShiro.TestSupport;
namespace MoShiro.Backend.Tests;
-[Collection("backend")]
-public sealed class IngestAndQueryTests
+public sealed class IngestAndQueryTests : IClassFixture
{
private const long Minute = 60_000;
diff --git a/backend/tests/MoShiro.Backend.Tests/InstallTests.cs b/backend/tests/MoShiro.Backend.Tests/InstallTests.cs
index 71e85b4..767bacb 100644
--- a/backend/tests/MoShiro.Backend.Tests/InstallTests.cs
+++ b/backend/tests/MoShiro.Backend.Tests/InstallTests.cs
@@ -7,8 +7,7 @@ namespace MoShiro.Backend.Tests;
/// 一行安装:Agent 和 Probe 共用同一份静态脚本(所有人拿到的字节都一样,所以哈希固定、可以先核对再执行),
/// 面板地址、角色、一次性代码都走命令里的环境变量;脚本再向面板取 CI 签名的发布清单来验证镜像。
///
-[Collection("probe-e2e")]
-public sealed class InstallTests
+public sealed class InstallTests : IClassFixture
{
private readonly BackendFixture _backend;
diff --git a/backend/tests/MoShiro.Backend.Tests/Ipv6Tests.cs b/backend/tests/MoShiro.Backend.Tests/Ipv6Tests.cs
index 0b5ed82..adb00bb 100644
--- a/backend/tests/MoShiro.Backend.Tests/Ipv6Tests.cs
+++ b/backend/tests/MoShiro.Backend.Tests/Ipv6Tests.cs
@@ -10,9 +10,8 @@ namespace MoShiro.Backend.Tests;
/// 真实双栈回环:同一身份的两种会话、原始块、查询与测试结果必须各自独立。
// 慢的端到端测试(真实时间的阶梯、要等测量块出来):CI 里单独一批跑(Category=Slow),不拦着出镜像,见 ci/verify-backend.sh
-[Collection("probe-e2e")]
[Trait("Category", "Slow")]
-public sealed class Ipv6Tests
+public sealed class Ipv6Tests : IClassFixture
{
private readonly BackendFixture _backend;
diff --git a/backend/tests/MoShiro.Backend.Tests/LinkTests.cs b/backend/tests/MoShiro.Backend.Tests/LinkTests.cs
index fdd0450..e6eff94 100644
--- a/backend/tests/MoShiro.Backend.Tests/LinkTests.cs
+++ b/backend/tests/MoShiro.Backend.Tests/LinkTests.cs
@@ -4,8 +4,7 @@ using MoShiro.TestSupport;
namespace MoShiro.Backend.Tests;
-[Collection("probe-e2e")]
-public sealed class LinkTests
+public sealed class LinkTests : IClassFixture
{
private readonly BackendFixture _backend;
diff --git a/backend/tests/MoShiro.Backend.Tests/LiveTests.cs b/backend/tests/MoShiro.Backend.Tests/LiveTests.cs
index c875df6..251ed80 100644
--- a/backend/tests/MoShiro.Backend.Tests/LiveTests.cs
+++ b/backend/tests/MoShiro.Backend.Tests/LiveTests.cs
@@ -6,8 +6,7 @@ using MoShiro.TestSupport;
namespace MoShiro.Backend.Tests;
-[Collection("backend")]
-public sealed class LiveTests
+public sealed class LiveTests : IClassFixture
{
private readonly BackendFixture _backend;
diff --git a/backend/tests/MoShiro.Backend.Tests/NodeInfoTests.cs b/backend/tests/MoShiro.Backend.Tests/NodeInfoTests.cs
index 0bb40dc..5ee8fbb 100644
--- a/backend/tests/MoShiro.Backend.Tests/NodeInfoTests.cs
+++ b/backend/tests/MoShiro.Backend.Tests/NodeInfoTests.cs
@@ -174,8 +174,7 @@ public sealed partial class NodeInfoTests
/// 访客能拿到的东西里不能有 IP 等敏感信息:公开接口的整个响应里不出现 IP 形状的字符串,也没有 ip、city、asn、经纬度这些字段。
/// Agent 和 Probe 这里是真的连上了(Probe 的来源地址是 127.0.0.1),所以如果哪里把地址带出来了,这里一定能发现。
///
-[Collection("probe-e2e")]
-public sealed partial class NodePrivacyTests
+public sealed partial class NodePrivacyTests : IClassFixture
{
private static readonly string[] s_forbiddenKeys = ["ip", "city", "asn", "latitude", "longitude", "cidr", "remote", "public_host", "geo_host", "host_name", "hostname"];
diff --git a/backend/tests/MoShiro.Backend.Tests/OnDemandTestsTests.cs b/backend/tests/MoShiro.Backend.Tests/OnDemandTestsTests.cs
index f852ca6..e29ecd8 100644
--- a/backend/tests/MoShiro.Backend.Tests/OnDemandTestsTests.cs
+++ b/backend/tests/MoShiro.Backend.Tests/OnDemandTestsTests.cs
@@ -6,8 +6,7 @@ using MoShiro.TestSupport;
namespace MoShiro.Backend.Tests;
-[Collection("backend")]
-public sealed class OnDemandTestsTests
+public sealed class OnDemandTestsTests : IClassFixture
{
private readonly BackendFixture _backend;
diff --git a/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs b/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs
index de5f88a..a0e645c 100644
--- a/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs
+++ b/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs
@@ -7,8 +7,7 @@ namespace MoShiro.Backend.Tests;
/// 对照 docs/api/api.md 第 7 节 检查运行时生成的 OpenAPI 文档:operationId、路径风格、错误响应格式、字段命名。
/// 新增端点违反规范时,这里会直接失败(等价于文档里的 eng/check-openapi)。
///
-[Collection("backend")]
-public sealed partial class OpenApiConformanceTests
+public sealed partial class OpenApiConformanceTests : IClassFixture
{
private readonly BackendFixture _backend;
diff --git a/backend/tests/MoShiro.Backend.Tests/OperationsAndSecurityTests.cs b/backend/tests/MoShiro.Backend.Tests/OperationsAndSecurityTests.cs
index 986db40..863dc76 100644
--- a/backend/tests/MoShiro.Backend.Tests/OperationsAndSecurityTests.cs
+++ b/backend/tests/MoShiro.Backend.Tests/OperationsAndSecurityTests.cs
@@ -12,8 +12,7 @@ using MoShiro.TestSupport;
namespace MoShiro.Backend.Tests;
-[Collection("backend")]
-public sealed class OperationsAndSecurityTests
+public sealed class OperationsAndSecurityTests : IClassFixture
{
private const long Minute = 60_000;
diff --git a/backend/tests/MoShiro.Backend.Tests/OwnerTests.cs b/backend/tests/MoShiro.Backend.Tests/OwnerTests.cs
index 393b8c5..495c5bf 100644
--- a/backend/tests/MoShiro.Backend.Tests/OwnerTests.cs
+++ b/backend/tests/MoShiro.Backend.Tests/OwnerTests.cs
@@ -38,8 +38,7 @@ public sealed class OwnerPolicyTests
}
}
-[Collection("probe-e2e")]
-public sealed class OwnerEndToEndTests : IDisposable
+public sealed class OwnerEndToEndTests : IDisposable, IClassFixture
{
private readonly BackendFixture _backend;
private readonly BackendOptions _options;
diff --git a/backend/tests/MoShiro.Backend.Tests/PasskeyTests.cs b/backend/tests/MoShiro.Backend.Tests/PasskeyTests.cs
index d4d56ad..027bb85 100644
--- a/backend/tests/MoShiro.Backend.Tests/PasskeyTests.cs
+++ b/backend/tests/MoShiro.Backend.Tests/PasskeyTests.cs
@@ -86,8 +86,7 @@ public sealed class SoftwarePasskey : IDisposable
}
/// passkey 登录:注册、登录、拒绝各种错误的断言、只能管理自己的 passkey。设计见 docs/security/access.md 第 4 节。
-[Collection("probe-e2e")]
-public sealed class PasskeyTests
+public sealed class PasskeyTests : IClassFixture
{
private readonly BackendFixture _backend;
diff --git a/backend/tests/MoShiro.Backend.Tests/ProbeEndToEndTests.cs b/backend/tests/MoShiro.Backend.Tests/ProbeEndToEndTests.cs
index d8d1dce..b43a62d 100644
--- a/backend/tests/MoShiro.Backend.Tests/ProbeEndToEndTests.cs
+++ b/backend/tests/MoShiro.Backend.Tests/ProbeEndToEndTests.cs
@@ -4,9 +4,8 @@ namespace MoShiro.Backend.Tests;
/// 真实的后端 + 真实的 Agent + 真实的 Probe(都在进程内,回环网络)。
// 慢的端到端测试(真实时间的阶梯、要等测量块出来):CI 里单独一批跑(Category=Slow),不拦着出镜像,见 ci/verify-backend.sh
-[Collection("probe-e2e")]
[Trait("Category", "Slow")]
-public sealed class ProbeEndToEndTests
+public sealed class ProbeEndToEndTests : IClassFixture
{
private readonly BackendFixture _backend;
diff --git a/backend/tests/MoShiro.Backend.Tests/ProtocolDeclarationTests.cs b/backend/tests/MoShiro.Backend.Tests/ProtocolDeclarationTests.cs
index 90a8a9d..0ec3615 100644
--- a/backend/tests/MoShiro.Backend.Tests/ProtocolDeclarationTests.cs
+++ b/backend/tests/MoShiro.Backend.Tests/ProtocolDeclarationTests.cs
@@ -12,8 +12,7 @@ namespace MoShiro.Backend.Tests;
/// 二进制通道都有对应的收件箱,文档里写了每一个方法。新增方法绕不过声明,声明了没人处理也会被发现。
/// 注册处理函数时路由器本身还会核对方向、类型和角色,不符就在启动时失败。
///
-[Collection("probe-e2e")]
-public sealed class ProtocolDeclarationTests
+public sealed class ProtocolDeclarationTests : IClassFixture
{
private static readonly MethodTable s_table = MoShiroMethodTable.Table;
diff --git a/backend/tests/MoShiro.Backend.Tests/ReleaseKeyTests.cs b/backend/tests/MoShiro.Backend.Tests/ReleaseKeyTests.cs
index 725c50e..1ee335c 100644
--- a/backend/tests/MoShiro.Backend.Tests/ReleaseKeyTests.cs
+++ b/backend/tests/MoShiro.Backend.Tests/ReleaseKeyTests.cs
@@ -11,8 +11,7 @@ namespace MoShiro.Backend.Tests;
/// 可信资产来源:官方发布密钥始终信任;部署的人可以另外导入自己的发布公钥(默认没有),用自己的私钥给自己构建的清单签名。
/// 面板只收公钥,拒绝私钥;删除之后只有它签过的清单不再被接受,已登记的也丢掉。
///
-[Collection("probe-e2e")]
-public sealed class ReleaseKeyTests
+public sealed class ReleaseKeyTests : IClassFixture
{
private static long s_seq = TimeProvider.System.GetUtcNow().ToUnixTimeSeconds() * 1000 + 500_000;
diff --git a/backend/tests/MoShiro.Backend.Tests/ReleaseOfferTests.cs b/backend/tests/MoShiro.Backend.Tests/ReleaseOfferTests.cs
index 9f20e68..ff50210 100644
--- a/backend/tests/MoShiro.Backend.Tests/ReleaseOfferTests.cs
+++ b/backend/tests/MoShiro.Backend.Tests/ReleaseOfferTests.cs
@@ -11,8 +11,7 @@ namespace MoShiro.Backend.Tests;
/// CI 登记清单不需要令牌,但清单必须带 CI 发布密钥的有效签名:令牌存在面板的数据库里,清库、重装或还没有 passkey 的时候就拿不到,
/// 而清单本来就是签名的,面板直接验签更简单,也没有长期令牌可偷。
///
-[Collection("probe-e2e")]
-public sealed class ReleaseOfferTests
+public sealed class ReleaseOfferTests : IClassFixture
{
// openssl 生成的测试密钥对和签名(`openssl dgst -sha256 -sign`,DER):证明验签接受 CI 脚本实际产出的格式
private const string TestKey = "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEQwlE2KjYWszeaTsng6CfPxhOqeIV\n+7dm940f7XfHJr9A6/OTiZ2YnhFWWipZ4Z4poPtb6hMW04tIARO8+Km34w==\n-----END PUBLIC KEY-----";
diff --git a/backend/tests/MoShiro.Backend.Tests/ScheduleTests.cs b/backend/tests/MoShiro.Backend.Tests/ScheduleTests.cs
index fe315bb..c64713c 100644
--- a/backend/tests/MoShiro.Backend.Tests/ScheduleTests.cs
+++ b/backend/tests/MoShiro.Backend.Tests/ScheduleTests.cs
@@ -7,8 +7,7 @@ using MoShiro.TestSupport;
namespace MoShiro.Backend.Tests;
/// 定时重启:默认开、北京时间 04:30;节点连上时补发,管理端改了设置马上群发,关掉就发空。
-[Collection("probe-e2e")]
-public sealed class ScheduleTests
+public sealed class ScheduleTests : IClassFixture
{
private readonly BackendFixture _backend;
diff --git a/backend/tests/MoShiro.Backend.Tests/StaticSiteTests.cs b/backend/tests/MoShiro.Backend.Tests/StaticSiteTests.cs
index a16e76d..52c7154 100644
--- a/backend/tests/MoShiro.Backend.Tests/StaticSiteTests.cs
+++ b/backend/tests/MoShiro.Backend.Tests/StaticSiteTests.cs
@@ -132,8 +132,7 @@ public sealed class StaticSiteTests
}
/// HTTP 层:后端自己的路径不会被前端的回退吞掉;响应头对。
-[Collection("probe-e2e")]
-public sealed class StaticSiteHttpTests
+public sealed class StaticSiteHttpTests : IClassFixture
{
private readonly BackendFixture _backend;
diff --git a/backend/tests/MoShiro.Backend.Tests/TestAssemblyConfiguration.cs b/backend/tests/MoShiro.Backend.Tests/TestAssemblyConfiguration.cs
new file mode 100644
index 0000000..79b0a1a
--- /dev/null
+++ b/backend/tests/MoShiro.Backend.Tests/TestAssemblyConfiguration.cs
@@ -0,0 +1 @@
+[assembly: Xunit.v3.Parallelization(MaxThreads = 16, Mode = Xunit.Sdk.ParallelMode.Collections)]
diff --git a/backend/tests/MoShiro.Backend.Tests/TestReleaseKey.cs b/backend/tests/MoShiro.Backend.Tests/TestReleaseKey.cs
index c932b96..a4d127d 100644
--- a/backend/tests/MoShiro.Backend.Tests/TestReleaseKey.cs
+++ b/backend/tests/MoShiro.Backend.Tests/TestReleaseKey.cs
@@ -11,11 +11,26 @@ namespace MoShiro.Backend.Tests;
public sealed class TestReleaseKey : IReleaseKey
{
private static readonly ECDsa s_key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
+ private static readonly object s_gate = new();
public static TestReleaseKey Instance { get; } = new();
- public string Pem => s_key.ExportSubjectPublicKeyInfoPem();
+ public string Pem
+ {
+ get
+ {
+ lock (s_gate)
+ {
+ return s_key.ExportSubjectPublicKeyInfoPem();
+ }
+ }
+ }
- public static string Sign(string manifest) =>
- Convert.ToBase64String(s_key.SignData(Encoding.UTF8.GetBytes(manifest), HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence));
+ public static string Sign(string manifest)
+ {
+ lock (s_gate)
+ {
+ return Convert.ToBase64String(s_key.SignData(Encoding.UTF8.GetBytes(manifest), HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence));
+ }
+ }
}
diff --git a/backend/tests/MoShiro.Backend.Tests/TrustTests.cs b/backend/tests/MoShiro.Backend.Tests/TrustTests.cs
index baa2cd3..be968b9 100644
--- a/backend/tests/MoShiro.Backend.Tests/TrustTests.cs
+++ b/backend/tests/MoShiro.Backend.Tests/TrustTests.cs
@@ -17,8 +17,7 @@ namespace MoShiro.Backend.Tests;
/// 信任根:操作员(passkey)签操作员集合;安装命令里带着创世指纹和属主(都是公开值),节点注册时验证面板的集合并固定在本地。
/// 设计见 docs/security/access.md 第 8 节。
///
-[Collection("probe-e2e")]
-public sealed class TrustTests
+public sealed class TrustTests : IClassFixture
{
private readonly BackendFixture _backend;
diff --git a/backend/tests/MoShiro.Backend.Tests/UpdateTests.cs b/backend/tests/MoShiro.Backend.Tests/UpdateTests.cs
index 33a6b21..712def4 100644
--- a/backend/tests/MoShiro.Backend.Tests/UpdateTests.cs
+++ b/backend/tests/MoShiro.Backend.Tests/UpdateTests.cs
@@ -5,8 +5,7 @@ using MoShiro.TestSupport;
namespace MoShiro.Backend.Tests;
-[Collection("probe-e2e")]
-public sealed class UpdateTests
+public sealed class UpdateTests : IClassFixture
{
private const string Digest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
diff --git a/ci/verify-backend.sh b/ci/verify-backend.sh
index ea765a8..95c8589 100644
--- a/ci/verify-backend.sh
+++ b/ci/verify-backend.sh
@@ -14,8 +14,8 @@ set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
mode="${1:-fast}"
case "$mode" in
- fast) filter=(--filter-not-trait "Category=Slow") ;;
- slow) filter=(--filter-trait "Category=Slow") ;;
+ fast) filter=(--filter-not-trait "Category=Slow"); test_timeout=300 ;;
+ slow) filter=(--filter-trait "Category=Slow"); test_timeout=900 ;;
*) echo "usage: verify-backend.sh [fast|slow]" >&2; exit 2 ;;
esac
cache="${MOSHIRO_CI_CACHE:-/home/justme/woodpecker/cache/backend-tests}"
@@ -70,9 +70,43 @@ quic_runtime="$cache/quic-runtime"
bash "$root/ci/fetch-quic-test-runtime.sh" "$quic_runtime"
export LD_LIBRARY_PATH="$quic_runtime/runtime${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
status=0
-# 别的程序集里没有匹配的测试会因为「零个测试」返回 8,忽略它
-(cd "$src/backend" && dotnet test --solution MoShiro.slnx -c Release --no-build "${filter[@]}" --ignore-exit-code 8) 2>&1 | tee "$log" || status=${PIPESTATUS[0]}
+# Each test module can spawn its own testhost. Run the whole command in a new
+# session and time out its process group, so orphaned testhosts cannot keep the
+# Woodpecker step and the shared cache lock alive.
+if ! command -v setsid >/dev/null 2>&1; then
+ echo "setsid is required to enforce the backend test timeout" >&2
+ exit 2
+fi
+
+run_with_timeout() {
+ local seconds="$1" marker="$cache/test-timeout.marker" test_pid watcher_pid result=0
+ shift
+ rm -f "$marker"
+ (
+ cd "$src/backend"
+ setsid "$@" &
+ test_pid=$!
+ setsid bash -c 'sleep "$1"; : > "$2"; kill -TERM -- "-$3" 2>/dev/null || true' _ "$seconds" "$marker" "$test_pid" >/dev/null 2>&1 &
+ watcher_pid=$!
+ wait "$test_pid" || result=$?
+ if [[ -f "$marker" ]]; then
+ kill -KILL -- "-$test_pid" 2>/dev/null || true
+ result=124
+ fi
+ kill -TERM -- "-$watcher_pid" 2>/dev/null || true
+ wait "$watcher_pid" 2>/dev/null || true
+ rm -f "$marker"
+ return "$result"
+ )
+}
+
+# Ignore exit 8 from other assemblies with no matching tests; both suites have a hard wall-clock cap.
+run_with_timeout "$test_timeout" dotnet test --solution MoShiro.slnx -c Release --no-build --max-parallel-test-modules 16 "${filter[@]}" --ignore-exit-code 8 2>&1 | tee "$log" || status=${PIPESTATUS[0]}
[[ "$status" == 0 ]] && exit 0
+if [[ "$status" == 124 || "$status" == 137 ]]; then
+ echo "backend tests exceeded the ${test_timeout}s timeout" >&2
+ exit "$status"
+fi
# 个别端到端测试会偶发失败(比如 Hy2 阶梯测试里探针掉线):失败的测试单独重跑(最多两次),重跑通过就当通过(日志里留着第一次的失败);
# 失败太多说明是真的坏了,不重跑
@@ -87,7 +121,7 @@ for name in "${failed[@]}"; do
for attempt in 1 2; do
echo "retrying $name (attempt $attempt of 2)"
# 过滤要带通配符;别的程序集里没有匹配的测试,会因为「零个测试」返回 8,忽略它
- if (cd "$src/backend" && dotnet test --solution MoShiro.slnx -c Release --no-build --filter-method "*$name*" --ignore-exit-code 8); then
+ if run_with_timeout 90 dotnet test --solution MoShiro.slnx -c Release --no-build --max-parallel-test-modules 16 --filter-method "*$name*" --ignore-exit-code 8; then
passed=true
break
fi