Skip to content

Commit 2137e35

Browse files
nficanoclaude
andcommitted
feat(runtime): surface INVALID_REQUEST on malformed envelopes (§12)
`WebSocketTransport.TryDeserialize` and the equivalent path in `StdioTransport` previously swallowed `ArcpException`/`JsonException` and returned null, so the misbehaving peer kept sending bad envelopes with no feedback. Spec §12 explicitly defines `INVALID_REQUEST` as the response. Introduce an internal `arcp.invalid_envelope` sentinel envelope plus the matching `InvalidEnvelopePayload` that transports yield on parse failure. `SessionState.Dispatch` recognizes the sentinel and throws `InvalidRequestException`, which the existing receiver-loop catch converts into an outbound `session.error{INVALID_REQUEST}`. The sentinel is never put on the wire. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f88129a commit 2137e35

8 files changed

Lines changed: 86 additions & 5 deletions

File tree

src/Arcp.Core/Envelope/MessageTypeRegistry.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ public static MessageTypeRegistry CreateCoreCatalog()
4949
r.Register(MessageTypeNames.SessionJobs, typeof(SessionJobsPayload));
5050
r.Register(MessageTypeNames.SessionError, typeof(SessionErrorPayload));
5151
r.Register(MessageTypeNames.SessionResume, typeof(SessionResumePayload));
52+
r.Register(MessageTypeNames.InvalidEnvelope, typeof(InvalidEnvelopePayload));
5253
r.Register(MessageTypeNames.JobSubmit, typeof(JobSubmitPayload));
5354
r.Register(MessageTypeNames.JobAccepted, typeof(JobAcceptedPayload));
5455
r.Register(MessageTypeNames.JobEvent, typeof(JobEventPayload));
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
using System.Text.Json.Serialization;
3+
4+
namespace Arcp.Core.Messages;
5+
6+
/// <summary>Internal payload for the <c>arcp.invalid_envelope</c> sentinel emitted by a transport
7+
/// when a peer sends an envelope that fails to parse. Never transmitted over the wire; the
8+
/// dispatcher converts it into an outbound <c>session.error{INVALID_REQUEST}</c> per spec §12.</summary>
9+
public sealed record InvalidEnvelopePayload
10+
{
11+
/// <summary>The parse-error message (truncated when logged so as not to echo arbitrary client
12+
/// content back to peers in the error detail).</summary>
13+
[JsonPropertyName("parse_error")] public required string ParseError { get; init; }
14+
}

src/Arcp.Core/Messages/MessageTypeNames.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ public static class MessageTypeNames
3333
/// <summary>Gets the session resume.</summary>
3434
public const string SessionResume = "session.resume";
3535

36+
/// <summary>Sentinel emitted by a transport when an inbound envelope fails to deserialize.
37+
/// The dispatcher converts this into an outbound <c>session.error{INVALID_REQUEST}</c> per
38+
/// spec §12 so the misbehaving peer receives feedback instead of silent drop. Not transmitted
39+
/// over the wire.</summary>
40+
public const string InvalidEnvelope = "arcp.invalid_envelope";
41+
3642
/// <summary>Gets the job submit.</summary>
3743
public const string JobSubmit = "job.submit";
3844
/// <summary>Gets the job accepted.</summary>

src/Arcp.Core/PublicAPI.Unshipped.txt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,12 @@ Arcp.Core.Messages.SessionPingPayload.Nonce.init -> void
512512
Arcp.Core.Messages.SessionPingPayload.SentAt.get -> System.DateTimeOffset
513513
Arcp.Core.Messages.SessionPingPayload.SentAt.init -> void
514514
Arcp.Core.Messages.SessionPingPayload.SessionPingPayload() -> void
515+
Arcp.Core.Messages.InvalidEnvelopePayload
516+
Arcp.Core.Messages.InvalidEnvelopePayload.<Clone>$() -> Arcp.Core.Messages.InvalidEnvelopePayload!
517+
Arcp.Core.Messages.InvalidEnvelopePayload.Equals(Arcp.Core.Messages.InvalidEnvelopePayload? other) -> bool
518+
Arcp.Core.Messages.InvalidEnvelopePayload.InvalidEnvelopePayload() -> void
519+
Arcp.Core.Messages.InvalidEnvelopePayload.ParseError.get -> string!
520+
Arcp.Core.Messages.InvalidEnvelopePayload.ParseError.init -> void
515521
Arcp.Core.Messages.SessionResumePayload
516522
Arcp.Core.Messages.SessionResumePayload.<Clone>$() -> Arcp.Core.Messages.SessionResumePayload!
517523
Arcp.Core.Messages.SessionResumePayload.Equals(Arcp.Core.Messages.SessionResumePayload? other) -> bool
@@ -716,6 +722,7 @@ const Arcp.Core.Messages.MessageTypeNames.SessionJobs = "session.jobs" -> string
716722
const Arcp.Core.Messages.MessageTypeNames.SessionListJobs = "session.list_jobs" -> string!
717723
const Arcp.Core.Messages.MessageTypeNames.SessionPing = "session.ping" -> string!
718724
const Arcp.Core.Messages.MessageTypeNames.SessionPong = "session.pong" -> string!
725+
const Arcp.Core.Messages.MessageTypeNames.InvalidEnvelope = "arcp.invalid_envelope" -> string!
719726
const Arcp.Core.Messages.MessageTypeNames.SessionResume = "session.resume" -> string!
720727
const Arcp.Core.Messages.MessageTypeNames.SessionWelcome = "session.welcome" -> string!
721728
const Arcp.Core.Messages.StatusPhases.CredentialRotated = "credential_rotated" -> string!
@@ -850,6 +857,9 @@ override Arcp.Core.Messages.SessionPingPayload.ToString() -> string!
850857
override Arcp.Core.Messages.SessionPongPayload.Equals(object? obj) -> bool
851858
override Arcp.Core.Messages.SessionPongPayload.GetHashCode() -> int
852859
override Arcp.Core.Messages.SessionPongPayload.ToString() -> string!
860+
override Arcp.Core.Messages.InvalidEnvelopePayload.Equals(object? obj) -> bool
861+
override Arcp.Core.Messages.InvalidEnvelopePayload.GetHashCode() -> int
862+
override Arcp.Core.Messages.InvalidEnvelopePayload.ToString() -> string!
853863
override Arcp.Core.Messages.SessionResumePayload.Equals(object? obj) -> bool
854864
override Arcp.Core.Messages.SessionResumePayload.GetHashCode() -> int
855865
override Arcp.Core.Messages.SessionResumePayload.ToString() -> string!
@@ -983,6 +993,8 @@ static Arcp.Core.Messages.SessionPingPayload.operator !=(Arcp.Core.Messages.Sess
983993
static Arcp.Core.Messages.SessionPingPayload.operator ==(Arcp.Core.Messages.SessionPingPayload? left, Arcp.Core.Messages.SessionPingPayload? right) -> bool
984994
static Arcp.Core.Messages.SessionPongPayload.operator !=(Arcp.Core.Messages.SessionPongPayload? left, Arcp.Core.Messages.SessionPongPayload? right) -> bool
985995
static Arcp.Core.Messages.SessionPongPayload.operator ==(Arcp.Core.Messages.SessionPongPayload? left, Arcp.Core.Messages.SessionPongPayload? right) -> bool
996+
static Arcp.Core.Messages.InvalidEnvelopePayload.operator !=(Arcp.Core.Messages.InvalidEnvelopePayload? left, Arcp.Core.Messages.InvalidEnvelopePayload? right) -> bool
997+
static Arcp.Core.Messages.InvalidEnvelopePayload.operator ==(Arcp.Core.Messages.InvalidEnvelopePayload? left, Arcp.Core.Messages.InvalidEnvelopePayload? right) -> bool
986998
static Arcp.Core.Messages.SessionResumePayload.operator !=(Arcp.Core.Messages.SessionResumePayload? left, Arcp.Core.Messages.SessionResumePayload? right) -> bool
987999
static Arcp.Core.Messages.SessionResumePayload.operator ==(Arcp.Core.Messages.SessionResumePayload? left, Arcp.Core.Messages.SessionResumePayload? right) -> bool
9881000
static Arcp.Core.Messages.SessionWelcomePayload.operator !=(Arcp.Core.Messages.SessionWelcomePayload? left, Arcp.Core.Messages.SessionWelcomePayload? right) -> bool

src/Arcp.Core/Transport/StdioTransport.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using System.Text;
77
using System.Threading;
88
using System.Threading.Tasks;
9+
using Arcp.Core.Messages;
910
using Arcp.Core.Wire;
1011

1112
namespace Arcp.Core.Transport;
@@ -72,7 +73,11 @@ public async IAsyncEnumerable<Envelope> ReceiveAsync([EnumeratorCancellation] Ca
7273
}
7374
catch (Exception ex) when (ex is Errors.ArcpException or System.Text.Json.JsonException)
7475
{
75-
continue;
76+
env = new Envelope
77+
{
78+
Type = MessageTypeNames.InvalidEnvelope,
79+
Payload = new InvalidEnvelopePayload { ParseError = ex.Message },
80+
};
7681
}
7782
yield return env;
7883
}

src/Arcp.Core/Transport/WebSocketTransport.cs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using System.Text;
99
using System.Threading;
1010
using System.Threading.Tasks;
11+
using Arcp.Core.Messages;
1112
using Arcp.Core.Wire;
1213

1314
namespace Arcp.Core.Transport;
@@ -116,16 +117,22 @@ public async IAsyncEnumerable<Envelope> ReceiveAsync([EnumeratorCancellation] Ca
116117
{
117118
return ArcpJson.Deserialize(utf8);
118119
}
119-
catch (Errors.ArcpException)
120+
catch (Errors.ArcpException ex)
120121
{
121-
return null;
122+
return InvalidEnvelopeSentinel(ex.Message);
122123
}
123-
catch (System.Text.Json.JsonException)
124+
catch (System.Text.Json.JsonException ex)
124125
{
125-
return null;
126+
return InvalidEnvelopeSentinel(ex.Message);
126127
}
127128
}
128129

130+
private static Envelope InvalidEnvelopeSentinel(string parseError) => new()
131+
{
132+
Type = MessageTypeNames.InvalidEnvelope,
133+
Payload = new InvalidEnvelopePayload { ParseError = parseError },
134+
};
135+
129136
/// <summary>Close (asynchronous).</summary>
130137
public async ValueTask CloseAsync(string? reason = null, CancellationToken cancellationToken = default)
131138
{

src/Arcp.Runtime/SessionState.Dispatch.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ private async Task DispatchAsync(Envelope env, CancellationToken cancellationTok
4848
{
4949
switch (env.Type)
5050
{
51+
case MessageTypeNames.InvalidEnvelope:
52+
// Spec §12: surface INVALID_REQUEST to the peer so it gets feedback instead of
53+
// a silent drop. Detail keeps the parse-error short to avoid echoing bytes back.
54+
var parseError = (env.Payload as InvalidEnvelopePayload)?.ParseError ?? "malformed envelope";
55+
throw new InvalidRequestException("Malformed ARCP envelope", parseError);
5156
case MessageTypeNames.SessionHello:
5257
await HandleHelloAsync(env, cancellationToken).ConfigureAwait(false);
5358
break;

tests/Arcp.IntegrationTests/SessionDispatchTests.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,37 @@ public async Task Subscriber_cannot_cancel_others_job()
164164
result.Success.Should().BeTrue();
165165
}
166166

167+
[Fact]
168+
public async Task Malformed_envelope_yields_INVALID_REQUEST_session_error()
169+
{
170+
// Spec §12: malformed envelopes get explicit INVALID_REQUEST feedback (no silent drop).
171+
// Transports surface a sentinel `arcp.invalid_envelope` envelope on parse failure; the
172+
// dispatcher converts it into a `session.error{INVALID_REQUEST}` response.
173+
var (server, transport) = StartServer(s =>
174+
s.RegisterAgent("noop", (ctx, ct) => Task.FromResult<object?>(null)));
175+
176+
// Simulate a transport-level deserialization failure by sending the sentinel directly.
177+
await transport.SendAsync(new Arcp.Core.Wire.Envelope
178+
{
179+
Type = MessageTypeNames.InvalidEnvelope,
180+
Payload = new InvalidEnvelopePayload { ParseError = "test: not valid JSON" },
181+
});
182+
183+
Arcp.Core.Wire.Envelope? err = null;
184+
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
185+
try
186+
{
187+
await foreach (var env in transport.ReceiveAsync(cts.Token))
188+
{
189+
if (env.Type == MessageTypeNames.SessionError) { err = env; break; }
190+
}
191+
}
192+
catch (OperationCanceledException) { }
193+
194+
err.Should().NotBeNull();
195+
((SessionErrorPayload)err!.Payload!).Code.Should().Be(ErrorCode.InvalidRequest);
196+
}
197+
167198
[Fact]
168199
public async Task SessionBye_closes_the_session_cleanly()
169200
{

0 commit comments

Comments
 (0)