Skip to content

Extract shared JsonSerializerOptions to eliminate duplicate allocations#774

Open
PureWeen wants to merge 1 commit intomainfrom
op-dedup-json-serializer-options
Open

Extract shared JsonSerializerOptions to eliminate duplicate allocations#774
PureWeen wants to merge 1 commit intomainfrom
op-dedup-json-serializer-options

Conversation

@PureWeen
Copy link
Copy Markdown
Owner

Replace 11 inline new JsonSerializerOptions { WriteIndented = true } allocations across 7 files with a shared JsonDefaults.Indented static readonly field.

Each inline call allocated a new JsonSerializerOptions instance on every invocation, bypassing System.Text.Json's internal caching. The shared static readonly field is safe here — JsonSerializerOptions is a pure BCL type with no platform API dependencies.

Changes

  • New: PolyPilot/Models/JsonDefaults.csinternal static class with Indented field
  • Modified (11 call sites across 7 files):
    • CopilotService.Events.cs (line 1496)
    • CopilotService.Organization.cs (lines 458, 3059)
    • CopilotService.Persistence.cs (lines 159, 1461, 1557)
    • CopilotService.cs (line 1966)
    • RepoManager.cs (line 359)
    • ScheduledTaskService.cs (line 493)
    • ConnectionSettings.cs (lines 334, 336)
  • Test project: added <Compile Include> for JsonDefaults.cs

Verification

  • dotnet build PolyPilot/PolyPilot.csproj -f net10.0-maccatalyst ✅ 0 errors
  • dotnet test PolyPilot.Tests/PolyPilot.Tests.csproj ✅ 3565 passed, 0 failed

Zero behavioral change — pure mechanical refactor.

Replace 11 inline `new JsonSerializerOptions { WriteIndented = true }`
allocations across 7 files with a shared `JsonDefaults.Indented` static
readonly field. Each inline allocation bypassed System.Text.Json's internal
caching; the shared instance is reused for every serialization call.

New file: PolyPilot/Models/JsonDefaults.cs
Modified: CopilotService.Events.cs, CopilotService.Organization.cs,
          CopilotService.Persistence.cs, CopilotService.cs,
          RepoManager.cs, ScheduledTaskService.cs, ConnectionSettings.cs
Test csproj: added Compile Include for JsonDefaults.cs

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions
Copy link
Copy Markdown
Contributor

🔍 Design-Level Findings (outside diff)

🟡 MODERATE — Incomplete refactor: two inline allocations remain (Flagged by 2/3 reviewers)

The PR description states all 11 inline JsonSerializerOptions allocations were replaced, but two additional sites using the fully-qualified new System.Text.Json.JsonSerializerOptions { WriteIndented = true } syntax were missed:

  • PolyPilot/Services/CopilotService.Events.cs ~line 3596 (CaptureZeroIdleDebugInfoInternal)
  • PolyPilot/Models/ModelCapabilities.cs ~line 956 (GroupPreset.Save)

Both are identical WriteIndented = true-only allocations and could use JsonDefaults.Indented. Consider updating these in this PR or a follow-up.


🟢 MINOR — Pre-existing duplicate static instance (Flagged by 2/3 reviewers)

PolyPilot/Services/UsageStatsService.cs ~line 125 has:

private static readonly JsonSerializerOptions SaveOptions = new() { WriteIndented = true };

This is now a semantic duplicate of JsonDefaults.Indented. Not a regression from this PR, but a follow-up consolidation opportunity.


One additional finding (type metadata cache growth from shared options in FormatToolResult) was flagged by 1/3 reviewers but discarded after tie-breaker review determined it was based on an incorrect understanding of System.Text.Json's caching model — type metadata is cached globally regardless of whether options are shared or per-call.

Generated by Expert Code Review (auto) for issue #774 · ● 5.9M ·

@PureWeen
Copy link
Copy Markdown
Owner Author

/review

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 27, 2026

Expert Code Review completed successfully!

@github-actions
Copy link
Copy Markdown
Contributor

Expert Code Review — PR #774

PR: Extract shared JsonSerializerOptions to eliminate duplicate allocations

Methodology

3 independent reviewers with adversarial consensus (Reviewer 1: deep reasoning, Reviewer 2: pattern matching, Reviewer 3: alternative perspective).

Findings

# Severity Consensus File Line(s) Finding
1 🟢 MINOR 2/3 PolyPilot/Models/JsonDefaults.cs 7 Defensive freeze missing. static readonly prevents reassignment but not mutation of the JsonSerializerOptions object. Calling Indented.MakeReadOnly() after construction (or in a static constructor) would make accidental Converters.Add() fail fast instead of silently corrupting all 11 call sites. Low practical risk today — no code mutates it — but a worthwhile defensive hardening.
2 🟢 MINOR 3/3 PolyPilot/Services/CopilotService.Events.cs ~3596 (outside diff) Missed conversion site. The [ZERO-IDLE] capture path still allocates new JsonSerializerOptions { WriteIndented = true } inline. Uses fully-qualified System.Text.Json.JsonSerializerOptions which likely evaded the search pattern.
3 🟢 MINOR 2/3 PolyPilot/Models/ModelCapabilities.cs ~956 (outside diff) Missed conversion site. GroupPresetStore.Save() still allocates inline with the fully-qualified type name. Same root cause as #2.
4 🟢 MINOR 2/3 PolyPilot/Services/UsageStatsService.cs ~125 (outside diff) Redundant duplicate field. Pre-existing private static readonly JsonSerializerOptions SaveOptions = new() { WriteIndented = true } is now a semantic duplicate of JsonDefaults.Indented. Not a regression, but consolidation is incomplete.

Discarded Findings

  • EfficiencyAnalysisService.cs:43 — 0/3 flagged. Uses different options (CamelCase policy). Correctly excluded from this PR.

Verified Safe (3/3 agree)

  • JsonSerializerOptions is thread-safe once frozen (after first serialization) — sharing as static readonly is the [recommended .NET pattern]((learn.microsoft.com/redacted)
  • ✅ No platform API in the constructor — does not violate the project's static readonly rule for platform-dependent types
  • ✅ No existing code mutates the options after construction
  • PolyPilot.Models namespace is accessible from all call sites (Services/ files already have the using)
  • ✅ Test csproj <Compile Include> correctly ordered alphabetically

CI Status

  • Previous workflow run: 5/6 checks passed (the safe_outputs failure is from an earlier review workflow, not build CI)
  • No dedicated build/test CI checks are configured on this PR

Test Coverage

  • PR author reports: dotnet test — 3565 passed, 0 failed ✅
  • No new unit tests added, which is appropriate for a pure mechanical refactor with zero behavioral change
  • Existing serialization tests provide indirect coverage through integration

Summary

Clean mechanical refactor. All 11 converted call sites are correct. The 3 missed sites (findings #2#4) are pre-existing and outside the diff — not regressions — but would complete the consolidation goal. Finding #1 (MakeReadOnly()) is a low-risk defensive improvement. No regressions, security issues, data loss risks, or race conditions introduced.

Generated by Expert Code Review · 3 independent reviewers with adversarial consensus

Warning

⚠️ Firewall blocked 3 domains

The following domains were blocked by the firewall during workflow execution:

  • api.nuget.org
  • dc.services.visualstudio.com
  • learn.microsoft.com

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
  allowed:
    - defaults
    - "api.nuget.org"
    - "dc.services.visualstudio.com"
    - "learn.microsoft.com"

See Network Configuration for more information.

Generated by Expert Code Review for issue #774 · ● 13.6M ·

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Review Summary — PR #774: Extract shared JsonSerializerOptions

The core refactor is correct: JsonSerializerOptions is a pure BCL type with no platform API dependencies, so static readonly is safe here (no TypeInitializationException risk per project conventions). Thread safety after the first freeze is guaranteed by System.Text.Json. All 3 reviewers confirmed zero behavioral regressions, no data loss risks, and no race conditions.

Findings

🟡 MODERATE — MakeReadOnly() not called (3/3 reviewers)

File: PolyPilot/Models/JsonDefaults.cs:7 (inline comment above)

readonly prevents field reassignment only — not mutation of the object. The instance is fully mutable until first use. Any future code adding a converter or changing a property before the first serialization would silently corrupt global serialization behavior; after freeze it would throw an opaque InvalidOperationException far from the actual mutation site. Fix: call opts.MakeReadOnly() before returning from a factory method (see inline comment for full fix).


🟢 MINOR — One migration site missed (3/3 reviewers)

File: PolyPilot/Services/CopilotService.Events.cs ~line 3596 (outside diff)

A zero-idle diagnostic capture path still uses new System.Text.Json.JsonSerializerOptions { WriteIndented = true } and was not converted. Not a regression, but leaves the PR's stated goal incomplete and will confuse future maintainers about whether the omission was intentional.

Fix: var json = System.Text.Json.JsonSerializer.Serialize(capture, JsonDefaults.Indented);


i️ Informational — Dead #if branch in ConnectionSettings.cs (pre-existing, not introduced)

After this PR both the IOS || ANDROID and #else branches contain identical JsonSerializer.Serialize(this, JsonDefaults.Indented) calls. The structural #if/#else split around the serialization line is pre-existing dead weight that could be collapsed, but this is outside scope for this PR.

Warning

⚠️ Firewall blocked 3 domains

The following domains were blocked by the firewall during workflow execution:

  • api.nuget.org
  • dc.services.visualstudio.com
  • learn.microsoft.com

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
  allowed:
    - defaults
    - "api.nuget.org"
    - "dc.services.visualstudio.com"
    - "learn.microsoft.com"

See Network Configuration for more information.

Generated by Expert Code Review for issue #774 · ● 13.6M


internal static class JsonDefaults
{
internal static readonly JsonSerializerOptions Indented = new() { WriteIndented = true };
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 MODERATE — Shared instance not hardened against mutation (3/3 reviewers)

readonly only prevents reassignment of the field reference; it does not prevent mutation of the object's properties or Converters list. JsonSerializerOptions is fully mutable from construction until the first JsonSerializer.Serialize() call, at which point System.Text.Json internally freezes it.

Concrete failing scenario: Any future code that calls JsonDefaults.Indented.Converters.Add(...) or sets .PropertyNamingPolicy etc. before the first serialization will silently corrupt every consumer in the assembly. After freeze, the same attempt throws InvalidOperationException from deep inside System.Text.Json with no stack frame pointing back here — a hard-to-diagnose crash.

All current call sites are pure reads so there is no immediate regression, but the field is internal (accessible throughout the assembly) with no compile-time guard.

Fix: Call MakeReadOnly() immediately after construction (.NET 7+, this project targets .NET 10):

internal static class JsonDefaults
{
    internal static readonly JsonSerializerOptions Indented = CreateIndented();

    private static JsonSerializerOptions CreateIndented()
    {
        var opts = new JsonSerializerOptions { WriteIndented = true };
        opts.MakeReadOnly();
        return opts;
    }
}

This causes any mutation attempt to throw immediately and deterministically at the mutation site rather than producing mysterious downstream failures.

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.

1 participant