@@ -36,6 +36,17 @@ public static int Port
3636
3737 static readonly TimeSpan timeout = TimeSpan . FromSeconds ( 3 ) ;
3838
39+ /// <summary>
40+ /// The deadline for the async exchange. Longer than the synchronous one because the owner
41+ /// answers on its listener thread, so a connection can sit behind an accept that is itself
42+ /// waiting up to ten seconds on <see cref="InlineApplier"/>'s cross process mutex. Shorter
43+ /// than forever because there was no bound at all: SendTimeout and ReceiveTimeout apply only
44+ /// to synchronous calls, and the token every async call was given is the caller's, which is
45+ /// default from DiffRunner.AddInlineAsync - Verify passes none. An owner that accepted the
46+ /// connection and then stopped answering hung the failing test for good.
47+ /// </summary>
48+ static readonly TimeSpan asyncTimeout = TimeSpan . FromSeconds ( 30 ) ;
49+
3950 /// <summary>
4051 /// For callers on a clock or an interactive path, such as the tray's scan timer and its menu.
4152 /// The exchange is loopback to a local process, so anything slower than this is a wedged owner
@@ -95,40 +106,71 @@ public static bool TrySend(
95106 /// Fully async, including the read. A blocking read here would tie up a thread pool thread for
96107 /// the whole exchange, and a parallel test run calling this once per failing snapshot would
97108 /// starve the pool on a small machine.
109+ /// <para>
110+ /// <paramref name="port"/> and <paramref name="wait"/> override <see cref="Port"/> and
111+ /// <see cref="asyncTimeout"/> for a single call, as they do on the synchronous overload. Tests
112+ /// pass their own ephemeral port rather than mutating anything static, so they can run in
113+ /// parallel.
114+ /// </para>
98115 /// </summary>
99- public static async Task < bool > TrySendAsync ( ViewerMessage message , Cancel cancel )
116+ public static async Task < bool > TrySendAsync (
117+ ViewerMessage message ,
118+ Cancel cancel ,
119+ int ? port = null ,
120+ TimeSpan ? wait = null )
100121 {
122+ var endpointPort = port ?? Port ;
123+ var timeToWait = wait ?? asyncTimeout ;
124+ using var deadline = CancelSource . CreateLinkedTokenSource ( cancel ) ;
125+ deadline . CancelAfter ( timeToWait ) ;
126+ var token = deadline . Token ;
101127 try
102128 {
103129 using var client = new TcpClient ( ) ;
130+ // Closing the socket is the only thing that unblocks every framework: the pre-net7
131+ // ReadToEndAsync takes no token at all, and net462 has no cancellable connect or
132+ // write either. Registered after the client and so disposed before it, which is what
133+ // stops the callback firing on a disposed object
134+ using var abort = token . Register ( ( ) => Abort ( client ) ) ;
104135#if NET6_0_OR_GREATER
105- await client . ConnectAsync ( IPAddress . Loopback , Port , cancel ) ;
136+ await client . ConnectAsync ( IPAddress . Loopback , endpointPort , token ) ;
106137#else
107- cancel . ThrowIfCancellationRequested ( ) ;
108- using ( cancel . Register ( client . Close ) )
109- {
110- await client . ConnectAsync ( IPAddress . Loopback , Port ) ;
111- }
138+ token . ThrowIfCancellationRequested ( ) ;
139+ await client . ConnectAsync ( IPAddress . Loopback , endpointPort ) ;
112140#endif
113- Configure ( client , timeout ) ;
141+ Configure ( client , timeToWait ) ;
114142 var stream = client . GetStream ( ) ;
115143 var bytes = Encoding . UTF8 . GetBytes ( message . Build ( ) ) ;
116144#if NET6_0_OR_GREATER
117- await stream . WriteAsync ( bytes , cancel ) ;
145+ await stream . WriteAsync ( bytes , token ) ;
118146#else
119- await stream . WriteAsync ( bytes , 0 , bytes . Length , cancel ) ;
147+ await stream . WriteAsync ( bytes , 0 , bytes . Length , token ) ;
120148#endif
121- await stream . FlushAsync ( cancel ) ;
149+ await stream . FlushAsync ( token ) ;
122150 HalfClose ( client ) ;
123151 using var reader = new StreamReader ( stream , Encoding . UTF8 ) ;
124152#if NET7_0_OR_GREATER
125- var text = await reader . ReadToEndAsync ( cancel ) ;
153+ var text = await reader . ReadToEndAsync ( token ) ;
126154#else
127155 var text = await reader . ReadToEndAsync ( ) ;
128156#endif
129157 return ViewerResponse . TryParse ( text , out var response ) &&
130158 response . Ok ;
131159 }
160+ // The deadline, rather than the caller cancelling. Whatever the abort surfaced as - a
161+ // cancellation, a closed socket, a torn down stream - the owner is present but not
162+ // answering. Reported as absence because that is the recoverable answer: the caller
163+ // launches a viewer or stages the patch, rather than waiting on a process that has
164+ // stopped listening. Logged so the two are still tellable apart afterwards
165+ catch ( Exception exception )
166+ when ( ! cancel . IsCancellationRequested && token . IsCancellationRequested )
167+ {
168+ // Trace rather than Logging, because this file is linked into the viewer too
169+ Trace . WriteLine (
170+ $ "Timed out after { timeToWait } waiting for the inline queue owner on port { endpointPort } . " +
171+ $ "Verb: { message . Verb } . The owner is present but unresponsive. { exception . GetType ( ) . Name } ") ;
172+ return false ;
173+ }
132174 // Cancellation is the caller's business; a missing owner is not.
133175 catch ( Exception exception )
134176 when ( exception is not OperationCanceledException && Ignorable ( exception ) )
@@ -137,6 +179,21 @@ public static async Task<bool> TrySendAsync(ViewerMessage message, Cancel cancel
137179 }
138180 }
139181
182+ /// <summary>
183+ /// Unblocks whatever the exchange is waiting on. Swallowing here rather than letting it out:
184+ /// this runs on the timer that fired the deadline, where a throw has nowhere to go.
185+ /// </summary>
186+ static void Abort ( TcpClient client )
187+ {
188+ try
189+ {
190+ client . Close ( ) ;
191+ }
192+ catch ( Exception exception )
193+ when ( Ignorable ( exception ) )
194+ {
195+ }
196+ }
140197 static void Configure ( TcpClient client , TimeSpan wait )
141198 {
142199 client . SendTimeout = ( int ) wait . TotalMilliseconds ;
0 commit comments