Skip to content

Commit 3067bac

Browse files
adirh3Copilot
andauthored
dotnet: release oversized JSON-RPC receive buffers (#2047)
* Fix oversized JSON-RPC buffer retention Bound the reusable receive buffer after a completed large frame while preserving any carried bytes for the next frame. Add a regression test that verifies the next read does not retain a multi-megabyte buffer. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Strengthen oversized buffer carry-over test Keep the receive stream open, coalesce a second valid response with the oversized frame, and verify both carried-message processing and the subsequent bounded read buffer. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 2f76dd2 commit 3067bac

2 files changed

Lines changed: 148 additions & 2 deletions

File tree

dotnet/src/JsonRpc.cs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ internal sealed partial class JsonRpc : IDisposable
2929
{
3030
private const int ErrorCodeMethodNotFound = -32601;
3131
private const int ErrorCodeInternalError = -32603;
32+
private const int InitialReadBufferSize = 256;
33+
private const int MaximumRetainedReadBufferSize = 1024 * 1024;
3234

3335
private readonly Stream _sendStream;
3436
private readonly Stream _receiveStream;
@@ -259,7 +261,7 @@ private static byte[] BuildFrame(ReadOnlySpan<byte> json, out int frameLen)
259261

260262
private async Task ReadLoopAsync(CancellationToken cancellationToken)
261263
{
262-
var buffer = new byte[256];
264+
var buffer = new byte[InitialReadBufferSize];
263265
int carried = 0; // bytes in buffer carried over from previous read
264266
try
265267
{
@@ -298,6 +300,17 @@ private async Task ReadLoopAsync(CancellationToken cancellationToken)
298300
Buffer.BlockCopy(buffer, contentLength, buffer, 0, carried);
299301
}
300302

303+
if (buffer.Length > MaximumRetainedReadBufferSize)
304+
{
305+
var retainedBuffer = new byte[Math.Max(InitialReadBufferSize, carried)];
306+
if (carried > 0)
307+
{
308+
Buffer.BlockCopy(buffer, 0, retainedBuffer, 0, carried);
309+
}
310+
311+
buffer = retainedBuffer;
312+
}
313+
301314
if (message is not { } parsed)
302315
{
303316
continue;

dotnet/test/Unit/JsonRpcTests.cs

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*--------------------------------------------------------------------------------------------*/
44

55
using System.Reflection;
6+
using System.Text;
67
using System.Text.Json;
78
using System.Text.Json.Serialization.Metadata;
89
using Xunit;
@@ -93,6 +94,57 @@ public async Task JsonRpc_Cancels_And_Disposes_Pending_Requests()
9394
await Assert.ThrowsAnyAsync<ObjectDisposedException>(() => pending);
9495
}
9596

97+
[Fact]
98+
public async Task JsonRpc_Does_Not_Retain_Oversized_Receive_Buffer()
99+
{
100+
var oversizedFrame = CreateResponseFrame(
101+
long.MaxValue,
102+
"ignored",
103+
headerPaddingLength: 1024 * 1024);
104+
var carriedFrame = CreateResponseFrame(1, "carried");
105+
using var receiveStream = new CoalescedFramesThenWaitStream(oversizedFrame, carriedFrame);
106+
using var rpc = new JsonRpcReflection(Stream.Null, receiveStream);
107+
108+
var carriedResponse = rpc.InvokeAsync<string>("pending", args: null);
109+
rpc.StartListening();
110+
111+
var responseCompleted = await Task.WhenAny(
112+
carriedResponse,
113+
Task.Delay(TimeSpan.FromSeconds(5)));
114+
Assert.Same(carriedResponse, responseCompleted);
115+
Assert.Equal("carried", await carriedResponse);
116+
Assert.True(receiveStream.FramesWereCoalesced);
117+
118+
var readCompleted = await Task.WhenAny(
119+
receiveStream.PostFrameReadBufferSize,
120+
Task.Delay(TimeSpan.FromSeconds(5)));
121+
Assert.Same(receiveStream.PostFrameReadBufferSize, readCompleted);
122+
Assert.InRange(await receiveStream.PostFrameReadBufferSize, 1, 1024 * 1024);
123+
}
124+
125+
private static byte[] CreateResponseFrame(long id, string result, int headerPaddingLength = 0)
126+
{
127+
using var bodyStream = new MemoryStream();
128+
using (var writer = new Utf8JsonWriter(bodyStream))
129+
{
130+
writer.WriteStartObject();
131+
writer.WriteString("jsonrpc", "2.0");
132+
writer.WriteNumber("id", id);
133+
writer.WriteString("result", result);
134+
writer.WriteEndObject();
135+
}
136+
137+
var body = bodyStream.ToArray();
138+
var paddingHeader = headerPaddingLength > 0
139+
? $"X-Padding: {new string('x', headerPaddingLength)}\r\n"
140+
: string.Empty;
141+
var header = Encoding.ASCII.GetBytes($"{paddingHeader}Content-Length: {body.Length}\r\n\r\n");
142+
var frame = new byte[header.Length + body.Length];
143+
header.CopyTo(frame, 0);
144+
body.CopyTo(frame, header.Length);
145+
return frame;
146+
}
147+
96148
private static int GetRemoteErrorCode(Exception exception)
97149
{
98150
var property = exception.GetType().GetProperty("ErrorCode", BindingFlags.Instance | BindingFlags.Public);
@@ -170,12 +222,17 @@ private sealed class JsonRpcReflection : IDisposable
170222
private readonly object _instance;
171223

172224
public JsonRpcReflection(Stream stream)
225+
: this(stream, stream)
226+
{
227+
}
228+
229+
public JsonRpcReflection(Stream sendStream, Stream receiveStream)
173230
{
174231
_instance = Activator.CreateInstance(
175232
JsonRpcType,
176233
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
177234
binder: null,
178-
args: [stream, stream, SerializerOptions, null],
235+
args: [sendStream, receiveStream, SerializerOptions, null],
179236
culture: null)!;
180237
}
181238

@@ -198,6 +255,82 @@ public async Task<T> InvokeAsync<T>(string methodName, object?[]? args, Cancella
198255
public void Dispose() => ((IDisposable)_instance).Dispose();
199256
}
200257

258+
private sealed class CoalescedFramesThenWaitStream : Stream
259+
{
260+
private readonly TaskCompletionSource<int> _postFrameReadBufferSize =
261+
new(TaskCreationOptions.RunContinuationsAsynchronously);
262+
private readonly byte[] _frames;
263+
private readonly int _firstFrameLength;
264+
private int _offset;
265+
266+
public CoalescedFramesThenWaitStream(byte[] firstFrame, byte[] secondFrame)
267+
{
268+
_firstFrameLength = firstFrame.Length;
269+
_frames = new byte[firstFrame.Length + secondFrame.Length];
270+
firstFrame.CopyTo(_frames, 0);
271+
secondFrame.CopyTo(_frames, firstFrame.Length);
272+
}
273+
274+
public bool FramesWereCoalesced { get; private set; }
275+
276+
public Task<int> PostFrameReadBufferSize => _postFrameReadBufferSize.Task;
277+
278+
public override bool CanRead => true;
279+
280+
public override bool CanSeek => false;
281+
282+
public override bool CanWrite => false;
283+
284+
public override long Length => throw new NotSupportedException();
285+
286+
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
287+
288+
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
289+
290+
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) =>
291+
ReadCoreAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask();
292+
293+
#if NET8_0_OR_GREATER
294+
public override
295+
#else
296+
internal
297+
#endif
298+
ValueTask<int> ReadAsync(Memory<byte> destination, CancellationToken cancellationToken = default) =>
299+
ReadCoreAsync(destination, cancellationToken);
300+
301+
public override void Flush()
302+
{
303+
}
304+
305+
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
306+
307+
public override void SetLength(long value) => throw new NotSupportedException();
308+
309+
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
310+
311+
private ValueTask<int> ReadCoreAsync(Memory<byte> destination, CancellationToken cancellationToken)
312+
{
313+
if (_offset >= _frames.Length)
314+
{
315+
_postFrameReadBufferSize.TrySetResult(destination.Length);
316+
return new ValueTask<int>(WaitForCancellationAsync(cancellationToken));
317+
}
318+
319+
var startingOffset = _offset;
320+
var bytesRead = Math.Min(destination.Length, _frames.Length - _offset);
321+
_frames.AsMemory(_offset, bytesRead).CopyTo(destination);
322+
_offset += bytesRead;
323+
FramesWereCoalesced |= startingOffset < _firstFrameLength && _offset == _frames.Length;
324+
return new ValueTask<int>(bytesRead);
325+
}
326+
327+
private static async Task<int> WaitForCancellationAsync(CancellationToken cancellationToken)
328+
{
329+
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false);
330+
return 0;
331+
}
332+
}
333+
201334
private sealed class InMemoryDuplexStream : Stream
202335
{
203336
private readonly Queue<byte> _buffer = new();

0 commit comments

Comments
 (0)