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

Memory Leak when using ContextPropagationInterceptor #2419

Closed
aputlock opened this issue Apr 26, 2024 · 1 comment · Fixed by #2421
Closed

Memory Leak when using ContextPropagationInterceptor #2419

aputlock opened this issue Apr 26, 2024 · 1 comment · Fixed by #2421
Labels
bug Something isn't working

Comments

@aputlock
Copy link

aputlock commented Apr 26, 2024

What version of gRPC and what language are you using?

C#
gRPC codegen compiled with Grpc.Tools 2.40.0
Affected process importing Grpc.AspNetCore 2.50.0

What operating system (Linux, Windows,...) and version?

Windows

What runtime / compiler are you using (e.g. .NET Core SDK version dotnet --info)

.net7

What did you do?

My service implements a server streaming call, which as part of its implementation polls an rpc from another service. We also have ContextPropagationInterceptor added to the pipeline.

Simplified example:

public override async Task Stream(StreamRequest request, IServerStreamWriter<StreamResponse> responseStream, ServerCallContext context)
{
    using CancellationTokenSource cts = new CancellationTokenSource();
    while (!context.CancellationToken.IsCancellationRequested)
    {
        // _otherService is a codegen'd grpc client
        var response = await _otherService.GetStateAsync(new GetStateRequest(), new Metadata(), cancellationToken: cts.Token);
        await responseStream.WriteAsync(response, context.CancellationToken);
    }
}

What did you expect to see?

All resources cleaned up

What did you see instead?

Process memory increases over time -- memory dump + ETW trace indicates there's a leak of Linked2CancellationTokenSource objects (+ associated CallbackRegistration, CallbackNode).

ContextPropagationInterceptor allocates a LinkedCancellationTokenSource and sets it up to be disposed when the containing GrpcCall is disposed. However, it doesn't seem like the call is ever implicitly disposed.

Using unary calls as an example:

linkedCts = CancellationTokenSource.CreateLinkedTokenSource(serverCallContext.CancellationToken, options.CancellationToken);

return new AsyncUnaryCall<TResponse>(
responseAsync: call.ResponseAsync,
responseHeadersAsync: UnaryCallbacks<TResponse>.GetResponseHeadersAsync,
getStatusFunc: UnaryCallbacks<TResponse>.GetStatus,
getTrailersFunc: UnaryCallbacks<TResponse>.GetTrailers,
disposeAction: UnaryCallbacks<TResponse>.Dispose,
CreateContextState(call, cts));

internal static readonly Action<object> Dispose = state => ((ContextState<AsyncUnaryCall<TResponse>>)state).Dispose();

public void Dispose()
{
Call.Dispose();
CancellationTokenSource.Dispose();
}

I can work around this by adjusting my code, but it doesn't feel expected/ergonomic from a user's perspective:

public override async Task Stream(StreamRequest request, IServerStreamWriter<StreamResponse> responseStream, ServerCallContext context)
{
    using CancellationTokenSource cts = new CancellationTokenSource();
    while (!context.CancellationToken.IsCancellationRequested)
    {
        using var unaryCall = _otherService.GetStateAsync(new GetStateRequest(), new Metadata(), cancellationToken: cts.Token);
        var response = await unaryCall;
        await responseStream.WriteAsync(response, context.CancellationToken);
    }
}

Particularly considering the comment on AsyncUnaryCall:Dispose:

/// <summary>
/// Provides means to cleanup after the call.
/// If the call has already finished normally (request stream has been completed and call result has been received), doesn't do anything.
/// Otherwise, requests cancellation of the call which should terminate all pending async operations associated with the call.
/// As a result, all resources being used by the call should be released eventually.
/// </summary>
/// <remarks>
/// Normally, there is no need for you to dispose the call unless you want to utilize the
/// "Cancel" semantics of invoking <c>Dispose</c>.
/// </remarks>

It seems like either AsyncUnaryCall should be automatically disposing its AsyncCallState at some point, or ContextPropagationInterceptor has a faulty assumption that AsyncCallState will be disposed in normal control flow.

@JamesNK
Copy link
Member

JamesNK commented Apr 28, 2024

Thanks for the investigation. Fix here: #2421

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working
Projects
None yet
Development

Successfully merging a pull request may close this issue.

2 participants