using System;
using System.Collections.Concurrent;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace Acp;
///
/// A JSON-RPC 2.0 peer over an : correlates outbound requests with their
/// responses by id, and dispatches inbound requests/notifications to registered handlers. One
/// endpoint drives one connection; both ACP sides build on it.
///
internal sealed class JsonRpcEndpoint
{
private IAcpTransport Transport { get; }
private ConcurrentDictionary> Pending { get; } = new();
private ConcurrentDictionary>> RequestHandlers { get; } = new();
private ConcurrentDictionary> NotificationHandlers { get; } = new();
private Func>? DefaultRequestHandler { get; set; }
private Func? DefaultNotificationHandler { get; set; }
private long nextId;
public JsonRpcEndpoint(IAcpTransport transport) => Transport = transport;
public void OnRequest(string method, Func> handler) =>
RequestHandlers[method] = handler;
public void OnNotification(string method, Func handler) =>
NotificationHandlers[method] = handler;
public void OnUnknownRequest(Func> handler) =>
DefaultRequestHandler = handler;
public void OnUnknownNotification(Func handler) =>
DefaultNotificationHandler = handler;
/// Reads and dispatches messages until the transport closes or cancellation fires.
public async Task RunAsync(CancellationToken cancellationToken = default)
{
try
{
while (!cancellationToken.IsCancellationRequested)
{
string? line = await Transport.ReadLineAsync(cancellationToken).ConfigureAwait(false);
if (line is null) break; // stream closed
string trimmed = line.Trim();
if (trimmed.Length < 2 || trimmed[0] != '{' || trimmed[^1] != '}')
continue; // skip banners / non-JSON noise
await DispatchAsync(line, cancellationToken).ConfigureAwait(false);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// normal shutdown
}
finally
{
FailPending(new AcpException("Connection closed."));
}
}
// Input is raw stdout from an external agent CLI: the line-51 brace guard is first/last-char
// only, so a malformed-but-brace-wrapped frame can still fail JsonDocument.Parse or a
// Deserialize. Any such JsonException must stay contained to this one frame: it must never
// escape into RunAsync, where the finally would FailPending every in-flight request and kill
// the read loop. For a method+id frame we can reply with InvalidRequest; for everything else
// (we may not have an id) we skip the frame and keep reading.
private async ValueTask DispatchAsync(string line, CancellationToken ct)
{
JsonDocument doc;
try
{
doc = JsonDocument.Parse(line);
}
catch (JsonException)
{
return;
}
using (doc)
{
JsonElement root = doc.RootElement;
bool hasId = root.TryGetProperty("id", out _);
bool hasMethod = root.TryGetProperty("method", out _);
if (hasMethod && hasId)
{
JsonRpcRequest request;
try
{
request = root.Deserialize(AcpJson.Options)!;
}
catch (JsonException ex)
{
// Reply only if the id itself is well-formed (number/string); otherwise we have
// nothing to correlate a response to, so skip the frame.
if (TryReadId(root, out RequestId id))
await SendAsync(Error(id, JsonRpcErrorCode.InvalidRequest, ex.Message), ct).ConfigureAwait(false);
return;
}
await HandleRequestAsync(request, ct).ConfigureAwait(false);
}
else if (hasMethod)
{
JsonRpcNotification notification;
try
{
notification = root.Deserialize(AcpJson.Options)!;
}
catch (JsonException)
{
return;
}
Func? handler =
NotificationHandlers.TryGetValue(notification.Method, out Func? h) ? h : DefaultNotificationHandler;
if (handler is not null)
await handler(notification, ct).ConfigureAwait(false);
}
else if (hasId)
{
JsonRpcResponse response;
try
{
response = root.Deserialize(AcpJson.Options)!;
}
catch (JsonException)
{
return;
}
if (Pending.TryRemove(response.Id, out TaskCompletionSource? tcs))
tcs.TrySetResult(response);
}
}
}
private async ValueTask HandleRequestAsync(JsonRpcRequest request, CancellationToken ct)
{
JsonRpcResponse response;
try
{
Func>? handler =
RequestHandlers.TryGetValue(request.Method, out Func>? h) ? h : DefaultRequestHandler;
response = handler is not null
? await handler(request, ct).ConfigureAwait(false)
: Error(request.Id, JsonRpcErrorCode.MethodNotFound, $"Method '{request.Method}' is not available");
}
catch (AcpException ex)
{
response = new JsonRpcResponse { Id = request.Id, Error = new JsonRpcError { Code = ex.Code, Message = ex.Message, Data = ex.ErrorData } };
}
catch (Exception ex)
{
response = Error(request.Id, JsonRpcErrorCode.InternalError, ex.Message);
}
await SendAsync(response, ct).ConfigureAwait(false);
}
public async ValueTask SendRequestAsync(string method, JsonElement? @params, CancellationToken ct)
{
RequestId id = RequestId.Of(Interlocked.Increment(ref nextId));
TaskCompletionSource tcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
Pending[id] = tcs;
using (ct.Register(static state => ((TaskCompletionSource)state!).TrySetCanceled(), tcs))
{
await SendAsync(new JsonRpcRequest { Id = id, Method = method, Params = @params }, ct).ConfigureAwait(false);
return await tcs.Task.ConfigureAwait(false);
}
}
public ValueTask SendNotificationAsync(string method, JsonElement? @params, CancellationToken ct) =>
SendAsync(new JsonRpcNotification { Method = method, Params = @params }, ct);
private ValueTask SendAsync(T message, CancellationToken ct) =>
Transport.WriteLineAsync(JsonSerializer.Serialize(message, AcpJson.Options), ct);
private static JsonRpcResponse Error(RequestId id, JsonRpcErrorCode code, string message) =>
new() { Id = id, Error = new JsonRpcError { Code = (int)code, Message = message } };
private static bool TryReadId(JsonElement root, out RequestId id)
{
try
{
id = root.GetProperty("id").Deserialize(AcpJson.Options);
return id.IsValid;
}
catch (Exception ex) when (ex is JsonException or FormatException or OverflowException)
{
id = default;
return false;
}
}
private void FailPending(Exception ex)
{
foreach (RequestId key in Pending.Keys)
if (Pending.TryRemove(key, out TaskCompletionSource? tcs))
tcs.TrySetException(ex);
}
}