diff --git a/src/Xamarin.AndroidTools/Debugging/DebuggingExtensions.cs b/src/Xamarin.AndroidTools/Debugging/DebuggingExtensions.cs index 2915cd57159..53ecdf69685 100644 --- a/src/Xamarin.AndroidTools/Debugging/DebuggingExtensions.cs +++ b/src/Xamarin.AndroidTools/Debugging/DebuggingExtensions.cs @@ -23,7 +23,7 @@ public static class DebuggingExtensions // Twenty retries at 250 ms intervals allow up to five seconds for the process to appear. const int GET_PID_RETRY_COUNT = 20; const int WAIT_BEFORE_RETRY_GET_PID = 250; - const int WAIT_FOR_DEBUGGER_TO_ATTACH_MS = 1400; + internal static readonly TimeSpan WaitForDebuggerReadinessTimeout = TimeSpan.FromSeconds (30); /// /// Starts the process debugging using the given execution configuration @@ -181,10 +181,7 @@ public static async Task ConnectJdwpAsync(this AndroidDevice androidDevice, Exec await AdbServer.Default.ForwardPort (androidDevice, "tcp", jdwpClient.Port, "jdwp", pid, token).ConfigureAwait (false); try { await jdwpClient.ConnectAsync (token).ConfigureAwait (false); - - // Keep the Connection for 1300 milliseconds, otherwise the Android OS ignores the connection! - // https://github.com/aosp-mirror/platform_frameworks_base/blob/6b28a227400749f4f8ad1f56799370e7c2cab149/core/java/android/os/Debug.java#L101C50-L101C54 - await Task.Delay (WAIT_FOR_DEBUGGER_TO_ATTACH_MS, token).ConfigureAwait (false); + await jdwpClient.WaitForDebuggerReadinessAsync (WaitForDebuggerReadinessTimeout, config.LogWiter, token).ConfigureAwait (false); await jdwpClient.DisconnectAsync ().ConfigureAwait (false); } finally { diff --git a/src/Xamarin.AndroidTools/Debugging/Java/DdmCommandPacket.cs b/src/Xamarin.AndroidTools/Debugging/Java/DdmCommandPacket.cs new file mode 100644 index 00000000000..e5fd66984d7 --- /dev/null +++ b/src/Xamarin.AndroidTools/Debugging/Java/DdmCommandPacket.cs @@ -0,0 +1,26 @@ +using System; +using System.Buffers.Binary; +using System.Text; + +namespace Xamarin.AndroidTools.Debugging.Java +{ + internal class DdmCommandPacket : CommandPacket + { + public DdmCommandPacket (string chunkType, ReadOnlyMemory chunkData) + { + if (chunkType == null) + throw new ArgumentNullException (nameof (chunkType)); + if (chunkType.Length != 4) + throw new ArgumentException ("DDM chunk types must contain exactly four ASCII characters.", nameof (chunkType)); + + CommandSet = 0xc7; + Command = 0x01; + + var data = new byte [8 + chunkData.Length]; + Encoding.ASCII.GetBytes (chunkType, 0, chunkType.Length, data, 0); + BinaryPrimitives.WriteInt32BigEndian (data.AsSpan (4, 4), chunkData.Length); + chunkData.CopyTo (data.AsMemory (8)); + Data = data; + } + } +} diff --git a/src/Xamarin.AndroidTools/Debugging/Java/JdwpClient.cs b/src/Xamarin.AndroidTools/Debugging/Java/JdwpClient.cs index 5b57fa98146..756659786df 100644 --- a/src/Xamarin.AndroidTools/Debugging/Java/JdwpClient.cs +++ b/src/Xamarin.AndroidTools/Debugging/Java/JdwpClient.cs @@ -1,6 +1,7 @@ using System; using System.Buffers.Binary; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Net.Sockets; using System.Text; @@ -13,11 +14,19 @@ namespace Xamarin.AndroidTools.Debugging.Java public class JdwpClient : IDisposable { const string handshake = "JDWP-Handshake"; - const int packetSize = 11; + const int packetHeaderSize = 11; + const int maximumPacketSize = 16 * 1024 * 1024; + const int legacyDebuggerIdleDelayMilliseconds = 1400; + const uint chunkHelo = 0x48454c4f; + const uint chunkFeat = 0x46454154; + const uint chunkStag = 0x53544147; + const uint stageApplicationRunning = 0x415f474f; + const string bootStagesFeature = "support_boot_stages"; private bool disposed = false; private TcpClient tcpClient; - private NetworkStream stream; + private Stream stream; + private readonly Queue bufferedPackets = new Queue (); public string HostName { get; } @@ -29,35 +38,51 @@ public JdwpClient(string hostname = "127.0.0.1", int port = 8100) Port = port; } + internal JdwpClient (Stream stream) + { + this.stream = stream ?? throw new ArgumentNullException (nameof (stream)); + HostName = ""; + } + public async Task ConnectAsync(CancellationToken cancellationToken = default) { tcpClient = new TcpClient(); - await tcpClient.ConnectAsync(HostName, Port); + using (cancellationToken.Register (() => tcpClient.Close ())) { + try { + await tcpClient.ConnectAsync (HostName, Port).ConfigureAwait (false); + } catch (Exception) when (cancellationToken.IsCancellationRequested) { + throw new OperationCanceledException (cancellationToken); + } + } stream = tcpClient.GetStream(); var data = Encoding.ASCII.GetBytes(handshake); - await stream.WriteAsync(data, 0, data.Length); + await stream.WriteAsync(data, 0, data.Length, cancellationToken).ConfigureAwait (false); var buffer = new byte[handshake.Length]; - var replyBuffer = new byte[packetSize]; - - // Read handshake response - var read = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken); + await ReadExactlyAsync (buffer, cancellationToken).ConfigureAwait (false); - var str = Encoding.ASCII.GetString(buffer, 0, read); + var str = Encoding.ASCII.GetString(buffer, 0, buffer.Length); if (str.Equals(handshake)) { // Send version request command to kick things off - await SendAsync(new VersionCommandPacket(), cancellationToken); + var versionCommand = new VersionCommandPacket (); + await SendAsync(versionCommand, cancellationToken).ConfigureAwait (false); + + while (true) { + var reply = await ReadPacketAsync (cancellationToken).ConfigureAwait (false); + if (!reply.IsReply || reply.Id != versionCommand.Id) { + bufferedPackets.Enqueue (reply); + continue; + } + if (reply.ErrorCode != 0) + throw new InvalidDataException ($"JDWP version request failed with error {reply.ErrorCode}."); - var replies = await ReadReply (cancellationToken); - // Read the Result but we do not need to process it. - AndroidLogger.LogDebug ($"VersionCommandPacket:"); - foreach (var reply in replies) { str = Encoding.ASCII.GetString(reply.Data.ToArray (), 0, reply.Data.Length); - AndroidLogger.LogDebug ($"\t{str}"); + AndroidLogger.LogDebug ($"VersionCommandPacket:\n\t{str}"); + break; } } else @@ -66,50 +91,294 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) } } - async Task> ReadReply(CancellationToken cancellationToken = default) where T : ReplyPacket, new() + public Task WaitForDebuggerReadinessAsync (TimeSpan timeout, CancellationToken cancellationToken = default) { - List packets = new List (); - do - { - if (stream != null) - { - byte[] headerData = new byte[11]; + return WaitForDebuggerReadinessAsync ( + timeout, + (delay, token) => Task.Delay (delay, token), + logWriter: null, + cancellationToken + ); + } - // Read the header data or bust - var read = await stream.ReadAsync (headerData, 0, headerData.Length, cancellationToken); - if (read != headerData.Length) { - break; - } - - // Get overall packet length from header - ReadOnlyMemory h = headerData; - var packetLength = BinaryPrimitives.ReadUInt32BigEndian(h.Slice (0, 4).Span); - // The remaining packet buffer is total packet length minus header length - byte[] packetData = new byte[packetLength - headerData.Length]; - - if (packetData.Length > 0) { - // Read the remainder of the packet into the second buffer - int datalen = packetData.Length; - while (datalen > 0) { - read = await stream.ReadAsync (packetData, 0, datalen, cancellationToken); - datalen -= read; - if (read == 0) - break; - } - if (datalen > 0) { - break; + internal Task WaitForDebuggerReadinessAsync ( + TimeSpan timeout, + Action logWriter, + CancellationToken cancellationToken = default) + { + return WaitForDebuggerReadinessAsync ( + timeout, + (delay, token) => Task.Delay (delay, token), + logWriter, + cancellationToken + ); + } + + internal async Task WaitForDebuggerReadinessAsync ( + TimeSpan timeout, + Func delayAsync, + Action logWriter, + CancellationToken cancellationToken = default) + { + if (stream == null) + throw new InvalidOperationException ("The JDWP client is not connected."); + if (timeout <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException (nameof (timeout)); + if (delayAsync == null) + throw new ArgumentNullException (nameof (delayAsync)); + + var stopwatch = Stopwatch.StartNew (); + LogReadiness (logWriter, stopwatch, $"Waiting for FEAT and HELO; timeout={timeout.TotalMilliseconds:0} ms."); + + using (var timeoutSource = new CancellationTokenSource ()) + using (var linkedSource = CancellationTokenSource.CreateLinkedTokenSource (cancellationToken, timeoutSource.Token)) { + timeoutSource.CancelAfter (timeout); + using (linkedSource.Token.Register (() => stream.Dispose ())) { + try { + await WaitForDebuggerReadinessCoreAsync (delayAsync, logWriter, stopwatch, linkedSource.Token).ConfigureAwait (false); + } catch (Exception e) when (linkedSource.IsCancellationRequested) { + if (cancellationToken.IsCancellationRequested) { + LogReadiness (logWriter, stopwatch, "Canceled while waiting for A_GO."); + throw new OperationCanceledException ("Waiting for Android JDWP readiness was canceled.", e, cancellationToken); } + LogReadiness (logWriter, stopwatch, $"Timed out waiting for A_GO; timeout={timeout.TotalMilliseconds:0} ms."); + throw new TimeoutException ( + $"Timed out after {timeout.TotalMilliseconds:0} ms waiting for Android to reach JDWP stage A_GO.", + e + ); + } catch (InvalidDataException) { + LogReadiness (logWriter, stopwatch, "Protocol error while waiting for readiness."); + throw; } - var packet = new T (); - packet.FromMemory (headerData, packetData); - packets.Add (packet); } - else - { - break; + } + } + + async Task WaitForDebuggerReadinessCoreAsync ( + Func delayAsync, + Action logWriter, + Stopwatch stopwatch, + CancellationToken cancellationToken) + { + var heloData = new byte [4]; + BinaryPrimitives.WriteInt32BigEndian (heloData, 1); + var heloCommand = new DdmCommandPacket ("HELO", heloData); + var featCommand = new DdmCommandPacket ("FEAT", ReadOnlyMemory.Empty); + + await SendAsync (heloCommand, cancellationToken).ConfigureAwait (false); + await SendAsync (featCommand, cancellationToken).ConfigureAwait (false); + + bool heloReceived = false; + bool featReceived = false; + bool supportsBootStages = false; + bool applicationRunningSeen = false; + + while (true) { + var packet = bufferedPackets.Count > 0 + ? bufferedPackets.Dequeue () + : await ReadPacketAsync (cancellationToken).ConfigureAwait (false); + + if (packet.IsReply && packet.Id == heloCommand.Id) { + if (packet.ErrorCode != 0) + throw new InvalidDataException ($"DDM HELO request failed with error {packet.ErrorCode}."); + heloReceived = true; + var heloStage = ReadHeloStage (packet.Data); + LogReadiness (logWriter, stopwatch, $"HELO stage observed: {FormatStage (heloStage)}."); + applicationRunningSeen |= heloStage == stageApplicationRunning; + } else if (packet.IsReply && packet.Id == featCommand.Id) { + if (packet.ErrorCode != 0) + throw new InvalidDataException ($"DDM FEAT request failed with error {packet.ErrorCode}."); + featReceived = true; + supportsBootStages = ReadFeatures (packet.Data).Contains (bootStagesFeature); + LogReadiness ( + logWriter, + stopwatch, + supportsBootStages + ? "FEAT boot-stage support: available." + : $"FEAT boot-stage support: unavailable; using {legacyDebuggerIdleDelayMilliseconds} ms legacy fallback." + ); + } else if (!packet.IsReply && packet.CommandSet == 0xc7 && packet.Command == 0x01) { + if (TryReadChunk (packet.Data, out var chunkType, out var chunkData) && + chunkType == chunkStag && + chunkData.Length == 4) { + var stagStage = BinaryPrimitives.ReadUInt32BigEndian (chunkData.Span); + LogReadiness (logWriter, stopwatch, $"STAG stage observed: {FormatStage (stagStage)}."); + applicationRunningSeen |= stagStage == stageApplicationRunning; + } + } + + if (!featReceived) + continue; + + if (!supportsBootStages) { + await delayAsync (TimeSpan.FromMilliseconds (legacyDebuggerIdleDelayMilliseconds), cancellationToken).ConfigureAwait (false); + LogReadiness (logWriter, stopwatch, "Legacy fallback complete."); + return; + } + + if (heloReceived && applicationRunningSeen) { + LogReadiness (logWriter, stopwatch, "A_GO readiness complete."); + return; } - } while (stream.DataAvailable); - return packets; + } + } + + static void LogReadiness (Action logWriter, Stopwatch stopwatch, string message) + { + logWriter?.Invoke ($"JDWP readiness ({stopwatch.ElapsedMilliseconds} ms): {message}"); + } + + static string FormatStage (uint? stage) + { + if (!stage.HasValue) + return "not reported"; + + var value = stage.Value; + var characters = new char [4]; + for (int i = 0; i < characters.Length; i++) { + var character = (byte) (value >> (24 - (i * 8))); + if ((character < 'A' || character > 'Z') && character != '_') + return $"0x{value:x8}"; + characters [i] = (char) character; + } + return new string (characters); + } + + async Task ReadPacketAsync (CancellationToken cancellationToken) + { + if (stream == null) + throw new InvalidOperationException ("The JDWP client is not connected."); + + var header = new byte [packetHeaderSize]; + await ReadExactlyAsync (header, cancellationToken).ConfigureAwait (false); + + var packetLength = BinaryPrimitives.ReadInt32BigEndian (header.AsSpan (0, 4)); + if (packetLength < packetHeaderSize || packetLength > maximumPacketSize) + throw new InvalidDataException ($"Invalid JDWP packet length {packetLength}."); + + var data = new byte [packetLength - packetHeaderSize]; + await ReadExactlyAsync (data, cancellationToken).ConfigureAwait (false); + return new ReceivedPacket (header, data); + } + + async Task ReadExactlyAsync (byte [] buffer, CancellationToken cancellationToken) + { + if (stream == null) + throw new InvalidOperationException ("The JDWP client is not connected."); + + int offset = 0; + while (offset < buffer.Length) { + var read = await stream.ReadAsync (buffer, offset, buffer.Length - offset, cancellationToken).ConfigureAwait (false); + if (read == 0) + throw new EndOfStreamException ("The JDWP connection closed before the expected data was received."); + offset += read; + } + } + + static uint? ReadHeloStage (ReadOnlyMemory packetData) + { + if (!TryReadChunk (packetData, out var chunkType, out var data) || chunkType != chunkHelo) + throw new InvalidDataException ("The DDM HELO reply did not contain a HELO chunk."); + + var offset = 0; + ReadInt32 (data, ref offset); + ReadInt32 (data, ref offset); + var vmIdentifierLength = ReadNonNegativeLength (data, ref offset, "VM identifier"); + var applicationNameLength = ReadNonNegativeLength (data, ref offset, "application name"); + SkipUtf16String (data, ref offset, vmIdentifierLength, "VM identifier"); + SkipUtf16String (data, ref offset, applicationNameLength, "application name"); + + if (!HasBytes (data, offset, 4)) + return null; + ReadInt32 (data, ref offset); + if (!HasBytes (data, offset, 4)) + return null; + SkipUtf16String (data, ref offset, ReadNonNegativeLength (data, ref offset, "ABI"), "ABI"); + if (!HasBytes (data, offset, 4)) + return null; + SkipUtf16String (data, ref offset, ReadNonNegativeLength (data, ref offset, "JVM flags"), "JVM flags"); + if (!HasBytes (data, offset, 1)) + return null; + offset++; + if (!HasBytes (data, offset, 4)) + return null; + SkipUtf16String (data, ref offset, ReadNonNegativeLength (data, ref offset, "package name"), "package name"); + if (!HasBytes (data, offset, 4)) + return null; + return BinaryPrimitives.ReadUInt32BigEndian (data.Slice (offset, 4).Span); + } + + static HashSet ReadFeatures (ReadOnlyMemory packetData) + { + if (!TryReadChunk (packetData, out var chunkType, out var data) || chunkType != chunkFeat) + throw new InvalidDataException ("The DDM FEAT reply did not contain a FEAT chunk."); + + var offset = 0; + var featureCount = ReadNonNegativeLength (data, ref offset, "feature count"); + var features = new HashSet (StringComparer.Ordinal); + for (int i = 0; i < featureCount; i++) { + var featureLength = ReadNonNegativeLength (data, ref offset, "feature"); + var byteLength = CheckedUtf16ByteLength (featureLength, "feature"); + if (!HasBytes (data, offset, byteLength)) + throw new InvalidDataException ("The DDM FEAT reply ended in the middle of a feature name."); + features.Add (Encoding.BigEndianUnicode.GetString (data.Slice (offset, byteLength).ToArray ())); + offset += byteLength; + } + return features; + } + + static bool TryReadChunk (ReadOnlyMemory packetData, out uint chunkType, out ReadOnlyMemory chunkData) + { + chunkType = 0; + chunkData = ReadOnlyMemory.Empty; + if (packetData.Length < 8) + return false; + + chunkType = BinaryPrimitives.ReadUInt32BigEndian (packetData.Slice (0, 4).Span); + var chunkLength = BinaryPrimitives.ReadInt32BigEndian (packetData.Slice (4, 4).Span); + if (chunkLength < 0 || packetData.Length - 8 != chunkLength) + throw new InvalidDataException ($"Invalid DDM chunk length {chunkLength}."); + chunkData = packetData.Slice (8, chunkLength); + return true; + } + + static int ReadNonNegativeLength (ReadOnlyMemory data, ref int offset, string fieldName) + { + var length = ReadInt32 (data, ref offset); + if (length < 0) + throw new InvalidDataException ($"The DDM {fieldName} length cannot be negative."); + return length; + } + + static int ReadInt32 (ReadOnlyMemory data, ref int offset) + { + if (!HasBytes (data, offset, 4)) + throw new InvalidDataException ("The DDM payload ended before an expected integer."); + var value = BinaryPrimitives.ReadInt32BigEndian (data.Slice (offset, 4).Span); + offset += 4; + return value; + } + + static void SkipUtf16String (ReadOnlyMemory data, ref int offset, int characterLength, string fieldName) + { + var byteLength = CheckedUtf16ByteLength (characterLength, fieldName); + if (!HasBytes (data, offset, byteLength)) + throw new InvalidDataException ($"The DDM payload ended in the middle of the {fieldName}."); + offset += byteLength; + } + + static int CheckedUtf16ByteLength (int characterLength, string fieldName) + { + try { + return checked (characterLength * 2); + } catch (OverflowException e) { + throw new InvalidDataException ($"The DDM {fieldName} length is too large.", e); + } + } + + static bool HasBytes (ReadOnlyMemory data, int offset, int count) + { + return offset >= 0 && count >= 0 && offset <= data.Length - count; } [Obsolete ("Use DisconnectAsync instead", error:true)] @@ -158,8 +427,10 @@ async Task SendAsync(CommandPacket packet, CancellationToken cancellationToken = if (stream != null) { var buffer = packet.ToMemory().ToArray(); - await stream.WriteAsync(buffer, 0, buffer.Length, cancellationToken); + await stream.WriteAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait (false); } + else + throw new InvalidOperationException ("The JDWP client is not connected."); } public void Dispose() @@ -182,6 +453,30 @@ protected virtual void Dispose(bool disposing) disposed = true; } } + + sealed class ReceivedPacket + { + public ReceivedPacket (ReadOnlyMemory header, ReadOnlyMemory data) + { + Id = BinaryPrimitives.ReadInt32BigEndian (header.Slice (4, 4).Span); + Flags = header.Span [8]; + Data = data; + if (IsReply) { + ErrorCode = BinaryPrimitives.ReadInt16BigEndian (header.Slice (9, 2).Span); + } else { + CommandSet = header.Span [9]; + Command = header.Span [10]; + } + } + + public int Id { get; } + public byte Flags { get; } + public short ErrorCode { get; } + public byte CommandSet { get; } + public byte Command { get; } + public ReadOnlyMemory Data { get; } + public bool IsReply => (Flags & 0x80) == 0x80; + } } } diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/JdwpClientTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/JdwpClientTests.cs new file mode 100644 index 00000000000..973493c213a --- /dev/null +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/JdwpClientTests.cs @@ -0,0 +1,353 @@ +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Xamarin.AndroidTools.Debugging; +using Xamarin.AndroidTools.Debugging.Java; + +namespace Xamarin.Android.Tools.Tests; + +[TestFixture] +public class JdwpClientTests +{ + [Test] + public void DebuggerReadinessTimeoutMatchesDebuggerLaunchTimeout () + { + Assert.AreEqual (TimeSpan.FromSeconds (30), DebuggingExtensions.WaitForDebuggerReadinessTimeout); + Assert.AreEqual (new DebuggerOptions ().Timeout, DebuggingExtensions.WaitForDebuggerReadinessTimeout); + } + + [Test] + public void WaitForDebuggerReadinessDoesNotTreatDebgAsReady () + { + var diagnostics = new List (); + using (var stream = new DdmStream ( + includeBootStagesFeature: true, + heloStage: "DEBG", + ignoreReadCancellation: true)) + using (var client = new JdwpClient (stream)) { + Assert.ThrowsAsync (() => client.WaitForDebuggerReadinessAsync ( + TimeSpan.FromMilliseconds (50), + diagnostics.Add, + CancellationToken.None + )); + Assert.IsTrue (stream.IsDisposed); + } + + AssertDiagnostic (diagnostics, 0, "Waiting for FEAT and HELO; timeout=50 ms."); + AssertDiagnostic (diagnostics, 1, "HELO stage observed: DEBG."); + AssertDiagnostic (diagnostics, 2, "FEAT boot-stage support: available."); + AssertDiagnostic (diagnostics, 3, "Timed out waiting for A_GO; timeout=50 ms."); + } + + [Test] + public void WaitForDebuggerReadinessDoesNotTreatFeatErrorAsLegacy () + { + var diagnostics = new List (); + using (var stream = new DdmStream (includeBootStagesFeature: false, featErrorCode: 99)) + using (var client = new JdwpClient (stream)) { + Assert.ThrowsAsync (() => client.WaitForDebuggerReadinessAsync ( + TimeSpan.FromSeconds (1), + diagnostics.Add, + CancellationToken.None + )); + } + + AssertDiagnostic (diagnostics, 0, "Waiting for FEAT and HELO; timeout=1000 ms."); + AssertDiagnostic (diagnostics, 1, "HELO stage observed: not reported."); + AssertDiagnostic (diagnostics, 2, "Protocol error while waiting for readiness."); + } + + [Test] + public async Task WaitForDebuggerReadinessCompletesAfterAgo () + { + var diagnostics = new List (); + using (var stream = new DdmStream (includeBootStagesFeature: true, heloStage: "DEBG", stagStage: "A_GO")) + using (var client = new JdwpClient (stream)) { + await client.WaitForDebuggerReadinessAsync (TimeSpan.FromSeconds (1), diagnostics.Add, CancellationToken.None); + } + + AssertDiagnostic (diagnostics, 0, "Waiting for FEAT and HELO; timeout=1000 ms."); + AssertDiagnostic (diagnostics, 1, "HELO stage observed: DEBG."); + AssertDiagnostic (diagnostics, 2, "FEAT boot-stage support: available."); + AssertDiagnostic (diagnostics, 3, "STAG stage observed: A_GO."); + AssertDiagnostic (diagnostics, 4, "A_GO readiness complete."); + } + + [Test] + public async Task WaitForDebuggerReadinessKeepsEarlyAgoObservation () + { + var diagnostics = new List (); + using (var stream = new DdmStream ( + includeBootStagesFeature: true, + heloStage: "DEBG", + stagStage: "A_GO", + stagBeforeHelo: true)) + using (var client = new JdwpClient (stream)) { + await client.WaitForDebuggerReadinessAsync (TimeSpan.FromSeconds (1), diagnostics.Add, CancellationToken.None); + } + + AssertDiagnostic (diagnostics, 0, "Waiting for FEAT and HELO; timeout=1000 ms."); + AssertDiagnostic (diagnostics, 1, "STAG stage observed: A_GO."); + AssertDiagnostic (diagnostics, 2, "HELO stage observed: DEBG."); + AssertDiagnostic (diagnostics, 3, "FEAT boot-stage support: available."); + AssertDiagnostic (diagnostics, 4, "A_GO readiness complete."); + } + + [Test] + public void WaitForDebuggerReadinessHonorsCancellation () + { + var diagnostics = new List (); + using (var cancellationSource = new CancellationTokenSource ()) + using (var stream = new DdmStream ( + includeBootStagesFeature: true, + heloStage: "DEBG", + onBlockedRead: cancellationSource.Cancel)) + using (var client = new JdwpClient (stream)) { + Assert.CatchAsync (() => client.WaitForDebuggerReadinessAsync ( + TimeSpan.FromSeconds (1), + diagnostics.Add, + cancellationSource.Token + )); + Assert.IsTrue (stream.IsDisposed); + } + + AssertDiagnostic (diagnostics, 0, "Waiting for FEAT and HELO; timeout=1000 ms."); + AssertDiagnostic (diagnostics, 1, "HELO stage observed: DEBG."); + AssertDiagnostic (diagnostics, 2, "FEAT boot-stage support: available."); + AssertDiagnostic (diagnostics, 3, "Canceled while waiting for A_GO."); + } + + [Test] + public async Task WaitForDebuggerReadinessUsesLegacyDelayWithoutBootStages () + { + TimeSpan? observedDelay = null; + var diagnostics = new List (); + using (var stream = new DdmStream (includeBootStagesFeature: false)) + using (var client = new JdwpClient (stream)) { + await client.WaitForDebuggerReadinessAsync ( + TimeSpan.FromSeconds (1), + (delay, _) => { + observedDelay = delay; + return Task.CompletedTask; + }, + diagnostics.Add, + CancellationToken.None + ); + } + + Assert.AreEqual (TimeSpan.FromMilliseconds (1400), observedDelay); + AssertDiagnostic (diagnostics, 0, "Waiting for FEAT and HELO; timeout=1000 ms."); + AssertDiagnostic (diagnostics, 1, "HELO stage observed: not reported."); + AssertDiagnostic (diagnostics, 2, "FEAT boot-stage support: unavailable; using 1400 ms legacy fallback."); + AssertDiagnostic (diagnostics, 3, "Legacy fallback complete."); + } + + static void AssertDiagnostic (IReadOnlyList diagnostics, int index, string message) + { + Assert.Greater (diagnostics.Count, index); + StringAssert.StartsWith ("JDWP readiness (", diagnostics [index]); + StringAssert.EndsWith ($" ms): {message}", diagnostics [index]); + } + + sealed class DdmStream : Stream + { + const uint chunkHelo = 0x48454c4f; + const uint chunkFeat = 0x46454154; + const uint chunkStag = 0x53544147; + readonly Queue reads = new Queue (); + readonly bool includeBootStagesFeature; + readonly string heloStage; + readonly string stagStage; + readonly Action onBlockedRead; + readonly short featErrorCode; + readonly bool ignoreReadCancellation; + readonly bool stagBeforeHelo; + TaskCompletionSource blockedRead; + bool blockedReadNotified; + + public DdmStream ( + bool includeBootStagesFeature, + string heloStage = null, + string stagStage = null, + Action onBlockedRead = null, + short featErrorCode = 0, + bool ignoreReadCancellation = false, + bool stagBeforeHelo = false) + { + this.includeBootStagesFeature = includeBootStagesFeature; + this.heloStage = heloStage; + this.stagStage = stagStage; + this.onBlockedRead = onBlockedRead; + this.featErrorCode = featErrorCode; + this.ignoreReadCancellation = ignoreReadCancellation; + this.stagBeforeHelo = stagBeforeHelo; + } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => true; + public bool IsDisposed { get; private set; } + public override long Length => throw new NotSupportedException (); + public override long Position { + get => throw new NotSupportedException (); + set => throw new NotSupportedException (); + } + + public override Task ReadAsync (byte [] buffer, int offset, int count, CancellationToken cancellationToken) + { + if (reads.Count > 0) { + var bytesToRead = Math.Min (Math.Min (count, reads.Count), 3); + for (int i = 0; i < bytesToRead; i++) + buffer [offset + i] = reads.Dequeue (); + return Task.FromResult (bytesToRead); + } + + if (!blockedReadNotified) { + blockedReadNotified = true; + onBlockedRead?.Invoke (); + } + + blockedRead = new TaskCompletionSource (); + if (!ignoreReadCancellation) + cancellationToken.Register (() => blockedRead.TrySetCanceled ()); + return blockedRead.Task; + } + + public override Task WriteAsync (byte [] buffer, int offset, int count, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested (); + var packet = new ReadOnlyMemory (buffer, offset, count); + var id = BinaryPrimitives.ReadInt32BigEndian (packet.Slice (4, 4).Span); + var chunkType = BinaryPrimitives.ReadUInt32BigEndian (packet.Slice (11, 4).Span); + + if (chunkType == chunkHelo) { + if (stagBeforeHelo && stagStage != null) + Enqueue (CreateCommand (chunkStag, Encoding.ASCII.GetBytes (stagStage))); + Enqueue (CreateReply (id, chunkHelo, CreateHeloData (heloStage))); + } else if (chunkType == chunkFeat) { + Enqueue (CreateReply (id, chunkFeat, CreateFeatureData (includeBootStagesFeature), featErrorCode)); + if (!stagBeforeHelo && stagStage != null) + Enqueue (CreateCommand (chunkStag, Encoding.ASCII.GetBytes (stagStage))); + } else { + throw new InvalidDataException ($"Unexpected DDM chunk type 0x{chunkType:x8}."); + } + + return Task.CompletedTask; + } + + void Enqueue (byte [] data) + { + foreach (var value in data) + reads.Enqueue (value); + } + + static byte [] CreateReply (int id, uint chunkType, byte [] chunkData, short errorCode = 0) + { + var ddmData = errorCode == 0 ? CreateChunk (chunkType, chunkData) : []; + var packet = new byte [11 + ddmData.Length]; + BinaryPrimitives.WriteInt32BigEndian (packet.AsSpan (0, 4), packet.Length); + BinaryPrimitives.WriteInt32BigEndian (packet.AsSpan (4, 4), id); + packet [8] = 0x80; + BinaryPrimitives.WriteInt16BigEndian (packet.AsSpan (9, 2), errorCode); + ddmData.CopyTo (packet, 11); + return packet; + } + + static byte [] CreateCommand (uint chunkType, byte [] chunkData) + { + var ddmData = CreateChunk (chunkType, chunkData); + var packet = new byte [11 + ddmData.Length]; + BinaryPrimitives.WriteInt32BigEndian (packet.AsSpan (0, 4), packet.Length); + BinaryPrimitives.WriteInt32BigEndian (packet.AsSpan (4, 4), 900); + packet [9] = 0xc7; + packet [10] = 0x01; + ddmData.CopyTo (packet, 11); + return packet; + } + + static byte [] CreateChunk (uint chunkType, byte [] chunkData) + { + var data = new byte [8 + chunkData.Length]; + BinaryPrimitives.WriteUInt32BigEndian (data.AsSpan (0, 4), chunkType); + BinaryPrimitives.WriteInt32BigEndian (data.AsSpan (4, 4), chunkData.Length); + chunkData.CopyTo (data, 8); + return data; + } + + static byte [] CreateHeloData (string stage) + { + var data = new byte [stage == null ? 33 : 37]; + var offset = 0; + WriteInt32 (data, ref offset, 1); + WriteInt32 (data, ref offset, 1234); + WriteInt32 (data, ref offset, 0); + WriteInt32 (data, ref offset, 0); + WriteInt32 (data, ref offset, 0); + WriteInt32 (data, ref offset, 0); + WriteInt32 (data, ref offset, 0); + data [offset++] = 0; + WriteInt32 (data, ref offset, 0); + if (stage != null) + Encoding.ASCII.GetBytes (stage, 0, stage.Length, data, offset); + return data; + } + + static byte [] CreateFeatureData (bool includeBootStagesFeature) + { + if (!includeBootStagesFeature) + return new byte [4]; + + const string feature = "support_boot_stages"; + var featureBytes = Encoding.BigEndianUnicode.GetBytes (feature); + var data = new byte [8 + featureBytes.Length]; + BinaryPrimitives.WriteInt32BigEndian (data.AsSpan (0, 4), 1); + BinaryPrimitives.WriteInt32BigEndian (data.AsSpan (4, 4), feature.Length); + featureBytes.CopyTo (data, 8); + return data; + } + + static void WriteInt32 (byte [] data, ref int offset, int value) + { + BinaryPrimitives.WriteInt32BigEndian (data.AsSpan (offset, 4), value); + offset += 4; + } + + public override void Flush () + { + } + + public override int Read (byte [] buffer, int offset, int count) + { + throw new NotSupportedException (); + } + + public override long Seek (long offset, SeekOrigin origin) + { + throw new NotSupportedException (); + } + + public override void SetLength (long value) + { + throw new NotSupportedException (); + } + + public override void Write (byte [] buffer, int offset, int count) + { + throw new NotSupportedException (); + } + + protected override void Dispose (bool disposing) + { + if (disposing) { + IsDisposed = true; + blockedRead?.TrySetException (new IOException ("The stream was closed.")); + } + base.Dispose (disposing); + } + } +}