Public Access
Take a returning renderer's socket over instead of refusing it
One attach per process was WebView2's truth, not Android's: the phone kills the WebView's renderer independently of the app process, the page reloads, and its fresh socket was answered 409 by a guard that never reset — with no way back short of restarting the app. Only our own page knows the token, so a second valid upgrade is that page returning; it now displaces the old socket, which may never notice it is dead on its own, since a killed renderer sends no FIN. A send into the dead socket also no longer escapes as a fault. It used to unwind the pump's flush loop, after which nothing drained the credit window and the still-live shell froze behind it for good — including the BCL quirk where such a send surfaces as an OperationCanceledException nobody's token asked for.
This commit is contained in:
@@ -142,15 +142,96 @@ public sealed class TerminalDataPlaneTests : IAsyncDisposable
|
||||
await ConnectAsync(origin: "https://evil.example"));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The truth this replaced: a second valid attach used to be a 409, on the theory that one renderer
|
||||
/// lives for the whole process. Android's WebView does not honour that theory — its renderer process is
|
||||
/// routinely killed and the page reloads with a fresh socket — so a second valid attach is now a
|
||||
/// takeover. This asserts both halves: the newcomer gets the connection, and the displaced socket
|
||||
/// actually goes rather than lingering as a phantom nothing is reading from.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ASecondRenderer_IsRejected()
|
||||
public async Task ASecondRenderer_TakesOver_AndTheFirstSocketIsDropped()
|
||||
{
|
||||
Start();
|
||||
|
||||
using var first = await ConnectAsync();
|
||||
first.State.ShouldBe(WebSocketState.Open);
|
||||
|
||||
await Should.ThrowAsync<WebSocketException>(async () => await ConnectAsync());
|
||||
using var second = await ConnectAsync();
|
||||
second.State.ShouldBe(WebSocketState.Open);
|
||||
|
||||
// The first socket was aborted rather than closed gracefully — Abort skips the close handshake
|
||||
// entirely, so there is no Close frame for this side to see coming. What a receive on it sees
|
||||
// instead is the connection simply gone, which the client surfaces as an exception rather than as
|
||||
// a state that quietly flips on its own; nothing here reads from the socket otherwise, so the
|
||||
// state alone would not move.
|
||||
var firstBuffer = new byte[16];
|
||||
await Should.ThrowAsync<Exception>(async () =>
|
||||
await first.ReceiveAsync(firstBuffer.AsMemory(), TestContext.Current.CancellationToken));
|
||||
|
||||
await using var session = new FakeShellSession(bytesToProduce: 64);
|
||||
await using var pump = CreatePump(session);
|
||||
plane.Register(SessionId, pump);
|
||||
|
||||
var run = pump.RunAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var opened = await ReceiveAsync(second);
|
||||
opened.Opcode.ShouldBe((byte)TerminalServerOpcode.SessionOpened);
|
||||
|
||||
var output = await ReceiveAsync(second);
|
||||
output.Opcode.ShouldBe((byte)TerminalServerOpcode.Output);
|
||||
output.Payload.Length.ShouldBe(64);
|
||||
|
||||
await SendAsync(
|
||||
second,
|
||||
(byte)TerminalClientOpcode.Acknowledge,
|
||||
TerminalFrame.CreateAcknowledgementPayload((uint)output.Payload.Length));
|
||||
|
||||
await run;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The other half of the takeover: a renderer process that dies without a close handshake — which is
|
||||
/// what a killed Android WebView actually does, no FIN, nothing — must not fault the send path. A
|
||||
/// faulted send would propagate into <see cref="TerminalSessionPump"/>'s flush loop and freeze a live
|
||||
/// session; see <see cref="TerminalDataPlane.SendAsync"/>'s remark for why. Disposing the client socket
|
||||
/// abruptly, with no close handshake sent, is the closest this harness gets to that: the server-side
|
||||
/// socket is left believing itself open until it actually tries to write to it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Driven straight through <see cref="TerminalDataPlane.SendAsync"/> rather than through a pump, because
|
||||
/// a pump adds nothing here — the point is entirely about the transport's own contract, and a session
|
||||
/// layered on top would only leave it unclear whether a passing test proved the transport never threw or
|
||||
/// merely that the frames never happened to need a live socket.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task SendAsync_DoesNotThrow_WhenTheAttachedRendererDiedWithoutClosing_AndAFreshAttachStillReceives()
|
||||
{
|
||||
Start();
|
||||
|
||||
var first = await ConnectAsync();
|
||||
first.State.ShouldBe(WebSocketState.Open);
|
||||
first.Dispose();
|
||||
|
||||
// Whether this particular send lands on the OS's send buffer before the peer's absence is noticed,
|
||||
// or fails immediately, is not the point — either way it must not throw.
|
||||
await Should.NotThrowAsync(async () =>
|
||||
await plane.SendAsync(
|
||||
TerminalFrame.Create((byte)TerminalServerOpcode.Output, SessionId, "before"u8.ToArray()),
|
||||
TestContext.Current.CancellationToken));
|
||||
|
||||
using var second = await ConnectAsync();
|
||||
|
||||
await Should.NotThrowAsync(async () =>
|
||||
await plane.SendAsync(
|
||||
TerminalFrame.Create((byte)TerminalServerOpcode.Output, SessionId, "after"u8.ToArray()),
|
||||
TestContext.Current.CancellationToken));
|
||||
|
||||
var output = await ReceiveAsync(second);
|
||||
output.Opcode.ShouldBe((byte)TerminalServerOpcode.Output);
|
||||
Encoding.UTF8.GetString(output.Payload).ShouldBe("after");
|
||||
}
|
||||
|
||||
// ---- Frames ----
|
||||
|
||||
Reference in New Issue
Block a user