Fix | Scope configurable retry logic assembly resolution to opt-in callers - #4547
Conversation
SqlConfigurableRetryLogicLoader subscribed a handler to AssemblyLoadContext.Default.Resolving in its constructor and never removed it. Because SqlConfigurableRetryLogicManager builds that loader on the default RetryLogicProvider path, simply reading SqlCommand.RetryLogicProvider or SqlConnection.RetryLogicProvider installed a permanent, process-wide assembly resolution hook. The hook then participated in resolving every assembly the host application failed to find, even though the application had not configured any custom retry logic type. It also probed Environment.CurrentDirectory, which is ambient process state unrelated to where the application's binaries live, so assemblies could be resolved from an unintended location. Applications observed this as load failures, and in #2214 as a stack overflow, originating inside SqlClient for assemblies unrelated to SqlClient. Changes: - Probe AppContext.BaseDirectory instead of Environment.CurrentDirectory. - Subscribe the resolving handler only for the duration of the Type.GetType call in LoadType, and remove it in a finally block. - Skip type resolution entirely when no retryLogicType is configured. retryLogicType is optional while retryMethod is required, so configurations selecting a built-in retry method previously still ran the custom type resolution path. Together these mean the handler is never installed unless the application explicitly configured a custom retry logic type, and is gone again as soon as that type has been resolved. Only .NET is affected; the .NET Framework code path does not use AssemblyLoadContext. Refs #2214, #2134 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e8018b20-0e43-4de9-93dd-c8080a81a801
There was a problem hiding this comment.
Pull request overview
This PR fixes SqlConfigurableRetryLogicLoader’s .NET AssemblyLoadContext.Default.Resolving usage so it only applies to explicit opt-in callers (custom retry-logic type resolution) and no longer probes the process working directory.
Changes:
- Switch configurable retry-logic assembly probing from
Environment.CurrentDirectorytoAppContext.BaseDirectory. - Scope
AssemblyLoadContext.Default.Resolvingsubscription to the duration of theType.GetType(...)call (subscribe +finallyunsubscribe). - Skip custom type resolution entirely when
retryLogicTypeis not configured (use built-in factory directly). - Add unit + functional regression tests validating the handler is not left attached and that current-directory probing is not enabled.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Reliability/SqlConfigurableRetryLogicLoader.cs | Scopes the resolving handler to opt-in type resolution and switches probing to AppContext.BaseDirectory. |
| src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SqlConfigurableRetryLogicLoaderTest.cs | New unit tests validating no SqlConfigurableRetryLogicLoader resolving delegates remain attached. |
| src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConfigurableRetryLogicTest.cs | Adds functional regression test ensuring retry-logic initialization does not enable current-directory assembly probing. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
benrr101
left a comment
There was a problem hiding this comment.
Since it's still a draft, leaving a comment
Replaces the internals-based assertions in the configurable retry logic regression tests with a behavioural probe, and restores the UTF-8 BOM that was dropped from the functional test file. The unit test previously read AssemblyLoadContext's private _resolving field to check whether a handler was still subscribed. That reflects into runtime internals we do not own, so the value cannot simply be exposed internally as review suggested. The functional test took a different but also problematic approach, mutating Environment.CurrentDirectory, which is process-wide state and unsafe under parallel test execution. Both now plant a file that is not a valid assembly in the loader's probing directory (AppContext.BaseDirectory) under a name no other component could request, then assert that Assembly.Load reports it as not found. A subscribed handler would locate that file and surface BadImageFormatException instead, so the assertion discriminates cleanly while observing only public behaviour and touching no shared process state. Verified by temporarily reintroducing the unconditional subscription: all four unit tests and the functional test fail, and pass again once removed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e8018b20-0e43-4de9-93dd-c8080a81a801
paulmedynski
left a comment
There was a problem hiding this comment.
These changes don't remove the attack surface - they narrow it. Can we do better?
Also, we're missing tests that prove we add and then remove Default_Resolving from the default ALC under all of the code paths, and that we don't add it at all in some cases.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #4547 +/- ##
==========================================
- Coverage 64.75% 63.76% -1.00%
==========================================
Files 288 284 -4
Lines 44418 67920 +23502
==========================================
+ Hits 28763 43307 +14544
- Misses 15655 24613 +8958
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
The existing tests only covered the code paths where the assembly probing handler is never subscribed. The path that legitimately subscribes it, a configured custom retry logic type that actually resolves, was untested, so nothing verified that the handler is removed again afterwards. Add a test that resolves a retry logic factory out of the loader's probing directory and asserts that no probing handler remains subscribed once the loader has been constructed. An invocation counter on the factory confirms the configured type really was resolved and used, rather than the loader silently falling back to the built-in factory. Verified the test is sensitive to both behaviours it covers: pointing the loader's probing directory elsewhere makes it fail, and restoring the unconditional handler subscription makes it fail. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e8018b20-0e43-4de9-93dd-c8080a81a801
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConfigurableRetryLogicTest.cs:127
- Cleanup is described as best-effort, but File.Delete can throw exceptions other than IOException (e.g., UnauthorizedAccessException). To keep this regression test from failing due to cleanup issues in restrictive environments, catch a broader exception (or explicitly include UnauthorizedAccessException) in the cleanup block.
catch (IOException)
File.Delete can fail with UnauthorizedAccessException as well as IOException. Catching only the latter meant a cleanup failure could surface as a test failure that had nothing to do with the behaviour under test. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e8018b20-0e43-4de9-93dd-c8080a81a801
Summary
SqlConfigurableRetryLogicLoadersubscribed a handler toAssemblyLoadContext.Default.Resolvingin its constructor and never removed it.SqlConfigurableRetryLogicManagerbuilds that loader lazily on the defaultRetryLogicProviderpath, so simply readingSqlCommand.RetryLogicProviderorSqlConnection.RetryLogicProvider— which any app using retry logic does — installed a permanent, process-wide assembly resolution hook.Two consequences:
Environment.CurrentDirectory. The documented contract is the application's executing directory —AppContext.BaseDirectory— so probing the working directory was already a deviation. The working directory is ambient process state that any code in the process can change and bears no relationship to where the application's binaries live, so assemblies could be resolved from an unintended location.Only .NET is affected — the .NET Framework code path does not use
AssemblyLoadContext.Scope
This is the mitigation intended for the hotfix branches: 7.1.0, backported to 7.0 and 6.1 so the issue is resolved on all production branches. It deliberately does not change the underlying design of how retry logic assemblies are loaded.
The two larger design questions raised in review — loading configured retry logic into a dedicated
AssemblyLoadContext, and replacing type-name resolution with an explicit registration API — are tracked separately in #4623 for 8.0.Changes
AppContext.BaseDirectoryinstead ofEnvironment.CurrentDirectory.retryLogicTypeis configured, and only for the duration of resolving and constructing that provider.retryLogicTypeis configured.On (3): in
AppConfigManager,retryLogicTypeis optional whileretryMethodisIsRequired = true. So a config that selected a built-in retry method still went through the custom-type resolution path and installed the handler. It now short-circuits straight toSqlConfigurableRetryFactory.On (2): the handler spans both
LoadTypeandCreateInstance.LoadTypeonly produces aType— the configured type's constructor and retry method run insideCreateInstance, and a provider that touches its own private dependencies while being constructed needs the handler installed at that point.Net effect: the handler is never installed unless the application explicitly configured a custom retry logic type, and it is removed again once that provider has been constructed.
Compatibility
Three behavioural changes, in decreasing order of legitimacy:
deps.json, or register a resolving handler in the application.AppContext.BaseDirectory.retryLogicTypeto be configured at all — but no legitimate reliance.We intentionally do not keep the process-wide handler installed, because the driver should not alter assembly resolution for the whole application on behalf of configurable retry logic. The dedicated
AssemblyLoadContexttracked in #4623 closes case 1 properly.Tests
SqlConfigurableRetryLogicLoaderTest(UnitTests — needsInternalsVisibleTo, which FunctionalTests does not have). Covers: no configuration, config withoutretryLogicType, whitespace-onlyretryLogicType, and an unresolvableretryLogicType— asserting in each case that no handler declared by the loader remains attached toAssemblyLoadContext.Default.Resolving.RetryLogicTypeResolution_KeepsAssemblyProbingEnabledWhileProviderIsConstructed. Plants a retry factory whose constructor asserts, from insideCreateInstance, that the handler is installed, and asserts it is gone once resolution returns — so both the add and the remove are observed, at the point in the sequence that matters.AssemblyResolutionSubscription_DisposeRemovesAssemblyProbingHandler. Directly verifies that disposing the scoped subscription removes its handler.Constructor_WithResolvableRetryLogicType_DoesNotLeaveAssemblyProbingEnabled. Resolves a retry factory through the probing directory and confirms via an invocation counter that it really was used, so it exercises successful resolution rather than a silent fallback — then asserts no handler remains.RetryLogicProviderDoesNotLeaveAssemblyProbingEnabled(FunctionalTests). Plants a non-assembly file named<guid>.dllinAppContext.BaseDirectoryand assertsAssembly.LoadreportsFileNotFoundExceptionrather thanBadImageFormatException. No process-wide state is modified.The directory and handler-leak tests were confirmed to fail against the pre-fix code and pass after.
Validated on
net462,net8.0,net9.0, andnet10.0.Checklist
internal), so noref/updates requiredSuggested release note entry
Related
Refs #2214, #2134
Follow-up design work tracked in #4623