Change notifications for the project manager

There's still nothing processing the notifications in the background.
This is all the plumbing for dirty checking and publishing updates.
This commit is contained in:
Ryan Nowak 2017-09-06 23:12:21 -07:00
parent 82866d9442
commit 7cca8618ea
9 changed files with 523 additions and 10 deletions

View File

@ -5,6 +5,14 @@ using System;
namespace Microsoft.CodeAnalysis.Razor.ProjectSystem
{
// All of the public state of this is immutable - we create a new instance and notify subscribers
// when it changes.
//
// However we use the private state to track things like dirty/clean.
//
// See the private constructors... When we update the snapshot we either are processing a Workspace
// change (Project) or updating the computed state (ProjectSnapshotUpdateContext). We don't do both
// at once.
internal class DefaultProjectSnapshot : ProjectSnapshot
{
public DefaultProjectSnapshot(Project underlyingProject)
@ -17,6 +25,81 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem
UnderlyingProject = underlyingProject;
}
private DefaultProjectSnapshot(Project underlyingProject, DefaultProjectSnapshot other)
{
if (underlyingProject == null)
{
throw new ArgumentNullException(nameof(underlyingProject));
}
if (other == null)
{
throw new ArgumentNullException(nameof(other));
}
UnderlyingProject = underlyingProject;
ComputedVersion = other.ComputedVersion;
Configuration = other.Configuration;
}
private DefaultProjectSnapshot(ProjectSnapshotUpdateContext update, DefaultProjectSnapshot other)
{
if (update == null)
{
throw new ArgumentNullException(nameof(update));
}
if (other == null)
{
throw new ArgumentNullException(nameof(other));
}
UnderlyingProject = other.UnderlyingProject;
ComputedVersion = update.UnderlyingProject.Version;
Configuration = update.Configuration;
}
public override ProjectExtensibilityConfiguration Configuration { get; }
public override Project UnderlyingProject { get; }
// This is the version that the computed state is based on.
public VersionStamp? ComputedVersion { get; set; }
// We know the project is dirty if we don't have a computed result, or it was computed for a different version.
// Since the PSM updates the snapshots synchronously, the snapshot can never be older than the computed state.
public bool IsDirty => ComputedVersion == null || ComputedVersion.Value != UnderlyingProject.Version;
public DefaultProjectSnapshot WithProjectChange(Project project)
{
if (project == null)
{
throw new ArgumentNullException(nameof(project));
}
return new DefaultProjectSnapshot(project, this);
}
public DefaultProjectSnapshot WithProjectChange(ProjectSnapshotUpdateContext update)
{
if (update == null)
{
throw new ArgumentNullException(nameof(update));
}
return new DefaultProjectSnapshot(update, this);
}
public bool HasChangesComparedTo(ProjectSnapshot original)
{
if (original == null)
{
throw new ArgumentNullException(nameof(original));
}
return !object.Equals(Configuration, original.Configuration);
}
}
}

View File

@ -10,7 +10,7 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem
internal class DefaultProjectSnapshotManager : ProjectSnapshotManagerBase
{
private readonly ProjectSnapshotChangeTrigger[] _triggers;
private readonly Dictionary<ProjectId, ProjectSnapshot> _projects;
private readonly Dictionary<ProjectId, DefaultProjectSnapshot> _projects;
private readonly List<WeakReference<DefaultProjectSnapshotListener>> _listeners;
public DefaultProjectSnapshotManager(IEnumerable<ProjectSnapshotChangeTrigger> triggers, Workspace workspace)
@ -28,7 +28,7 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem
_triggers = triggers.ToArray();
Workspace = workspace;
_projects = new Dictionary<ProjectId, ProjectSnapshot>();
_projects = new Dictionary<ProjectId, DefaultProjectSnapshot>();
_listeners = new List<WeakReference<DefaultProjectSnapshotListener>>();
for (var i = 0; i < _triggers.Length; i++)
@ -59,6 +59,9 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem
var snapshot = new DefaultProjectSnapshot(underlyingProject);
_projects[underlyingProject.Id] = snapshot;
// New projects always start dirty, need to compute state in the background.
NotifyBackgroundWorker();
// We need to notify listeners about every project add.
NotifyListeners(new ProjectChangeEventArgs(snapshot, ProjectChangeKind.Added));
}
@ -70,12 +73,49 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem
throw new ArgumentNullException(nameof(underlyingProject));
}
// For now we don't have any state associated with the project so we can just construct a new snapshot.
var snapshot = new DefaultProjectSnapshot(underlyingProject);
_projects[underlyingProject.Id] = snapshot;
if (_projects.TryGetValue(underlyingProject.Id, out var original))
{
// Doing an update to the project should keep computed values, but mark the project as dirty if the
// underlying project is newer.
var snapshot = original.WithProjectChange(underlyingProject);
_projects[underlyingProject.Id] = snapshot;
// There's no need to notify listeners about project changes because we don't have any state.
// This will change when we implement extensibility support.
if (snapshot.IsDirty)
{
// We don't need to notify listeners yet because we don't have any **new** computed state. However we do
// need to trigger the background work to asynchronously compute the effect of the updates.
NotifyBackgroundWorker();
}
}
}
public override void ProjectChanged(ProjectSnapshotUpdateContext update)
{
if (update == null)
{
throw new ArgumentNullException(nameof(update));
}
if (_projects.TryGetValue(update.UnderlyingProject.Id, out var original))
{
// This is an update to the project's computed values, so everything should be overwritten
var snapshot = original.WithProjectChange(update);
_projects[update.UnderlyingProject.Id] = snapshot;
if (snapshot.IsDirty)
{
// It's possible that the snapshot can still be dirty if we got a project update while computing state in
// the background. We need to trigger the background work to asynchronously compute the effect of the updates.
NotifyBackgroundWorker();
}
// Now we need to know if the changes that we applied are significant. If that's the case then
// we need to notify listeners.
if (snapshot.HasChangesComparedTo(original))
{
NotifyListeners(new ProjectChangeEventArgs(snapshot, ProjectChangeKind.Changed));
}
}
}
public override void ProjectRemoved(Project underlyingProject)
@ -105,7 +145,14 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem
}
}
private void NotifyListeners(ProjectChangeEventArgs e)
// virtual so it can be overridden in tests
protected virtual void NotifyBackgroundWorker()
{
}
// virtual so it can be overridden in tests
protected virtual void NotifyListeners(ProjectChangeEventArgs e)
{
for (var i = 0; i < _listeners.Count; i++)
{

View File

@ -3,6 +3,8 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.Internal;
namespace Microsoft.CodeAnalysis.Razor.ProjectSystem
{
@ -37,5 +39,27 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem
public override ProjectExtensibilityAssembly RazorAssembly { get; }
public ProjectExtensibilityAssembly MvcAssembly { get; }
public override bool Equals(ProjectExtensibilityConfiguration other)
{
if (other == null)
{
return false;
}
// We're intentionally ignoring the 'Kind' here. That's mostly for diagnostics and doesn't influence any behavior.
return Enumerable.SequenceEqual(Assemblies.OrderBy(a => a.Identity.Name), other.Assemblies.OrderBy(a => a.Identity.Name));
}
public override int GetHashCode()
{
var hash = new HashCodeCombiner();
foreach (var assembly in Assemblies.OrderBy(a => a.Identity.Name))
{
hash.Add(assembly);
}
return hash;
}
}
}

View File

@ -5,7 +5,7 @@ using System;
namespace Microsoft.CodeAnalysis.Razor.ProjectSystem
{
internal sealed class ProjectExtensibilityAssembly
internal sealed class ProjectExtensibilityAssembly : IEquatable<ProjectExtensibilityAssembly>
{
public ProjectExtensibilityAssembly(AssemblyIdentity identity)
{
@ -18,5 +18,25 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem
}
public AssemblyIdentity Identity { get; }
public bool Equals(ProjectExtensibilityAssembly other)
{
if (other == null)
{
return false;
}
return Identity.Equals(other.Identity);
}
public override int GetHashCode()
{
return Identity.GetHashCode();
}
public override bool Equals(object obj)
{
return base.Equals(obj as ProjectExtensibilityAssembly);
}
}
}

View File

@ -1,16 +1,26 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Collections.Generic;
namespace Microsoft.CodeAnalysis.Razor.ProjectSystem
{
internal abstract class ProjectExtensibilityConfiguration
internal abstract class ProjectExtensibilityConfiguration : IEquatable<ProjectExtensibilityConfiguration>
{
public abstract IReadOnlyList<ProjectExtensibilityAssembly> Assemblies { get; }
public abstract ProjectExtensibilityConfigurationKind Kind { get; }
public abstract ProjectExtensibilityAssembly RazorAssembly { get; }
public abstract bool Equals(ProjectExtensibilityConfiguration other);
public abstract override int GetHashCode();
public override bool Equals(object obj)
{
return base.Equals(obj as ProjectExtensibilityConfiguration);
}
}
}

View File

@ -1,10 +1,14 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
namespace Microsoft.CodeAnalysis.Razor.ProjectSystem
{
internal abstract class ProjectSnapshot
{
public abstract ProjectExtensibilityConfiguration Configuration { get; }
public abstract Project UnderlyingProject { get; }
}
}

View File

@ -11,6 +11,8 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem
public abstract void ProjectChanged(Project underlyingProject);
public abstract void ProjectChanged(ProjectSnapshotUpdateContext update);
public abstract void ProjectRemoved(Project underlyingProject);
public abstract void ProjectsCleared();

View File

@ -0,0 +1,24 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
namespace Microsoft.CodeAnalysis.Razor.ProjectSystem
{
internal class ProjectSnapshotUpdateContext
{
public ProjectSnapshotUpdateContext(Project underlyingProject)
{
if (underlyingProject == null)
{
throw new ArgumentNullException(nameof(underlyingProject));
}
UnderlyingProject = underlyingProject;
}
public Project UnderlyingProject { get; }
public ProjectExtensibilityConfiguration Configuration { get; set; }
}
}

View File

@ -0,0 +1,299 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System.Collections.Generic;
using System.Linq;
using Moq;
using Xunit;
namespace Microsoft.CodeAnalysis.Razor.ProjectSystem
{
public class DefaultProjectSnapshotManagerTest
{
public DefaultProjectSnapshotManagerTest()
{
Workspace = new AdhocWorkspace();
ProjectManager = new TestProjectSnapshotManager(Enumerable.Empty<ProjectSnapshotChangeTrigger>(), Workspace);
}
private TestProjectSnapshotManager ProjectManager { get; }
private Workspace Workspace { get; }
[Fact]
public void ProjectAdded_AddsProject_NotifiesListeners_AndStartsBackgroundWorker()
{
// Arrange
var project = Workspace.CurrentSolution.AddProject("Test", "Test", LanguageNames.CSharp);
// Act
ProjectManager.ProjectAdded(project);
// Assert
var snapshot = ProjectManager.GetSnapshot(project.Id);
Assert.True(snapshot.IsDirty);
Assert.True(ProjectManager.ListenersNotified);
Assert.True(ProjectManager.WorkerStarted);
}
[Fact]
public void ProjectChanged_MadeDirty_RetainsComputedState_NotifiesListeners_AndStartsBackgroundWorker()
{
// Arrange
var project = Workspace.CurrentSolution.AddProject("Test", "Test", LanguageNames.CSharp);
ProjectManager.ProjectAdded(project);
ProjectManager.Reset();
// Adding some computed state
var configuration = Mock.Of<ProjectExtensibilityConfiguration>();
ProjectManager.ProjectChanged(new ProjectSnapshotUpdateContext(project) { Configuration = configuration });
ProjectManager.Reset();
project = project.WithAssemblyName("Test1"); // Simulate a project change
// Act
ProjectManager.ProjectChanged(project);
// Assert
var snapshot = ProjectManager.GetSnapshot(project.Id);
Assert.True(snapshot.IsDirty);
Assert.Same(configuration, snapshot.Configuration);
Assert.False(ProjectManager.ListenersNotified);
Assert.True(ProjectManager.WorkerStarted);
}
[Fact]
public void ProjectChanged_BackgroundUpdate_MadeClean_WithSignificantChanges_NotifiesListeners_AndDoesNotStartBackgroundWorker()
{
// Arrange
var project = Workspace.CurrentSolution.AddProject("Test", "Test", LanguageNames.CSharp);
ProjectManager.ProjectAdded(project);
ProjectManager.Reset();
var configuration = Mock.Of<ProjectExtensibilityConfiguration>();
// Act
ProjectManager.ProjectChanged(new ProjectSnapshotUpdateContext(project) { Configuration = configuration });
// Assert
var snapshot = ProjectManager.GetSnapshot(project.Id);
Assert.False(snapshot.IsDirty);
Assert.Same(configuration, snapshot.Configuration);
Assert.True(ProjectManager.ListenersNotified);
Assert.False(ProjectManager.WorkerStarted);
}
[Fact]
public void ProjectChanged_BackgroundUpdate_MadeClean_WithoutSignificantChanges_NotifiesListeners_AndDoesNotStartBackgroundWorker()
{
// Arrange
var project = Workspace.CurrentSolution.AddProject("Test", "Test", LanguageNames.CSharp);
ProjectManager.ProjectAdded(project);
ProjectManager.Reset();
var configuration = Mock.Of<ProjectExtensibilityConfiguration>();
ProjectManager.ProjectChanged(new ProjectSnapshotUpdateContext(project) { Configuration = configuration });
ProjectManager.Reset();
project = project.WithAssemblyName("Test1"); // Simulate a project change
ProjectManager.ProjectChanged(project);
ProjectManager.Reset();
// Act
ProjectManager.ProjectChanged(new ProjectSnapshotUpdateContext(project) { Configuration = configuration });
// Assert
var snapshot = ProjectManager.GetSnapshot(project.Id);
Assert.False(snapshot.IsDirty);
Assert.Same(configuration, snapshot.Configuration);
Assert.False(ProjectManager.ListenersNotified);
Assert.False(ProjectManager.WorkerStarted);
}
[Fact]
public void ProjectChanged_BackgroundUpdate_StillDirty_WithSignificantChanges_NotifiesListeners_AndStartsBackgroundWorker()
{
// Arrange
var project = Workspace.CurrentSolution.AddProject("Test", "Test", LanguageNames.CSharp);
ProjectManager.ProjectAdded(project);
ProjectManager.Reset();
var configuration = Mock.Of<ProjectExtensibilityConfiguration>();
// Compute an update for "Test"
var update = new ProjectSnapshotUpdateContext(project) { Configuration = configuration };
project = project.WithAssemblyName("Test1"); // Simulate a project change
ProjectManager.ProjectChanged(project);
ProjectManager.Reset();
// Act
ProjectManager.ProjectChanged(update);
// Assert
var snapshot = ProjectManager.GetSnapshot(project.Id);
Assert.True(snapshot.IsDirty);
Assert.Same(configuration, snapshot.Configuration);
Assert.True(ProjectManager.ListenersNotified);
Assert.True(ProjectManager.WorkerStarted);
}
[Fact]
public void ProjectChanged_BackgroundUpdate_StillDirty_WithoutSignificantChanges_NotifiesListeners_AndStartsBackgroundWorker()
{
// Arrange
var project = Workspace.CurrentSolution.AddProject("Test", "Test", LanguageNames.CSharp);
ProjectManager.ProjectAdded(project);
ProjectManager.Reset();
var configuration = Mock.Of<ProjectExtensibilityConfiguration>();
ProjectManager.ProjectChanged(new ProjectSnapshotUpdateContext(project) { Configuration = configuration });
project = project.WithAssemblyName("Test1"); // Simulate a project change
ProjectManager.ProjectChanged(project);
ProjectManager.Reset();
// Compute an update for "Test1"
var update = new ProjectSnapshotUpdateContext(project) { Configuration = configuration };
project = project.WithAssemblyName("Test2"); // Simulate a project change
ProjectManager.ProjectChanged(project);
ProjectManager.Reset();
// Act
ProjectManager.ProjectChanged(update); // Still dirty because the project changed while computing the update
// Assert
var snapshot = ProjectManager.GetSnapshot(project.Id);
Assert.True(snapshot.IsDirty);
Assert.Same(configuration, snapshot.Configuration);
Assert.False(ProjectManager.ListenersNotified);
Assert.True(ProjectManager.WorkerStarted);
}
[Fact]
public void ProjectChanged_IgnoresUnknownProject()
{
// Arrange
var project = Workspace.CurrentSolution.AddProject("Test", "Test", LanguageNames.CSharp);
// Act
ProjectManager.ProjectChanged(project);
// Assert
Assert.Empty(ProjectManager.Projects);
Assert.False(ProjectManager.ListenersNotified);
Assert.False(ProjectManager.WorkerStarted);
}
[Fact]
public void ProjectChanged_WithComputedState_IgnoresUnknownProject()
{
// Arrange
var project = Workspace.CurrentSolution.AddProject("Test", "Test", LanguageNames.CSharp);
// Act
ProjectManager.ProjectChanged(new ProjectSnapshotUpdateContext(project));
// Assert
Assert.Empty(ProjectManager.Projects);
Assert.False(ProjectManager.ListenersNotified);
Assert.False(ProjectManager.WorkerStarted);
}
[Fact]
public void ProjectRemoved_RemovesProject_NotifiesListeners_DoesNotStartBackgroundWorker()
{
// Arrange
var project = Workspace.CurrentSolution.AddProject("Test", "Test", LanguageNames.CSharp);
ProjectManager.ProjectAdded(project);
ProjectManager.Reset();
// Act
ProjectManager.ProjectRemoved(project);
// Assert
Assert.Empty(ProjectManager.Projects);
Assert.True(ProjectManager.ListenersNotified);
Assert.False(ProjectManager.WorkerStarted);
}
[Fact]
public void ProjectRemoved_IgnoresUnknownProject()
{
// Arrange
var project = Workspace.CurrentSolution.AddProject("Test", "Test", LanguageNames.CSharp);
// Act
ProjectManager.ProjectRemoved(project);
// Assert
Assert.Empty(ProjectManager.Projects);
Assert.False(ProjectManager.ListenersNotified);
Assert.False(ProjectManager.WorkerStarted);
}
[Fact]
public void ProjectsCleared_RemovesProject_NotifiesListeners_DoesNotStartBackgroundWorker()
{
// Arrange
var project = Workspace.CurrentSolution.AddProject("Test", "Test", LanguageNames.CSharp);
ProjectManager.ProjectAdded(project);
ProjectManager.Reset();
// Act
ProjectManager.ProjectsCleared();
// Assert
Assert.Empty(ProjectManager.Projects);
Assert.True(ProjectManager.ListenersNotified);
Assert.False(ProjectManager.WorkerStarted);
}
private class TestProjectSnapshotManager : DefaultProjectSnapshotManager
{
public TestProjectSnapshotManager(IEnumerable<ProjectSnapshotChangeTrigger> triggers, Workspace workspace)
: base(triggers, workspace)
{
}
public bool ListenersNotified { get; private set; }
public bool WorkerStarted { get; private set; }
public DefaultProjectSnapshot GetSnapshot(ProjectId id)
{
return Projects.Cast<DefaultProjectSnapshot>().FirstOrDefault(s => s.UnderlyingProject.Id == id);
}
public void Reset()
{
ListenersNotified = false;
WorkerStarted = false;
}
protected override void NotifyListeners(ProjectChangeEventArgs e)
{
ListenersNotified = true;
}
protected override void NotifyBackgroundWorker()
{
WorkerStarted = true;
}
}
}
}