55using System ;
66using System . Threading ;
77using System . Threading . Tasks ;
8+ using Microsoft . CodeAnalysis . ErrorReporting ;
89using Microsoft . CodeAnalysis . Shared . TestHooks ;
10+ using Microsoft . CodeAnalysis . Threading ;
911
1012namespace Microsoft . CodeAnalysis . Remote ;
1113
1214internal interface IRemoteKeepAliveService
1315{
1416 /// <summary>
15- /// Keeps alive this solution in the OOP process until the cancellation token is triggered. Used so that long
16- /// running features (like 'inline rename' or 'lightbulbs') we can call into oop several times, with the same
17- /// snapshot, knowing that things will stay hydrated and alive on the OOP side. Importantly, by keeping the
18- /// same <see cref="Solution"/> snapshot alive on the OOP side, computed attached values (like <see
19- /// cref="Compilation"/>s) will stay alive as well.
17+ /// Keeps the solution identified by <paramref name="solutionChecksum"/> alive in the OOP process until <paramref
18+ /// name="cancellationToken"/> is triggered. This enables long-running features (like inline rename or lightbulbs)
19+ /// to make multiple OOP calls against the same snapshot, ensuring that computed values (like <see
20+ /// cref="Compilation"/>s) remain cached rather than being rebuilt on each call.
2021 /// </summary>
21- ValueTask KeepAliveAsync ( Checksum solutionChecksum , CancellationToken cancellationToken ) ;
22+ /// <param name="solutionChecksum">Checksum identifying the solution to pin.</param>
23+ /// <param name="sessionId">Unique identifier for this session. The host uses this with <see
24+ /// cref="WaitForSessionIdAsync"/> to block until the solution is actually pinned before proceeding with
25+ /// dependent work.</param>
26+ /// <param name="cancellationToken">Cancellation of this token releases the pinned solution.</param>
27+ ValueTask KeepAliveAsync ( Checksum solutionChecksum , long sessionId , CancellationToken cancellationToken ) ;
28+
29+ /// <summary>
30+ /// Blocks until the session identified by <paramref name="sessionId"/> has fully synced and pinned its solution
31+ /// in the OOP process. This ensures the host doesn't proceed with OOP calls until the solution is guaranteed to
32+ /// be available.
33+ /// </summary>
34+ ValueTask WaitForSessionIdAsync ( long sessionId , CancellationToken cancellationToken ) ;
2235}
2336
2437internal sealed class RemoteKeepAliveSession : IDisposable
2538{
26- private readonly CancellationTokenSource _cancellationTokenSource = new ( ) ;
39+ private static long s_sessionId = 1 ;
40+
41+ /// <summary>
42+ /// Unique identifier for this session. Used to coordinate between <see cref="IRemoteKeepAliveService.KeepAliveAsync"/>
43+ /// (which syncs and pins the solution) and <see cref="IRemoteKeepAliveService.WaitForSessionIdAsync"/> (which blocks
44+ /// until pinning completes).
45+ /// </summary>
46+ private long SessionId { get ; } = Interlocked . Increment ( ref s_sessionId ) ;
2747
28- private RemoteKeepAliveSession (
48+ /// <summary>
49+ /// Controls the lifetime of the OOP-side pinning. The <see cref="IRemoteKeepAliveService.KeepAliveAsync"/> call
50+ /// blocks on this token; canceling it allows that call to return, releasing the pinned solution.
51+ /// </summary>
52+ private CancellationTokenSource KeepAliveTokenSource { get ; } = new ( ) ;
53+
54+ private RemoteKeepAliveSession ( )
55+ {
56+ }
57+
58+ /// <summary>
59+ /// Creates and fully establishes a keep-alive session. Returns only after the solution is confirmed to be
60+ /// pinned on the OOP side.
61+ /// </summary>
62+ /// <remarks>
63+ /// <para>This method coordinates two concurrent OOP calls:</para>
64+ /// <list type="number">
65+ /// <item><see cref="IRemoteKeepAliveService.KeepAliveAsync"/>: Syncs the solution to OOP, then blocks until
66+ /// <see cref="KeepAliveTokenSource"/> is canceled. This call is fire-and-forget from the host's perspective.</item>
67+ /// <item><see cref="IRemoteKeepAliveService.WaitForSessionIdAsync"/>: Blocks until KeepAliveAsync has completed
68+ /// syncing. This call is awaited, ensuring the solution is pinned before returning to the caller.</item>
69+ /// </list>
70+ /// <para>The two calls share <see cref="SessionId"/> so the OOP side can correlate them.</para>
71+ /// </remarks>
72+ private static async Task < RemoteKeepAliveSession > StartSessionAsync (
2973 SolutionCompilationState compilationState ,
30- RemoteHostClient ? client )
74+ ProjectId ? projectId ,
75+ RemoteHostClient ? client ,
76+ CancellationToken callerCancellationToken )
3177 {
78+ var session = new RemoteKeepAliveSession ( ) ;
79+
80+ // When running in-process (no OOP client), return immediately. The caller holds the solution snapshot
81+ // directly, so no pinning is needed.
3282 if ( client is null )
33- return ;
83+ return session ;
84+
85+ // Fire-and-forget: Start syncing and pinning the solution on the OOP side. This call will block on the OOP
86+ // side until KeepAliveTokenSource is canceled (i.e., when this session is Disposed).
87+ //
88+ // Important: We pass KeepAliveTokenSource.Token (not callerCancellationToken) because:
89+ // - The keep-alive must persist beyond this method, for the lifetime of the session
90+ // - Disposing the session is what should cancel this work, not the caller's token
91+ _ = InvokeKeepAliveAsync ( compilationState , projectId , client , session ) ;
92+
93+ // Block until the OOP side confirms the solution is pinned. This uses callerCancellationToken so the caller
94+ // can abandon the wait if they no longer need the session.
95+ await WaitForSessionIdAsync ( compilationState , projectId , client , session , callerCancellationToken ) . ConfigureAwait ( false ) ;
96+
97+ return session ;
3498
35- // Now kick off the keep-alive work. We don't wait on this as this will stick on the OOP side until
36- // the cancellation token triggers.
37- _ = client . TryInvokeAsync < IRemoteKeepAliveService > (
38- compilationState ,
39- ( service , solutionInfo , cancellationToken ) => service . KeepAliveAsync ( solutionInfo , cancellationToken ) ,
40- _cancellationTokenSource . Token ) . AsTask ( ) ;
99+ static async Task InvokeKeepAliveAsync (
100+ SolutionCompilationState compilationState ,
101+ ProjectId ? projectId ,
102+ RemoteHostClient client ,
103+ RemoteKeepAliveSession session )
104+ {
105+ try
106+ {
107+ // Yield to allow StartSessionAsync to proceed to WaitForSessionIdAsync concurrently.
108+ await Task . Yield ( ) . ConfigureAwait ( false ) ;
109+
110+ var sessionId = session . SessionId ;
111+ await client . TryInvokeAsync < IRemoteKeepAliveService > (
112+ compilationState ,
113+ projectId ,
114+ ( service , solutionInfo , cancellationToken ) => service . KeepAliveAsync ( solutionInfo , sessionId , cancellationToken ) ,
115+ session . KeepAliveTokenSource . Token ) . ConfigureAwait ( false ) ;
116+ }
117+ catch ( Exception ex ) when ( FatalError . ReportAndCatchUnlessCanceled ( ex ) )
118+ {
119+ // Non-cancellation exceptions indicate a catastrophic failure (e.g., broken OOP connection).
120+ // We must dispose the session to:
121+ // 1. Cancel KeepAliveTokenSource, which is linked into WaitForSessionIdAsync's token
122+ // 2. Unblock WaitForSessionIdAsync so it doesn't hang forever
123+ //
124+ // Cancellation exceptions are expected and normal - they occur when the session is properly
125+ // disposed, which cancels KeepAliveTokenSource and allows KeepAliveAsync to return.
126+ session . Dispose ( ) ;
127+
128+ // Don't rethrow: this is fire-and-forget. Errors were already reported via FatalError above.
129+ }
130+ }
131+
132+ static async Task WaitForSessionIdAsync (
133+ SolutionCompilationState compilationState ,
134+ ProjectId ? projectId ,
135+ RemoteHostClient client ,
136+ RemoteKeepAliveSession session ,
137+ CancellationToken callerCancellationToken )
138+ {
139+ try
140+ {
141+ // Link both cancellation sources so this call aborts if either:
142+ // - The caller cancels (they no longer need the session)
143+ // - InvokeKeepAliveAsync fails and disposes the session (which cancels KeepAliveTokenSource)
144+ //
145+ // Without the link to KeepAliveTokenSource, a failure in InvokeKeepAliveAsync would leave this
146+ // call hanging indefinitely since the OOP side would never signal completion.
147+ using var linkedTokenSource = CancellationTokenSource . CreateLinkedTokenSource (
148+ session . KeepAliveTokenSource . Token ,
149+ callerCancellationToken ) ;
150+
151+ await client . TryInvokeAsync < IRemoteKeepAliveService > (
152+ compilationState ,
153+ projectId ,
154+ ( service , _ , cancellationToken ) => service . WaitForSessionIdAsync ( session . SessionId , cancellationToken ) ,
155+ linkedTokenSource . Token ) . ConfigureAwait ( false ) ;
156+ }
157+ catch
158+ {
159+ // Any failure means we can't establish the session. The three lines below handle all cases:
160+ //
161+ // 1. Dispose(): Always clean up. The caller won't receive the session, so we must release it.
162+ // This also cancels KeepAliveTokenSource, which unblocks InvokeKeepAliveAsync if it's still running.
163+ //
164+ // 2. ThrowIfCancellationRequested(): If the caller's token caused the cancellation, rethrow with
165+ // that token to maintain proper cancellation semantics (the exception's CancellationToken
166+ // property should match what the caller passed in). This is a no-op if the caller didn't cancel.
167+ //
168+ // 3. throw: For all other failures (e.g., KeepAliveTokenSource canceled due to InvokeKeepAliveAsync
169+ // failing, or a non-cancellation exception), propagate the original exception.
170+ session . Dispose ( ) ;
171+ callerCancellationToken . ThrowIfCancellationRequested ( ) ;
172+ throw ;
173+ }
174+ }
41175 }
42176
177+ /// <summary>
178+ /// Constructor for synchronous, best-effort session creation. Does not wait for the session to be established.
179+ /// </summary>
43180 private RemoteKeepAliveSession ( SolutionCompilationState compilationState , IAsynchronousOperationListener listener )
44181 {
45- var cancellationToken = _cancellationTokenSource . Token ;
182+ // Unlike the async entry-point, this constructor returns immediately without waiting for the solution to
183+ // be pinned on the OOP side. This is acceptable for scenarios where:
184+ // - The caller cannot await (e.g., in a constructor)
185+ // - Best-effort pinning is sufficient (subsequent OOP calls will still work, just potentially slower)
186+ //
187+ // Track the async work so test infrastructure can detect outstanding operations.
46188 var token = listener . BeginAsyncOperation ( nameof ( RemoteKeepAliveSession ) ) ;
47189
48190 var task = CreateClientAndKeepAliveAsync ( ) ;
@@ -52,16 +194,25 @@ private RemoteKeepAliveSession(SolutionCompilationState compilationState, IAsync
52194
53195 async Task CreateClientAndKeepAliveAsync ( )
54196 {
197+ var cancellationToken = this . KeepAliveTokenSource . Token ;
55198 var client = await RemoteHostClient . TryGetClientAsync ( compilationState . Services , cancellationToken ) . ConfigureAwait ( false ) ;
56199 if ( client is null )
57200 return ;
58201
59- // Now kick off the keep-alive work. We don't wait on this as this will stick on the OOP side until
60- // the cancellation token triggers.
61- _ = client . TryInvokeAsync < IRemoteKeepAliveService > (
62- compilationState ,
63- ( service , solutionInfo , cancellationToken ) => service . KeepAliveAsync ( solutionInfo , cancellationToken ) ,
64- cancellationToken ) . AsTask ( ) ;
202+ // Fire-and-forget: Start the keep-alive without waiting for confirmation. Unlike StartSessionAsync,
203+ // we don't call WaitForSessionIdAsync because this is a best-effort, non-blocking path.
204+ try
205+ {
206+ var sessionId = this . SessionId ;
207+ await client . TryInvokeAsync < IRemoteKeepAliveService > (
208+ compilationState ,
209+ projectId : null ,
210+ ( service , solutionInfo , cancellationToken ) => service . KeepAliveAsync ( solutionInfo , sessionId , cancellationToken ) ,
211+ cancellationToken ) . ConfigureAwait ( false ) ;
212+ }
213+ catch ( Exception ex ) when ( FatalError . ReportAndCatchUnlessCanceled ( ex ) )
214+ {
215+ }
65216 }
66217 }
67218
@@ -76,43 +227,54 @@ async Task CreateClientAndKeepAliveAsync()
76227 public void Dispose ( )
77228 {
78229 GC . SuppressFinalize ( this ) ;
79- _cancellationTokenSource . Cancel ( ) ;
80- _cancellationTokenSource . Dispose ( ) ;
230+
231+ // Cancel rather than dispose the token source. CancellationTokenSource.Dispose() is only necessary when
232+ // not canceling (to clean up internal wait handles), but we always cancel. The finalizer's Contract.Fail
233+ // will catch any cases where Dispose is forgotten.
234+ this . KeepAliveTokenSource . Cancel ( ) ;
81235 }
82236
83237 /// <summary>
84- /// Creates a session between the host and OOP, effectively pinning this <paramref name="solution"/> until <see
85- /// cref="IDisposable.Dispose"/> is called on it. By pinning the solution we ensure that all calls to OOP for
86- /// the same solution during the life of this session do not need to resync the solution. Nor do they then need
87- /// to rebuild any compilations they've already built due to the solution going away and then coming back.
238+ /// Creates a best-effort keep-alive session synchronously. Returns immediately without waiting for the session
239+ /// to be established on the OOP side.
88240 /// </summary>
89241 /// <remarks>
90- /// The <paramref name="listener"/> is not strictly necessary for this type. This class functions just as an
91- /// optimization to hold onto data so it isn't resync'ed or recomputed. However, we still want to let the
92- /// system know when unobserved async work is kicked off in case we have any tooling that keep track of this for
93- /// any reason (for example for tracking down problems in testing scenarios).
94- /// </remarks>
95- /// <remarks>
96- /// This synchronous entrypoint should be used only in contexts where using the async <see
97- /// cref="CreateAsync(Solution, CancellationToken)"/> is not possible (for example, in a constructor).
242+ /// <para>Use this overload only when async code is not possible (e.g., in constructors). For guaranteed session
243+ /// establishment, use <see cref="CreateAsync(Solution, CancellationToken)"/> instead.</para>
244+ /// <para>Because this method doesn't wait for establishment, subsequent OOP calls may not benefit from the
245+ /// pinned solution if they race ahead of the keep-alive setup. In practice this is rare, but callers requiring
246+ /// guaranteed consistency must use the async overloads.</para>
247+ /// <para>The <paramref name="listener"/> is used to track the async keep-alive work for testing infrastructure.</para>
98248 /// </remarks>
99249 public static RemoteKeepAliveSession Create ( Solution solution , IAsynchronousOperationListener listener )
100250 => new ( solution . CompilationState , listener ) ;
101251
102252 /// <summary>
103- /// Creates a session between the host and OOP, effectively pinning this <paramref name="solution"/> until <see
104- /// cref="IDisposable.Dispose"/> is called on it. By pinning the solution we ensure that all calls to OOP for
105- /// the same solution during the life of this session do not need to resync the solution. Nor do they then need
106- /// to rebuild any compilations they've already built due to the solution going away and then coming back.
253+ /// Creates a keep-alive session, returning only after the session is fully established on the OOP side.
107254 /// </summary>
255+ /// <remarks>
256+ /// All subsequent OOP calls made while this session is alive will see the same pinned solution instance,
257+ /// provided they pass matching solution/project-cone data. Mismatched calls (e.g., session created for full
258+ /// solution but call made for project-cone) will not benefit from the pinning.
259+ /// </remarks>
108260 public static Task < RemoteKeepAliveSession > CreateAsync ( Solution solution , CancellationToken cancellationToken )
109- => CreateAsync ( solution . CompilationState , cancellationToken ) ;
261+ => CreateAsync ( solution , projectId : null , cancellationToken ) ;
262+
263+ /// <inheritdoc cref="CreateAsync(Solution, CancellationToken)"/>
264+ public static Task < RemoteKeepAliveSession > CreateAsync ( Solution solution , ProjectId ? projectId , CancellationToken cancellationToken )
265+ => CreateAsync ( solution . CompilationState , projectId , cancellationToken ) ;
266+
267+ /// <inheritdoc cref="CreateAsync(Solution, CancellationToken)"/>
268+ public static Task < RemoteKeepAliveSession > CreateAsync ( SolutionCompilationState compilationState , CancellationToken cancellationToken )
269+ => CreateAsync ( compilationState , projectId : null , cancellationToken ) ;
110270
111271 /// <inheritdoc cref="CreateAsync(Solution, CancellationToken)"/>
112272 public static async Task < RemoteKeepAliveSession > CreateAsync (
113- SolutionCompilationState compilationState , CancellationToken cancellationToken )
273+ SolutionCompilationState compilationState , ProjectId ? projectId , CancellationToken cancellationToken )
114274 {
115- var client = await RemoteHostClient . TryGetClientAsync ( compilationState . Services , cancellationToken ) . ConfigureAwait ( false ) ;
116- return new RemoteKeepAliveSession ( compilationState , client ) ;
275+ var client = await RemoteHostClient . TryGetClientAsync (
276+ compilationState . Services , cancellationToken ) . ConfigureAwait ( false ) ;
277+
278+ return await StartSessionAsync ( compilationState , projectId , client , cancellationToken ) . ConfigureAwait ( false ) ;
117279 }
118280}
0 commit comments