From 13c50141ab44aeb295bd5dc75e4ad449954ada8c Mon Sep 17 00:00:00 2001 From: Joes Date: Wed, 9 Sep 2026 09:41:16 +0800 Subject: [PATCH 1/4] chore: upgrade .NET 11 RC1 dependencies Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- Directory.Packages.props | 6 +++--- global.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 64e46cb..5335eeb 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -39,7 +39,7 @@ - + - + @@ -58,4 +58,4 @@ - \ No newline at end of file + diff --git a/global.json b/global.json index 8976695..7e7e286 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "11.0.100-preview.7.26381.103", + "version": "11.0.100-rc.1.26425.128", "rollForward": "latestFeature", "allowPrerelease": true } From 943dcfc404cb0940b79872c503650d454dc9e445 Mon Sep 17 00:00:00 2001 From: Joes Date: Wed, 9 Sep 2026 10:00:01 +0800 Subject: [PATCH 2/4] chore: restrict CI trigger to main branch only --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2899ddd..db008a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,7 @@ name: CI on: push: - branches: [main, dev] + branches: [main] pull_request: workflow_dispatch: From 92bf4eb6cf2d809a52fa656489bc2d4b9e3e64fa Mon Sep 17 00:00:00 2001 From: Joes Date: Wed, 9 Sep 2026 10:19:22 +0800 Subject: [PATCH 3/4] refactor: use array slicing for content in LoopbackS3Server and integration tests --- tests/VelaShell.Plugin.S3.Tests/LoopbackS3Server.cs | 9 ++++++--- .../S3FileServiceIntegrationTests.cs | 12 ++++++++---- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/tests/VelaShell.Plugin.S3.Tests/LoopbackS3Server.cs b/tests/VelaShell.Plugin.S3.Tests/LoopbackS3Server.cs index adf538b..743e2d1 100644 --- a/tests/VelaShell.Plugin.S3.Tests/LoopbackS3Server.cs +++ b/tests/VelaShell.Plugin.S3.Tests/LoopbackS3Server.cs @@ -94,7 +94,7 @@ public void AddBucket(string bucket) => public void AddObject(string bucket, string key, byte[] content) { AddBucket(bucket); - _buckets[bucket][key] = new(content, DateTimeOffset.UtcNow); + _buckets[bucket][key] = new([.. content], DateTimeOffset.UtcNow); } /// 预置一个文本对象。 @@ -770,7 +770,10 @@ private static HttpResponse GetObject(ConcurrentDictionary return new() { Status = HttpStatusCode.OK, - Body = stored.Content, + // HttpListener may consume the response buffer while sending it. + // Never expose the stored object bytes to the transport layer: tests + // keep the same array as their expected content. + Body = [.. stored.Content], Headers = { ["ETag"] = $"\"{ETagOf(stored.Content)}\"" }, }; } @@ -780,7 +783,7 @@ private static HttpResponse HeadObject(ConcurrentDictionary(progress.Add)); - Assert.AreSequenceEqual(content, await File.ReadAllBytesAsync(local)); + Assert.IsTrue(expected.SequenceEqual(await File.ReadAllBytesAsync(local))); Assert.AreEqual(progress[^1].TotalBytes, progress[^1].TransferredBytes, "最后一次上报必须是满进度。"); AssertAllRequestsSigned(); } @@ -328,6 +329,7 @@ await _service.DownloadFileAsync(_session, "/test-bucket/data/blob.bin", local, public async Task Download_HeadDenied_StillDownloadsViaGet() { byte[] content = Encoding.UTF8.GetBytes(new string('z', 3000) + "end"); + byte[] expected = [.. content]; _server.AddObject(Bucket, "public/asset.png", content); _server.DeniedMethods.Add("HEAD"); string local = Path.Combine(Path.GetTempPath(), $"vela-s3-{Guid.NewGuid():N}"); @@ -337,7 +339,7 @@ public async Task Download_HeadDenied_StillDownloadsViaGet() await _service.DownloadFileAsync(_session, "/test-bucket/public/asset.png", local, new SynchronousProgress(progress.Add)); - Assert.AreSequenceEqual(content, await File.ReadAllBytesAsync(local)); + Assert.IsTrue(expected.SequenceEqual(await File.ReadAllBytesAsync(local))); // 总长度只能来自 GET 响应,但进度依然要收在满格上。 Assert.AreEqual(content.Length, progress[^1].TotalBytes); Assert.AreEqual(progress[^1].TotalBytes, progress[^1].TransferredBytes, "最后一次上报必须是满进度。"); @@ -356,6 +358,7 @@ await _service.DownloadFileAsync(_session, "/test-bucket/public/asset.png", loca public async Task Download_DirectReadDenied_FallsBackToPresignedUrl() { byte[] content = Encoding.UTF8.GetBytes(new string('p', 5000) + "tail"); + byte[] expected = [.. content]; _server.AddObject(Bucket, "locked/asset.bin", content); _server.DenyDirectReads = true; string local = Path.Combine(Path.GetTempPath(), $"vela-s3-{Guid.NewGuid():N}"); @@ -365,7 +368,7 @@ public async Task Download_DirectReadDenied_FallsBackToPresignedUrl() await _service.DownloadFileAsync(_session, "/test-bucket/locked/asset.bin", local, new SynchronousProgress(progress.Add)); - Assert.AreSequenceEqual(content, await File.ReadAllBytesAsync(local)); + Assert.IsTrue(expected.SequenceEqual(await File.ReadAllBytesAsync(local))); Assert.AreEqual(content.Length, progress[^1].TotalBytes); Assert.AreEqual(progress[^1].TotalBytes, progress[^1].TransferredBytes, "最后一次上报必须是满进度。"); // 预签名那次也必须是签对的:服务器重算签名,对不上会计入 SignatureFailures。 @@ -422,6 +425,7 @@ await Assert.ThrowsExactlyAsync( public async Task Download_ResumesWithRangeRequest() { byte[] content = Encoding.UTF8.GetBytes(new string('a', 1000) + new string('b', 1000)); + byte[] expected = [.. content]; _server.AddObject(Bucket, "resume.bin", content); string local = Path.Combine(Path.GetTempPath(), $"vela-s3-{Guid.NewGuid():N}"); try @@ -430,7 +434,7 @@ public async Task Download_ResumesWithRangeRequest() await _service.DownloadFileAsync(_session, "/test-bucket/resume.bin", local, resumeOffset: 1000); - Assert.AreSequenceEqual(content, await File.ReadAllBytesAsync(local)); + Assert.IsTrue(expected.SequenceEqual(await File.ReadAllBytesAsync(local))); AssertAllRequestsSigned(); } finally From 66100779a1713a65b94becd265734a7099726ad3 Mon Sep 17 00:00:00 2001 From: Joes Date: Wed, 9 Sep 2026 10:51:32 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(tests):=20=E6=8A=8A=20await=20=E7=A7=BB?= =?UTF-8?q?=E5=87=BA=E6=96=AD=E8=A8=80=E5=AE=9E=E5=8F=82,=E4=BF=AE?= =?UTF-8?q?=E5=A5=BD=20S3=20=E4=B8=8B=E8=BD=BD=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI 上 S3 的三个下载用例红了:Download_HeadDenied_StillDownloadsViaGet、 Download_DirectReadDenied_FallsBackToPresignedUrl、Download_ResumesWithRangeRequest。 下载本身是对的 —— 写出去的字节与远端完全一致,错的是断言那一行: Assert.AreSequenceEqual(content, await File.ReadAllBytesAsync(local)); 本仓库跑 net11.0 + LangVersion preview(C# first-class span),`content` 这个 byte[] 实参会先隐式转成 ReadOnlySpan,然后才在第二个实参的 await 处挂起。 span 是 ref struct,跨不了挂起点 —— 恢复之后拿到的是空 span,断言于是无条件失败。 编译器不报错也不告警,且只在 await 真正异步完成时才现形,所以表现成 「单跑绿、一起跑红」的假不稳定(本地 4 个红、CI 3 个红,就是这么来的)。 改法是把 await 先落到局部变量再断言。同一个坑在 Redis 测试里还有 5 处 (RedisStoreTests 4 处、RedisPanelEditingUiTests 1 处),一并改掉 —— 那几条眼下靠 await 同步完成侥幸没炸,是颗定时炸弹。 顺带回滚 92bf4eb 里按错误猜测(以为是缓冲区被复用)加的防御性拷贝与注释: LoopbackS3Server 用的是裸 TcpListener,注释里说的 HttpListener 并不存在。 规则写进 AGENTS.md:任何 span 接收者或 span 实参的调用,实参列表里不许出现 await。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019gsr3Qyjd4uQY1MJ9mVYen --- AGENTS.md | 26 +++++++++++++++++++ .../RedisPanelEditingUiTests.cs | 3 ++- .../RedisStoreTests.cs | 12 ++++++--- .../LoopbackS3Server.cs | 9 +++---- .../S3FileServiceIntegrationTests.cs | 19 ++++++++------ 5 files changed, 50 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7a96886..82a95d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,3 +74,29 @@ Telnet / 串口见 [`zh/host/Telnet与串口可行性调研.md`](https://github. ### 留在本仓库的文档 `README.md`、`LICENSE`,以及各插件目录下的 `README.md`(该插件自己的实现说明与偏离记录)。 + +### 测试:不要把 `await` 写进带集合实参的断言里 + +本仓库跑在 `net11.0` + `LangVersion preview`(C# 的 first-class span)。在这套语义下, +`byte[]` / 集合表达式实参会**隐式转成 `ReadOnlySpan`** 再传进 +`Assert.AreSequenceEqual`、`MemoryExtensions.SequenceEqual`、`StartsWith`、`IndexOf` 之类的重载。 + +于是这一行是错的: + +```csharp +Assert.AreSequenceEqual(content, await File.ReadAllBytesAsync(local)); // ❌ +``` + +实参从左往右求值:`content` 先转成 `ReadOnlySpan`,然后在第二个实参的 `await` 处挂起。 +span 是 ref struct,跨不了挂起点 —— 恢复之后拿到的是**空 span**,断言无条件失败。 +编译器**不报错也不告警**,只有真正走异步(await 没同步完成)时才现形, +表现为「单跑绿、一起跑红」「本地绿、CI 红」的假不稳定。 + +正确写法是先把 `await` 落到局部变量: + +```csharp +byte[] downloaded = await File.ReadAllBytesAsync(local); // ✅ +Assert.AreSequenceEqual(content, downloaded); +``` + +同一条规则适用于任何 span 接收者或 span 实参的调用:**调用的实参列表里不许出现 `await`**。 diff --git a/tests/VelaShell.Plugin.Redis.Tests/RedisPanelEditingUiTests.cs b/tests/VelaShell.Plugin.Redis.Tests/RedisPanelEditingUiTests.cs index 8ef6f66..3068846 100644 --- a/tests/VelaShell.Plugin.Redis.Tests/RedisPanelEditingUiTests.cs +++ b/tests/VelaShell.Plugin.Redis.Tests/RedisPanelEditingUiTests.cs @@ -696,7 +696,8 @@ public void BinaryValue_MalformedEscape_RefusesToWrite() await PumpAsync(); Assert.Contains("转义写坏了", vm.StatusMessage); - Assert.AreSequenceEqual(original, await ReadRawAsync("bad:blob"), "拒绝写入时服务端的值必须原封不动。"); + byte[] stored = await ReadRawAsync("bad:blob"); + Assert.AreSequenceEqual(original, stored, "拒绝写入时服务端的值必须原封不动。"); }); } diff --git a/tests/VelaShell.Plugin.Redis.Tests/RedisStoreTests.cs b/tests/VelaShell.Plugin.Redis.Tests/RedisStoreTests.cs index 2ae0c14..6452499 100644 --- a/tests/VelaShell.Plugin.Redis.Tests/RedisStoreTests.cs +++ b/tests/VelaShell.Plugin.Redis.Tests/RedisStoreTests.cs @@ -22,8 +22,10 @@ public async Task Favorites_RoundTripPerConnection() await store.SaveFavoritesAsync("redis.example:6379", ["user:1", "lock:a"]); await store.SaveFavoritesAsync("10.0.0.2:6379", ["other:1"]); - Assert.AreSequenceEqual(["user:1", "lock:a"], [.. (await store.LoadFavoritesAsync("redis.example:6379"))]); - Assert.AreSequenceEqual(["other:1"], [.. (await store.LoadFavoritesAsync("10.0.0.2:6379"))]); + List favorites = [.. await store.LoadFavoritesAsync("redis.example:6379")]; + Assert.AreSequenceEqual(["user:1", "lock:a"], favorites); + List otherFavorites = [.. await store.LoadFavoritesAsync("10.0.0.2:6379")]; + Assert.AreSequenceEqual(["other:1"], otherFavorites); } [TestMethod] @@ -59,8 +61,10 @@ public async Task History_IsScopedPerConnection() await store.AppendHistoryAsync("a:6379", "PING"); await store.AppendHistoryAsync("b:6379", "INFO"); - Assert.AreSequenceEqual(["PING"], [.. (await store.LoadHistoryAsync("a:6379"))]); - Assert.AreSequenceEqual(["INFO"], [.. (await store.LoadHistoryAsync("b:6379"))]); + List first = [.. await store.LoadHistoryAsync("a:6379")]; + Assert.AreSequenceEqual(["PING"], first); + List second = [.. await store.LoadHistoryAsync("b:6379")]; + Assert.AreSequenceEqual(["INFO"], second); } [TestMethod] diff --git a/tests/VelaShell.Plugin.S3.Tests/LoopbackS3Server.cs b/tests/VelaShell.Plugin.S3.Tests/LoopbackS3Server.cs index 743e2d1..adf538b 100644 --- a/tests/VelaShell.Plugin.S3.Tests/LoopbackS3Server.cs +++ b/tests/VelaShell.Plugin.S3.Tests/LoopbackS3Server.cs @@ -94,7 +94,7 @@ public void AddBucket(string bucket) => public void AddObject(string bucket, string key, byte[] content) { AddBucket(bucket); - _buckets[bucket][key] = new([.. content], DateTimeOffset.UtcNow); + _buckets[bucket][key] = new(content, DateTimeOffset.UtcNow); } /// 预置一个文本对象。 @@ -770,10 +770,7 @@ private static HttpResponse GetObject(ConcurrentDictionary return new() { Status = HttpStatusCode.OK, - // HttpListener may consume the response buffer while sending it. - // Never expose the stored object bytes to the transport layer: tests - // keep the same array as their expected content. - Body = [.. stored.Content], + Body = stored.Content, Headers = { ["ETag"] = $"\"{ETagOf(stored.Content)}\"" }, }; } @@ -783,7 +780,7 @@ private static HttpResponse HeadObject(ConcurrentDictionary(progress.Add)); - Assert.IsTrue(expected.SequenceEqual(await File.ReadAllBytesAsync(local))); + // 读回的字节先落到局部变量:把 await 写进 Assert.AreSequenceEqual 的实参里, + // 第一个实参会先转成 ReadOnlySpan,再在 await 处挂起 —— 恢复后那个 span 是空的, + // 断言于是无条件失败。这是 C# preview「first-class span」下的编译器坑,详见 AGENTS.md。 + byte[] downloaded = await File.ReadAllBytesAsync(local); + Assert.AreSequenceEqual(content, downloaded); Assert.AreEqual(progress[^1].TotalBytes, progress[^1].TransferredBytes, "最后一次上报必须是满进度。"); AssertAllRequestsSigned(); } @@ -329,7 +332,6 @@ await _service.DownloadFileAsync(_session, "/test-bucket/data/blob.bin", local, public async Task Download_HeadDenied_StillDownloadsViaGet() { byte[] content = Encoding.UTF8.GetBytes(new string('z', 3000) + "end"); - byte[] expected = [.. content]; _server.AddObject(Bucket, "public/asset.png", content); _server.DeniedMethods.Add("HEAD"); string local = Path.Combine(Path.GetTempPath(), $"vela-s3-{Guid.NewGuid():N}"); @@ -339,7 +341,8 @@ public async Task Download_HeadDenied_StillDownloadsViaGet() await _service.DownloadFileAsync(_session, "/test-bucket/public/asset.png", local, new SynchronousProgress(progress.Add)); - Assert.IsTrue(expected.SequenceEqual(await File.ReadAllBytesAsync(local))); + byte[] downloaded = await File.ReadAllBytesAsync(local); + Assert.AreSequenceEqual(content, downloaded); // 总长度只能来自 GET 响应,但进度依然要收在满格上。 Assert.AreEqual(content.Length, progress[^1].TotalBytes); Assert.AreEqual(progress[^1].TotalBytes, progress[^1].TransferredBytes, "最后一次上报必须是满进度。"); @@ -358,7 +361,6 @@ await _service.DownloadFileAsync(_session, "/test-bucket/public/asset.png", loca public async Task Download_DirectReadDenied_FallsBackToPresignedUrl() { byte[] content = Encoding.UTF8.GetBytes(new string('p', 5000) + "tail"); - byte[] expected = [.. content]; _server.AddObject(Bucket, "locked/asset.bin", content); _server.DenyDirectReads = true; string local = Path.Combine(Path.GetTempPath(), $"vela-s3-{Guid.NewGuid():N}"); @@ -368,7 +370,8 @@ public async Task Download_DirectReadDenied_FallsBackToPresignedUrl() await _service.DownloadFileAsync(_session, "/test-bucket/locked/asset.bin", local, new SynchronousProgress(progress.Add)); - Assert.IsTrue(expected.SequenceEqual(await File.ReadAllBytesAsync(local))); + byte[] downloaded = await File.ReadAllBytesAsync(local); + Assert.AreSequenceEqual(content, downloaded); Assert.AreEqual(content.Length, progress[^1].TotalBytes); Assert.AreEqual(progress[^1].TotalBytes, progress[^1].TransferredBytes, "最后一次上报必须是满进度。"); // 预签名那次也必须是签对的:服务器重算签名,对不上会计入 SignatureFailures。 @@ -425,7 +428,6 @@ await Assert.ThrowsExactlyAsync( public async Task Download_ResumesWithRangeRequest() { byte[] content = Encoding.UTF8.GetBytes(new string('a', 1000) + new string('b', 1000)); - byte[] expected = [.. content]; _server.AddObject(Bucket, "resume.bin", content); string local = Path.Combine(Path.GetTempPath(), $"vela-s3-{Guid.NewGuid():N}"); try @@ -434,7 +436,8 @@ public async Task Download_ResumesWithRangeRequest() await _service.DownloadFileAsync(_session, "/test-bucket/resume.bin", local, resumeOffset: 1000); - Assert.IsTrue(expected.SequenceEqual(await File.ReadAllBytesAsync(local))); + byte[] downloaded = await File.ReadAllBytesAsync(local); + Assert.AreSequenceEqual(content, downloaded); AssertAllRequestsSigned(); } finally