Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Additional work to release unused PreviewWorkspace memory #68054

Open
wants to merge 8 commits into
base: main
Choose a base branch
from

Conversation

sharwell
Copy link
Member

@sharwell sharwell commented May 2, 2023

Local heap dump shows 6.8GB memory retained by unused PreviewWorkspace instances. The GC root is linked through event handlers on GlobalOptionService. Since this has been a problem in the past (#67142), I took a multi-approach solution:

  1. More aggressively track and dispose PreviewWorkspace instances. They are stored in ReferenceCountedDisposable<> from the point of initial creation, with ownership transferred by a pattern of duplication of a reference followed by releasing the original. Outside of exceptions thrown by the constructor(s), instances should now be disposed in the event of any exception.
  2. Force the use of a weak event handler pattern for GlobalOptionService. In the event a PreviewWorkspace fails to be released, it will no longer be GC rooted through this event. Moved to Use a weak event handler pattern for GlobalOptionService #68062

@sharwell sharwell requested review from a team as code owners May 2, 2023 14:05
@dotnet-issue-labeler dotnet-issue-labeler bot added Area-IDE untriaged Issues and PRs which have not yet been triaged by a lead labels May 2, 2023
// Avoid propagating the exception within a UI cleanup layer
}
}

private void OnToolTipOpening(object sender, ToolTipEventArgs e)
Copy link
Member Author

@sharwell sharwell May 2, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 If an exception was no the original source of a PreviewWorkspace leaking, this is the second most likely case. This type assumes that there is a strict matching of calls to OnToolTipOpening and OnToolTipClosing. If there is ever a case where OnToolTipOpening is called more times than OnToolTipClosing, the additional calls would have leaked a PreviewWorkspace instance.

The updated code partially relaxes this assumption. If a stray workspace is detected in the call to OnToolTipOpening, it will be cleaned up at that time. We also add a new handler for ITableEntriesSnapshot.Dispose() (the owner of these entries) to force any remaining items to be disposed when the snapshot is disposed.


return new DisposableToolTip(toolTip, workspace);
return DisposableToolTip.Create(toolTip, workspace);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

visually, this just looks weird. It looks like you're returning something that is holding onto something that is explicitly being disposed. needs comments to explain why this is ok.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the correct pattern for correctly transferring ownership of a ReferenceCountedDisposable<T>. An RCD passed by value is only assured to be alive until the end of the call. The callee must obtain its own reference if it needs to store the value to a field. The behavior of the caller depends on the source of the RCD: if the caller created the RCD (as is the case here) it must release it before returning. If the caller received the RCD via an argument, it doesn't own that instance and can just return.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the correct pattern for correctly transferring ownership of a ReferenceCountedDisposable

literally nothing in the code states taht it's an RCD though. It just looks like a bog standard captured disposable. RCD is fine here because the using is only affecting the ref-count. And, when that is clear, is totally ok. But in cases like this, there's no indication of ref-counting at all, so it immediatley jumps out as looking problematic.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

➡️ Renamed Create methods that return RCD to CreateReferenceCounted

@@ -18,17 +19,17 @@ internal partial class ContentControlService
/// <summary>
/// Class which allows us to provide a delay-created tooltip for our reference entries.
/// </summary>
private class LazyToolTip : ForegroundThreadAffinitizedObject
private class LazyToolTip : ForegroundThreadAffinitizedObject, IDisposable
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

perhaps LazyDisposableToolTip?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

➡️ Keeping the current name because 1) it's impossible to create an instance of this without realizing it's disposable (the Create method returns RCD) and 2) there is no corresponding non-disposable type.

@@ -58,8 +75,11 @@ private void OnToolTipOpening(object sender, ToolTipEventArgs e)
Debug.Assert(_element.ToolTip == this);
Debug.Assert(_disposableToolTip == null);

// We don't expect _disposableToolTip to be non-null here, but we still make sure it's not leaking
_disposableToolTip?.Dispose();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what about just not doing work in that case? if hte tooltip was already created, we could just immediately exit?

Copy link
Member Author

@sharwell sharwell May 2, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

➡️ Updated

var rightWorkspace = new PreviewWorkspace(document.Project.Solution);
rightWorkspace.OpenDocument(document.Id, newBuffer.AsTextContainer());
using var rightWorkspace = PreviewWorkspace.Create(document.Project.Solution);
rightWorkspace.Target.OpenDocument(document.Id, newBuffer.AsTextContainer());

#pragma warning disable CA2007 // Consider calling ConfigureAwait on the awaited task (containing method uses JTF)
return await CreateAddedDocumentPreviewViewCoreAsync(newBuffer, rightWorkspace, document, zoomLevel, cancellationToken);
#pragma warning restore CA2007 // Consider calling ConfigureAwait on the awaited task
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same points about returning something that captures a local which appears to be disposed when we get to end of scope.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}

return new DifferenceViewerPreview(diffViewer);
return differenceViewer.TryAddReference()!;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for my own edification, this should always succeed, yes? (i presume that's also why we have the !). I feel ike it would be nice on these RefCountedDisposables to have a method (or extension) that adds and asserts/throws if that somehow failed (Similar to GetRequiredDocument vs GetDocument).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⏱️ Will add a AddReference() method

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

➡️ Implemented in b228912

{
Contract.ThrowIfNull(viewer);
_viewer = viewer;
LeftWorkspace = leftWorkspace?.TryAddReference();
RightWorkspace = rightWorkspace?.TryAddReference();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why ?.? You never are passed null instances in right?

Also, is this another location where we could use the asserting AddRef methods?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also, i'd get rid of nullable disable in this file. it seems like it would catch a few things.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multiple locations pass in null

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

➡️ These can be null. See 6a08a73

@@ -33,6 +48,9 @@ public void Dispose()
}

_viewer = null;

LeftWorkspace?.Dispose();
RightWorkspace?.Dispose();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no need for ?. afaict.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

➡️ Covered by 6a08a73

=> throw new NotImplementedException();

public void RemoveOptionChangedHandler(object target, EventHandler<OptionChangedEventArgs> handler)
=> throw new NotImplementedException();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would it be possible to make two PRs? one for the refcounted stuff, and one for the weak event handlerS?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

➡️ Extracted #68062

CreateDisposableToolTip(_excerptResult.Document, _excerptResult.Span));

disposeCallback += () =>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wow. that this is being += is subtle. Can you doc htat? I was def confused a bit. :)

Copy link
Member

@CyrusNajmabadi CyrusNajmabadi May 2, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dumb question. i see what you're doing here, creating a side-table of disposables that go away when the snapshot goes away. Is it not possible though to just reutrn FrameworkElements that do this cleanup once they themselves are disposed?

Feels like it would be more natural to have hte lifetimes of these disposables track with that, versus in a side table.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 FrameworkElement is not disposable, and WPF does not notify on final release

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

➡️ Documented the use of the += operator

{
try
{
disposable?.Dispose();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why ?. here?

Copy link
Member Author

@sharwell sharwell May 2, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

➡️ Updated to .

@@ -31,6 +36,23 @@ public TableEntriesSnapshot(ImmutableList<Entry> entries, int versionNumber)

public override int Count => _entries.Count;

public override void Dispose()
{
foreach (var callback in _disposeCallbacks)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: consider threading assert.

Copy link
Member Author

@sharwell sharwell May 2, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

➡️ Updated to remove affinity further down stream

@@ -20,6 +24,7 @@ private class TableEntriesSnapshot : WpfTableEntriesSnapshotBase
private readonly int _versionNumber;

private readonly ImmutableList<Entry> _entries;
private readonly List<Action> _disposeCallbacks = new();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

docs plz.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

➡️ Small amount of docs added

{
_updater?.CloseWorkspace();
_updater?.Dispose();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why ?.?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

➡️ _updater is lazily created

@@ -218,15 +229,27 @@ public void UpdatePreview(DocumentId documentId, SpanChange spanSource)
var document = updatedSolution.GetTextDocument(documentId);
if (document != null)
{
_updater.UpdateView(document, spanSource);
using var updater = _updater?.TryAddReference();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why ?.?

Copy link
Member Author

@sharwell sharwell May 2, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

➡️ Switched to Contract.ThrowIfNull at this location

@@ -218,15 +229,27 @@ public void UpdatePreview(DocumentId documentId, SpanChange spanSource)
var document = updatedSolution.GetTextDocument(documentId);
if (document != null)
{
_updater.UpdateView(document, spanSource);
using var updater = _updater?.TryAddReference();
RoslynDebug.AssertNotNull(updater);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

soundsl ike a place for RequireAddReference :)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 It would just be AddReference()

Copy link
Member Author

@sharwell sharwell May 2, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

➡️ Switched to AddReference at this location

{
private PreviewDialogWorkspace? _previewWorkspace;
private ReferenceCountedDisposable<PreviewDialogWorkspace>? _previewWorkspace;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wow... this is lazily created and assigned.... i hate this :D (same problem before your code).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

➡️ Planning to leave this one as-is since it's the same as before

{
var newWorkspace = PreviewDialogWorkspace.Create(document.Project.Solution);
if (Interlocked.CompareExchange(ref _previewWorkspace, newWorkspace, null) is not null)
newWorkspace.Dispose();
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'm def confused about why this is all written this way. i would presume the PreviewUpdater is for something like the options page. and deferring the whole workspace stuff just seems totally unnecessary (And makes things so contorted and hard to follow).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

➡️ Planning to leave this one as-is since it's the same as before

RoslynDebug.AssertNotNull(_previewWorkspace);

using var previewWorkspace2 = _previewWorkspace.TryAddReference();
RoslynDebug.AssertNotNull(previewWorkspace2);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RequireAddReference :)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

➡️ Part of b228912

@CyrusNajmabadi
Copy link
Member

overall LGTM. a few strong recommendations:

  1. break out event handlers from preview workspace.
  2. more aggressive asserts on refcounteddisposable.
  3. some light docs in teh code would be good. esp. around passing ownership around.
  4. nullable enable some files
  5. consider changing some lifetimes of some objects to be clearer.

not signing off yet as i would like to do at least more pass after some cleanup has happened. Thanks.

@dotnet dotnet deleted a comment from azure-pipelines bot May 16, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Area-IDE untriaged Issues and PRs which have not yet been triaged by a lead
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants