Skip to content

Honor declared activity return type during GRPC serialization#3301

Draft
Copilot wants to merge 3 commits into
devfrom
copilot/fix-grpc-serializer-activity
Draft

Honor declared activity return type during GRPC serialization#3301
Copilot wants to merge 3 commits into
devfrom
copilot/fix-grpc-serializer-activity

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Dec 30, 2025

Summary

What changed?

  • Added a type-hinting durable task factory wrapper so activity outputs are wrapped with their declared return type before serialization.
  • Updated ObjectConverterShim to unwrap declared-type hints and serialize using the declared type, avoiding runtime-type polymorphism drift.
  • Added a unit test covering polymorphic serialization to ensure declared type is respected.

Why is this change needed?

  • GRPC serialization was using runtime types, breaking [JsonPolymorphic] scenarios; declared activity return types now flow into serialization.

Issues / work items

  • N/A

Project checklist

  • Documentation changes are not required
    • Otherwise: Documentation PR is ready to merge and referenced in pending_docs.md
  • Release notes are not required for the next release
    • Otherwise: Notes added to release_notes.md
  • Backport is not required
    • Otherwise: Backport tracked by issue/PR #issue_or_pr
  • All required tests have been added/updated (unit tests, E2E tests)
  • No extra work is required to be leveraged by OutOfProc SDKs
    • Otherwise: Work tracked here: #issue_or_pr_in_each_sdk
  • No change to the version of the WebJobs.Extensions.DurableTask package
    • Otherwise: Major/minor updates are reflected in /src/Worker.Extensions.DurableTask/AssemblyInfo.cs
  • No EventIds were added to EventSource logs
  • This change should be added to the v2.x branch
    • Otherwise: This change applies exclusively to WebJobs.Extensions.DurableTask v3.x and will be retained only in the dev and main branches
  • Breaking change?
    • If yes:
      • Impact:
      • Migration guidance:

AI-assisted code disclosure (required)

Was an AI tool used? (select one)

  • No
  • Yes, AI helped write parts of this PR (e.g., GitHub Copilot)
  • Yes, an AI agent generated most of this PR

If AI was used:

  • Tool(s): GitHub Copilot Chat
  • AI-assisted areas/files: ObjectConverterShim.cs, TypeHintingDurableTaskFactory.cs, ObjectConverterShimTests.cs
  • What you changed after AI output: Adjusted type hinting strategy to avoid AsyncLocal races, refined test assertions.

AI verification (required if AI was used):

  • I understand the code and can explain it
  • I verified referenced APIs/types exist and are correct
  • I reviewed edge cases/failure paths (timeouts, retries, cancellation, exceptions)
  • I reviewed concurrency/async behavior
  • I checked for unintended breaking or behavior changes

Testing

Automated tests

  • Result: Passed — dotnet test test/Worker.Extensions.DurableTask.Tests

Manual validation (only if runtime/behavior changed)

  • Environment (OS, .NET version, components):
  • Steps + observed results:
    1.
    2.
    3.
  • Evidence (optional):

Notes for reviewers

  • N/A
// Activity result now carries declared type into serialization
object? result = await inner.RunAsync(ctx, input);
return ObjectConverterShim.WithDeclaredType(result, inner.OutputType);

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • azfunc.pkgs.visualstudio.com
    • Triggering command: /opt/hostedtoolcache/CodeQL/2.23.8/x64/codeql/csharp/tools/linux64/Semmle.Autobuild.CSharp /opt/hostedtoolcache/CodeQL/2.23.8/x64/codeql/csharp/tools/linux64/Semmle.Autobuild.CSharp (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

Original prompt

This section details on the original issue you should resolve

<issue_title>[out-of-proc] GRPC serializer does not respect Activity return type when [JsonPolymorphic] is used</issue_title>
<issue_description>### Description
If activity is defined to return a base type of some hierarchy that supports polymorphic (de-)serialization, the actual serialization of activity output happens according to its runtime type rather than declared type, which breaks polymorphic feature of System.Text.Json.

We were able to trace it to Microsoft.Azure.Functions.Worker.Rpc.RpcExtensions.ToRpcDefault, which calls serializer.Serialize(value)?.ToString(); (here serializer is JsonObjectSerializer). This overload has the following signature:

BinaryData Serialize(object? value, Type? inputType = null, CancellationToken cancellationToken = default)

When called with one parameter, inputType is null. The implementation itself calls

 JsonSerializer.SerializeToUtf8Bytes(value, inputType ?? value?.GetType() ?? typeof(object), _options);

thus resolving runtime type of the return value, and serializing accordingly, instead of decalred type of activity.

Expected behavior

GRPC serializer passes return type as inputType, performing correct JSON serialization of the result.

Actual behavior

Serialization happens according to runtime type, which breaks polymorphic serialization of System.Text.Json

Relevant source code snippets

Program.cs
using System.Text.Json.Serialization;
using Azure.Core.Serialization;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.DurableTask;
using Microsoft.DurableTask.Client;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

var host = new HostBuilder()
    .ConfigureFunctionsWorkerDefaults(builder => builder.Serializer = new CustomSerializer())
    .Build();

await host.RunAsync();

[JsonPolymorphic]
[JsonDerivedType(typeof(DerivedResponse), nameof(DerivedResponse))]
public abstract record BaseResponse(int Field1);

public sealed record DerivedResponse(int Field1, int Field2) : BaseResponse(Field1);

public static class DurableFunctions
{
    [Function(nameof(TestActivity))]
    public static Task<BaseResponse> TestActivity([ActivityTrigger] object? input)
    {
        return Task.FromResult<BaseResponse>(new DerivedResponse(42, 99));
    }

    [Function(nameof(TestOrchestration))]
    public static async Task TestOrchestration([OrchestrationTrigger] TaskOrchestrationContext context)
    {
        var logger = context.CreateReplaySafeLogger(nameof(TestOrchestration));
        var result = await context.CallActivityAsync<BaseResponse>(nameof(TestActivity));
        logger.LogInformation("Received {Result}", result);
    }

    [Function(nameof(HttpEntryPoint))]
    public static async Task HttpEntryPoint([HttpTrigger(
        AuthorizationLevel.Anonymous, "GET", Route = "debug")] HttpRequestData req,
        [DurableClient] DurableTaskClient client
    )
    {
        await client.ScheduleNewOrchestrationInstanceAsync(nameof(TestOrchestration));
    }
}

public sealed class CustomSerializer : JsonObjectSerializer
{
    // Called from Microsoft.Azure.Functions.Worker.Rpc.RpcExtensions.ToRpcDefault
    public override BinaryData Serialize(object? value, Type? inputType = null, CancellationToken cancellationToken = default)
    {
        // Breakpoint here will reveal the issue
        var result = base.Serialize(value, inputType, cancellationToken);

        return result;
    }
}
.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <AzureFunctionsVersion>v4</AzureFunctionsVersion>
    <OutputType>Exe</OutputType>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.Azure.Functions.Worker" Version="2.0.0" />
    <PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" Version="1.2.2" />
    <PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http" Version="3.2.0"/>
    <PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.0.0" />
  </ItemGroup>
  <ItemGroup>
    <None Update="host.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="local.settings.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
      <CopyToPublishDirectory>Never</CopyToPublishDirectory>
    </None>
  </ItemGroup>
  <ItemGroup>
    <Using Include="System.Threading.ExecutionContext" Alias="ExecutionContext"/>
  </ItemGroup>
</Project>

Known workarounds

There is no reliable workaround because ObjectSerializer does not get type information or any other required information. If there are multiple functions that retu...


✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.

Copilot AI and others added 2 commits December 30, 2025 20:52
Co-authored-by: YunchuWang <12449837+YunchuWang@users.noreply.github.com>
Co-authored-by: YunchuWang <12449837+YunchuWang@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix gRPC serializer to respect Activity return type with JsonPolymorphic Honor declared activity return type during GRPC serialization Dec 30, 2025
Copilot AI requested a review from YunchuWang December 30, 2025 21:00
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.

[out-of-proc] GRPC serializer does not respect Activity return type when [JsonPolymorphic] is used

2 participants