Skip to content

Commit a29dc18

Browse files
PureWeenCopilot
andauthored
fix(dotnet): add fallback TypeInfoResolver for StreamJsonRpc.RequestId (#783)
When a CancellationToken fires during a JSON-RPC call, StreamJsonRpc's StandardCancellationStrategy.CancelOutboundRequest() attempts to serialize RequestId. The SDK's SystemTextJsonFormatter is configured with AOT-safe source-generated contexts, but none of them include StreamJsonRpc.RequestId. This causes a NotSupportedException that is unobserved by the JSON-RPC reader, silently killing the connection and leaving sessions permanently stuck at IsProcessing=true. The fix adds a StreamJsonRpcTypeInfoResolver — a reflection-based fallback IJsonTypeInfoResolver — as the last entry in the TypeInfoResolverChain. This ensures RequestId (and any other StreamJsonRpc-internal types) can be serialized when cancellation fires, without disrupting the AOT-safe source-generated path for all SDK-owned types. The reflection fallback is only reached for types not covered by the five source-generated contexts, so the AOT/trimming suppressions (IL2026, IL3050) are targeted and justified. Fixes: PureWeen/PolyPilot#319 Affected versions: 0.1.30+ (introduced by NativeAOT work in PR #81) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 38603f2 commit a29dc18

File tree

2 files changed

+131
-0
lines changed

2 files changed

+131
-0
lines changed

dotnet/src/Client.cs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
using System.Text;
1515
using System.Text.Json;
1616
using System.Text.Json.Serialization;
17+
using System.Text.Json.Serialization.Metadata;
1718
using System.Text.RegularExpressions;
1819
using GitHub.Copilot.SDK.Rpc;
1920
using System.Globalization;
@@ -1254,6 +1255,12 @@ private static JsonSerializerOptions CreateSerializerOptions()
12541255
options.TypeInfoResolverChain.Add(SessionEventsJsonContext.Default);
12551256
options.TypeInfoResolverChain.Add(SDK.Rpc.RpcJsonContext.Default);
12561257

1258+
// StreamJsonRpc's RequestId needs serialization when CancellationToken fires during
1259+
// JSON-RPC operations. Its built-in converter (RequestIdSTJsonConverter) is internal,
1260+
// and [JsonSerializable] can't source-gen for it (SYSLIB1220), so we provide our own
1261+
// AOT-safe resolver + converter.
1262+
options.TypeInfoResolverChain.Add(new RequestIdTypeInfoResolver());
1263+
12571264
options.MakeReadOnly();
12581265

12591266
return options;
@@ -1687,6 +1694,50 @@ private static LogLevel MapLevel(TraceEventType eventType)
16871694
[JsonSerializable(typeof(UserInputResponse))]
16881695
internal partial class ClientJsonContext : JsonSerializerContext;
16891696

1697+
/// <summary>
1698+
/// AOT-safe type info resolver for <see cref="RequestId"/>.
1699+
/// StreamJsonRpc's own RequestIdSTJsonConverter is internal (SYSLIB1220/CS0122),
1700+
/// so we provide our own converter and wire it through <see cref="JsonMetadataServices.CreateValueInfo{T}"/>
1701+
/// to stay fully AOT/trimming-compatible.
1702+
/// </summary>
1703+
private sealed class RequestIdTypeInfoResolver : IJsonTypeInfoResolver
1704+
{
1705+
public JsonTypeInfo? GetTypeInfo(Type type, JsonSerializerOptions options)
1706+
{
1707+
if (type == typeof(RequestId))
1708+
return JsonMetadataServices.CreateValueInfo<RequestId>(options, new RequestIdJsonConverter());
1709+
return null;
1710+
}
1711+
}
1712+
1713+
private sealed class RequestIdJsonConverter : JsonConverter<RequestId>
1714+
{
1715+
public override RequestId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
1716+
{
1717+
return reader.TokenType switch
1718+
{
1719+
JsonTokenType.Number => reader.TryGetInt64(out long val)
1720+
? new RequestId(val)
1721+
: new RequestId(reader.HasValueSequence
1722+
? Encoding.UTF8.GetString(reader.ValueSequence)
1723+
: Encoding.UTF8.GetString(reader.ValueSpan)),
1724+
JsonTokenType.String => new RequestId(reader.GetString()!),
1725+
JsonTokenType.Null => RequestId.Null,
1726+
_ => throw new JsonException($"Unexpected token type for RequestId: {reader.TokenType}"),
1727+
};
1728+
}
1729+
1730+
public override void Write(Utf8JsonWriter writer, RequestId value, JsonSerializerOptions options)
1731+
{
1732+
if (value.Number.HasValue)
1733+
writer.WriteNumberValue(value.Number.Value);
1734+
else if (value.String is not null)
1735+
writer.WriteStringValue(value.String);
1736+
else
1737+
writer.WriteNullValue();
1738+
}
1739+
}
1740+
16901741
[GeneratedRegex(@"listening on port ([0-9]+)", RegexOptions.IgnoreCase)]
16911742
private static partial Regex ListeningOnPortRegex();
16921743
}

dotnet/test/SerializationTests.cs

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
*--------------------------------------------------------------------------------------------*/
4+
5+
using Xunit;
6+
using System.Text.Json;
7+
using System.Text.Json.Serialization;
8+
using StreamJsonRpc;
9+
10+
namespace GitHub.Copilot.SDK.Test;
11+
12+
/// <summary>
13+
/// Tests for JSON serialization compatibility, particularly for StreamJsonRpc types
14+
/// that are needed when CancellationTokens fire during JSON-RPC operations.
15+
/// This test suite verifies the fix for https://github.com/PureWeen/PolyPilot/issues/319
16+
/// </summary>
17+
public class SerializationTests
18+
{
19+
/// <summary>
20+
/// Verifies that StreamJsonRpc.RequestId can be round-tripped using the SDK's configured
21+
/// JsonSerializerOptions. This is critical for preventing NotSupportedException when
22+
/// StandardCancellationStrategy fires during JSON-RPC operations.
23+
/// </summary>
24+
[Fact]
25+
public void RequestId_CanBeSerializedAndDeserialized_WithSdkOptions()
26+
{
27+
var options = GetSerializerOptions();
28+
29+
// Long id
30+
var jsonLong = JsonSerializer.Serialize(new RequestId(42L), options);
31+
Assert.Equal("42", jsonLong);
32+
Assert.Equal(new RequestId(42L), JsonSerializer.Deserialize<RequestId>(jsonLong, options));
33+
34+
// String id
35+
var jsonStr = JsonSerializer.Serialize(new RequestId("req-1"), options);
36+
Assert.Equal("\"req-1\"", jsonStr);
37+
Assert.Equal(new RequestId("req-1"), JsonSerializer.Deserialize<RequestId>(jsonStr, options));
38+
39+
// Null id
40+
var jsonNull = JsonSerializer.Serialize(RequestId.Null, options);
41+
Assert.Equal("null", jsonNull);
42+
Assert.Equal(RequestId.Null, JsonSerializer.Deserialize<RequestId>(jsonNull, options));
43+
}
44+
45+
[Theory]
46+
[InlineData(0L)]
47+
[InlineData(-1L)]
48+
[InlineData(long.MaxValue)]
49+
public void RequestId_NumericEdgeCases_RoundTrip(long id)
50+
{
51+
var options = GetSerializerOptions();
52+
var requestId = new RequestId(id);
53+
var json = JsonSerializer.Serialize(requestId, options);
54+
Assert.Equal(requestId, JsonSerializer.Deserialize<RequestId>(json, options));
55+
}
56+
57+
/// <summary>
58+
/// Verifies the SDK's options can resolve type info for RequestId,
59+
/// ensuring AOT-safe serialization without falling back to reflection.
60+
/// </summary>
61+
[Fact]
62+
public void SerializerOptions_CanResolveRequestIdTypeInfo()
63+
{
64+
var options = GetSerializerOptions();
65+
var typeInfo = options.GetTypeInfo(typeof(RequestId));
66+
Assert.NotNull(typeInfo);
67+
Assert.Equal(typeof(RequestId), typeInfo.Type);
68+
}
69+
70+
private static JsonSerializerOptions GetSerializerOptions()
71+
{
72+
var prop = typeof(CopilotClient)
73+
.GetProperty("SerializerOptionsForMessageFormatter",
74+
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
75+
76+
var options = (JsonSerializerOptions?)prop?.GetValue(null);
77+
Assert.NotNull(options);
78+
return options;
79+
}
80+
}

0 commit comments

Comments
 (0)