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

Do not throw ObjectDisposedException in OnWorkspaceUpdateAsync #8892

Closed

Conversation

tmeschter
Copy link
Contributor

@tmeschter tmeschter commented Mar 3, 2023

The Workspace type (which is responsible for passing data from a single ConfiguredProject through to the Language Service) derives from OnceInitializedOnceDisposedUnderLockAsync; the latter ensures that initialization and disposal will happen at most once and allows implementers to protect themselves from disposal while in the middle of work. This is done by calling the protected ExecuteUnderLockAsync and passing in a delegate representing the work to be protected.

There are a couple of potential problems, however. First, Workspace does not protect all of its work; usually it does some amount of validation and processing and then calls ExecuteUnderLockAsync for the parts that may change state. Second, the methods that can be called from other types assert that the instance has not already been disposed by calling Verify.NotDisposed(this); this throws an ObjectDisposedException if the verification fails.

As best I can tell the first potential problem is not an actual problem. By inspecting the code it appears that none of the state accessed outside ExecuteUnderLockAsync will be problematic if accessed after disposal. So I'm going to leave that alone for now.

This commit addresses the second issue, at least in part. The OnWorkspaceUpdateAsync method is called from a dataflow block when new evaluation or design-time build data becomes available and we need to process it and update the Language Service. It starts with a call to Verify.NotDisposed(this). However, a Workspace is disposed by the LanguageServiceHost when it decides it is no longer needed--which is based on data from a different workflow. I can see no indication that the processing of these two workflows is coordinated, so the Workspace could be disposed after the call to NotDisposed, or we could see calls to OnWorkspaceUpdateAsync after disposal. And indeed I've occasionally seen the latter.

(Note that disposing the Workspace will break the links in the dataflow subscription, but by that time the dataflow block may have already decided to call OnWorkspaceUpdateAsync with the current input value.)

Here we replace the call to Verify.NotDisposed(this) with a simple check to see if the Workspace has already been disposed, or is in the process. In that case we can simply bail out early; since the Workspace has been disposed we know we don't care about processing the evaluation or build data.

Note that this may not be a complete fix; as previously mentioned disposal could occur during OnWorkspaceUpdateAsync. This is instead the minimal change that I believe could be sufficient to avoid the exception without introducing other problems.

A more certain fix would be to have each entry point to the Workspace wrap its work in ExecuteUnderLockAsync. However that is a more invasive change so I'm hoping to avoid it.

Microsoft Reviewers: Open in CodeFlow

The `Workspace` type (which is responsible for passing data from a single `ConfiguredProject` through to the Language Service) derives from `OnceInitializedOnceDisposedUnderLockAsync`; the latter ensures that initialization and disposal will happen at most once and allows implementers to protect themselves from disposal while in the middle of work. This is done by calling the protected `ExecuteUnderLockAsync` and passing in a delegate representing the work to be protected.

There are a couple of potential problems, however. First, `Workspace` does not protect _all_ of its work; usually it does some amount of validation and processing and then calls `ExecuteUnderLockAsync` for the parts that may change state. Second, the methods that can be called from other types assert that the instance has not already been disposed by calling `Verify.NotDisposed(this)`; this throws an `ObjectDisposedException` if the verification fails.

As best I can tell the first _potential_ problem is not an actual problem. By inspecting the code it appears that none of the state accessed outside `ExecuteUnderLockAsync` will be problematic if accessed after disposal. So I'm going to leave that alone for now.

This commit addresses the second issue, at least in part. The `OnWorkspaceUpdateAsync` method is called from a dataflow block when new evaluation or design-time build data becomes available and we need to process it and update the Language Service. It starts with a call to `Verify.NotDisposed(this)`. However, a `Workspace` is disposed by the `LanguageServiceHost` when it decides it is no longer needed--which is based on data from a _different_ workflow. I can see no indication that the processing of these two workflows is coordinated, so the `Workspace` could be disposed after the call to `NotDisposed`, or we could see calls to `OnWorkspaceUpdateAsync` after disposal. And indeed I've occasionally seen the latter.

(Note that disposing the Workspace will break the links in the dataflow subscription, but by that time the dataflow block may have already decided to call `OnWorkspaceUpdateAsync` with the current input value.)

Here we replace the call to `Verify.NotDisposed(this)` with a simple check to see if the `Workspace` has already been disposed, or is in the process. In that case we can simply bail out early; since the `Workspace` has been disposed we know we don't care about processing the evaluation or build data.

Note that this may not be a complete fix; as previously mentioned disposal could occur during `OnWorkspaceUpdateAsync`. This is instead the minimal change that I believe could be sufficient to avoid the exception without introducing other problems.

A more certain fix would be to have each entry point to the `Workspace` wrap its work in `ExecuteUnderLockAsync`. However that is a more invasive change so I'm hoping to avoid it.
@tmeschter tmeschter requested a review from a team as a code owner March 3, 2023 00:15
@@ -85,11 +85,21 @@ public async Task Dispose_TriggersObjectDisposedExceptionsOnPublicMembers()

await Assert.ThrowsAsync<ObjectDisposedException>(() => workspace.WriteAsync(w => Task.CompletedTask, CancellationToken.None));
await Assert.ThrowsAsync<ObjectDisposedException>(() => workspace.WriteAsync(w => TaskResult.EmptyString, CancellationToken.None));
await Assert.ThrowsAsync<ObjectDisposedException>(() => workspace.OnWorkspaceUpdateAsync(null!));
//await Assert.ThrowsAsync<ObjectDisposedException>(() => workspace.OnWorkspaceUpdateAsync(null!));
Copy link
Member

Choose a reason for hiding this comment

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

nit: Is this commented code intended to remain since this solution may not be a complete fix?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oh, no; I meant to delete that.

@drewnoakes
Copy link
Member

#8895 wraps the whole update in a lock to prevent disposal during updates.

@tmeschter
Copy link
Contributor Author

Closing in favor of #8895.

@tmeschter tmeschter closed this Mar 3, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants