Skip to content

Commit 1d8215d

Browse files
Ensure calls to OOP for source-generator purposes see the exact same Solution-snapshot-instances across all calls they make (#81529)
Co-authored-by: David Barbet <[email protected]>
2 parents 3c291cc + a649e5b commit 1d8215d

File tree

4 files changed

+289
-58
lines changed

4 files changed

+289
-58
lines changed

src/Workspaces/Core/Portable/Remote/IRemoteKeepAliveService.cs

Lines changed: 207 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -5,44 +5,186 @@
55
using System;
66
using System.Threading;
77
using System.Threading.Tasks;
8+
using Microsoft.CodeAnalysis.ErrorReporting;
89
using Microsoft.CodeAnalysis.Shared.TestHooks;
10+
using Microsoft.CodeAnalysis.Threading;
911

1012
namespace Microsoft.CodeAnalysis.Remote;
1113

1214
internal 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

2437
internal 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
}

src/Workspaces/Core/Portable/Remote/RemoteHostClient.cs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,17 +105,24 @@ public ValueTask<bool> TryInvokeAsync<TService>(
105105
CancellationToken cancellationToken)
106106
where TService : class
107107
{
108-
return TryInvokeAsync(solution.CompilationState, invocation, cancellationToken);
108+
return TryInvokeAsync(solution.CompilationState, projectId: null, invocation, cancellationToken);
109109
}
110110

111+
/// <param name="projectId">If <see langword="null"/> the entire solution snapshot represented by <paramref
112+
/// name="compilationState"/> will be synchronized with the OOP side. If not <see langword="null"/> only the
113+
/// project-cone represented by that id will be synchronized over.</param>
114+
/// <returns></returns>
111115
public async ValueTask<bool> TryInvokeAsync<TService>(
112116
SolutionCompilationState compilationState,
117+
ProjectId? projectId,
113118
Func<TService, Checksum, CancellationToken, ValueTask> invocation,
114119
CancellationToken cancellationToken)
115120
where TService : class
116121
{
117122
using var connection = CreateConnection<TService>(callbackTarget: null);
118-
return await connection.TryInvokeAsync(compilationState, invocation, cancellationToken).ConfigureAwait(false);
123+
return projectId is null
124+
? await connection.TryInvokeAsync(compilationState, invocation, cancellationToken).ConfigureAwait(false)
125+
: await connection.TryInvokeAsync(compilationState, projectId, invocation, cancellationToken).ConfigureAwait(false);
119126
}
120127

121128
public async ValueTask<Optional<TResult>> TryInvokeAsync<TService, TResult>(

0 commit comments

Comments
 (0)