File: Remote\SolutionChecksumUpdater.cs
Web Access
Project: src\src\EditorFeatures\Core\Microsoft.CodeAnalysis.EditorFeatures.csproj (Microsoft.CodeAnalysis.EditorFeatures)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
 
using System;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.Collections;
using Microsoft.CodeAnalysis.Internal.Log;
using Microsoft.CodeAnalysis.Notification;
using Microsoft.CodeAnalysis.PooledObjects;
using Microsoft.CodeAnalysis.Shared.TestHooks;
using Microsoft.CodeAnalysis.Text;
using Roslyn.Utilities;
 
namespace Microsoft.CodeAnalysis.Remote;
 
/// <summary>
/// This class runs against the in-process workspace, and when it sees changes proactively pushes them to
/// the out-of-process workspace through the <see cref="IRemoteAssetSynchronizationService"/>.
/// </summary>
internal sealed class SolutionChecksumUpdater
{
    private readonly Workspace _workspace;
 
    /// <summary>
    /// We're not at a layer where we are guaranteed to have an IGlobalOperationNotificationService.  So allow for
    /// it being null.
    /// </summary>
    private readonly IGlobalOperationNotificationService? _globalOperationService;
 
    private readonly IDocumentTrackingService _documentTrackingService;
 
    /// <summary>
    /// Queue to push out text changes in a batched fashion when we hear about them.  Because these should be short
    /// operations (only syncing text changes) we don't cancel this when we enter the paused state.  We simply don't
    /// start queuing more requests into this until we become unpaused.
    /// </summary>
    private readonly AsyncBatchingWorkQueue<(Document oldDocument, Document newDocument)> _textChangeQueue;
 
    /// <summary>
    /// Queue for kicking off the work to synchronize the primary workspace's solution.
    /// </summary>
    private readonly AsyncBatchingWorkQueue _synchronizeWorkspaceQueue;
 
    /// <summary>
    /// Queue for kicking off the work to synchronize the active document to the remote process.
    /// </summary>
    private readonly AsyncBatchingWorkQueue _synchronizeActiveDocumentQueue;
 
    private readonly object _gate = new();
    private bool _isSynchronizeWorkspacePaused;
 
    public SolutionChecksumUpdater(
        Workspace workspace,
        IAsynchronousOperationListenerProvider listenerProvider,
        CancellationToken shutdownToken)
    {
        var listener = listenerProvider.GetListener(FeatureAttribute.SolutionChecksumUpdater);
 
        _globalOperationService = workspace.Services.SolutionServices.ExportProvider.GetExports<IGlobalOperationNotificationService>().FirstOrDefault()?.Value;
 
        _workspace = workspace;
        _documentTrackingService = workspace.Services.GetRequiredService<IDocumentTrackingService>();
 
        _synchronizeWorkspaceQueue = new AsyncBatchingWorkQueue(
            DelayTimeSpan.NearImmediate,
            SynchronizePrimaryWorkspaceAsync,
            listener,
            shutdownToken);
 
        // Text changes and active doc info are tiny messages.  So attempt to send them immediately.  Just batching
        // things up if we get a flurry of notifications.
        _textChangeQueue = new AsyncBatchingWorkQueue<(Document oldDocument, Document newDocument)>(
            TimeSpan.Zero,
            SynchronizeTextChangesAsync,
            listener,
            shutdownToken);
 
        _synchronizeActiveDocumentQueue = new AsyncBatchingWorkQueue(
            TimeSpan.Zero,
            SynchronizeActiveDocumentAsync,
            listener,
            shutdownToken);
 
        // start listening workspace change event
        _workspace.WorkspaceChanged += OnWorkspaceChanged;
        _documentTrackingService.ActiveDocumentChanged += OnActiveDocumentChanged;
 
        if (_globalOperationService != null)
        {
            _globalOperationService.Started += OnGlobalOperationStarted;
            _globalOperationService.Stopped += OnGlobalOperationStopped;
        }
 
        // Enqueue the work to sync the initial data over.
        _synchronizeActiveDocumentQueue.AddWork();
        _synchronizeWorkspaceQueue.AddWork();
    }
 
    public void Shutdown()
    {
        // Try to stop any work that is in progress.
        PauseSynchronizingPrimaryWorkspace();
 
        _documentTrackingService.ActiveDocumentChanged -= OnActiveDocumentChanged;
        _workspace.WorkspaceChanged -= OnWorkspaceChanged;
 
        if (_globalOperationService != null)
        {
            _globalOperationService.Started -= OnGlobalOperationStarted;
            _globalOperationService.Stopped -= OnGlobalOperationStopped;
        }
    }
 
    private void OnGlobalOperationStarted(object? sender, EventArgs e)
        => PauseSynchronizingPrimaryWorkspace();
 
    private void OnGlobalOperationStopped(object? sender, EventArgs e)
        => ResumeSynchronizingPrimaryWorkspace();
 
    private void PauseSynchronizingPrimaryWorkspace()
    {
        // An expensive global operation started (like a build).  Pause ourselves and cancel any outstanding work in
        // progress to synchronize the solution.
        lock (_gate)
        {
            _synchronizeWorkspaceQueue.CancelExistingWork();
            _isSynchronizeWorkspacePaused = true;
        }
    }
 
    private void ResumeSynchronizingPrimaryWorkspace()
    {
        lock (_gate)
        {
            _isSynchronizeWorkspacePaused = false;
            _synchronizeWorkspaceQueue.AddWork();
        }
    }
 
    private void OnWorkspaceChanged(object? sender, WorkspaceChangeEventArgs e)
    {
        if (e.Kind == WorkspaceChangeKind.DocumentChanged)
        {
            var oldDocument = e.OldSolution.GetDocument(e.DocumentId);
            var newDocument = e.NewSolution.GetDocument(e.DocumentId);
            if (oldDocument != null && newDocument != null)
                _textChangeQueue.AddWork((oldDocument, newDocument));
        }
 
        // Check if we're currently paused.  If so ignore this notification.  We don't want to any work in response
        // to whatever the workspace is doing.
        lock (_gate)
        {
            if (!_isSynchronizeWorkspacePaused)
                _synchronizeWorkspaceQueue.AddWork();
        }
    }
 
    private void OnActiveDocumentChanged(object? sender, DocumentId? e)
        => _synchronizeActiveDocumentQueue.AddWork();
 
    private async ValueTask SynchronizePrimaryWorkspaceAsync(CancellationToken cancellationToken)
    {
        var solution = _workspace.CurrentSolution;
 
        // Wait for the remote side to actually become available (without being the cause of its creation ourselves). We
        // want to wait for some feature to kick this off, then we'll start syncing this data once that has happened.
        await RemoteHostClient.WaitForClientCreationAsync(_workspace, cancellationToken).ConfigureAwait(false);
 
        var client = await RemoteHostClient.TryGetClientAsync(_workspace, cancellationToken).ConfigureAwait(false);
        if (client == null)
            return;
 
        using (Logger.LogBlock(FunctionId.SolutionChecksumUpdater_SynchronizePrimaryWorkspace, cancellationToken))
        {
            await client.TryInvokeAsync<IRemoteAssetSynchronizationService>(
                solution,
                (service, solution, cancellationToken) => service.SynchronizePrimaryWorkspaceAsync(solution, cancellationToken),
                cancellationToken).ConfigureAwait(false);
        }
    }
 
    private async ValueTask SynchronizeActiveDocumentAsync(CancellationToken cancellationToken)
    {
        var activeDocument = _documentTrackingService.TryGetActiveDocument();
 
        // Wait for the remote side to actually become available (without being the cause of its creation ourselves). We
        // want to wait for some feature to kick this off, then we'll start syncing this data once that has happened.
        await RemoteHostClient.WaitForClientCreationAsync(_workspace, cancellationToken).ConfigureAwait(false);
 
        var client = await RemoteHostClient.TryGetClientAsync(_workspace, cancellationToken).ConfigureAwait(false);
        if (client == null)
            return;
 
        var solution = _workspace.CurrentSolution;
        await client.TryInvokeAsync<IRemoteAssetSynchronizationService>(
            (service, cancellationToken) => service.SynchronizeActiveDocumentAsync(activeDocument, cancellationToken),
            cancellationToken).ConfigureAwait(false);
    }
 
    private async ValueTask SynchronizeTextChangesAsync(
        ImmutableSegmentedList<(Document oldDocument, Document newDocument)> values,
        CancellationToken cancellationToken)
    {
        var client = await RemoteHostClient.TryGetClientAsync(_workspace, cancellationToken).ConfigureAwait(false);
        if (client == null)
            return;
 
        // this pushes text changes to the remote side if it can. this is purely perf optimization. whether this
        // pushing text change worked or not doesn't affect feature's functionality.
        //
        // this basically see whether it can cheaply find out text changes between 2 snapshots, if it can, it will
        // send out that text changes to remote side.
        //
        // the remote side, once got the text change, will again see whether it can use that text change information
        // without any high cost and create new snapshot from it.
        //
        // otherwise, it will do the normal behavior of getting full text from VS side. this optimization saves
        // times we need to do full text synchronization for typing scenario.
        using var _ = ArrayBuilder<(DocumentId id, Checksum textChecksum, ImmutableArray<TextChange> changes, Checksum newTextChecksum)>.GetInstance(out var builder);
 
        foreach (var (oldDocument, newDocument) in values)
        {
            if (!oldDocument.TryGetText(out var oldText) ||
                !newDocument.TryGetText(out var newText))
            {
                // we only support case where text already exist
                continue;
            }
 
            // Avoid allocating text before seeing if we can bail out.
            var changeRanges = newText.GetChangeRanges(oldText).AsImmutable();
            if (changeRanges.Length == 0)
                continue;
 
            // no benefit here. pulling from remote host is more efficient
            if (changeRanges is [{ Span.Length: var singleChangeLength }] && singleChangeLength == oldText.Length)
                continue;
 
            var state = await oldDocument.State.GetStateChecksumsAsync(cancellationToken).ConfigureAwait(false);
            var newState = await newDocument.State.GetStateChecksumsAsync(cancellationToken).ConfigureAwait(false);
 
            var textChanges = newText.GetTextChanges(oldText).AsImmutable();
            builder.Add((oldDocument.Id, state.Text, textChanges, newState.Text));
        }
 
        if (builder.Count == 0)
            return;
 
        await client.TryInvokeAsync<IRemoteAssetSynchronizationService>(
            (service, cancellationToken) => service.SynchronizeTextChangesAsync(builder.ToImmutableAndClear(), cancellationToken),
            cancellationToken).ConfigureAwait(false);
    }
}