Fix | Scope configurable retry logic assembly resolution to opt-in callers - #4547
Fix | Scope configurable retry logic assembly resolution to opt-in callers#4547priyankatiwari08 wants to merge 8 commits into
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
Addresses review feedback that scoping the assembly resolving handler to type resolution alone could break existing consumers whose configured retryLogicType has private dependencies. The handler is now subscribed before LoadType and removed only after CreateInstance has run the configured type's constructor and invoked its retry method, so dependency loads triggered during construction are still resolved. Adds Switch.Microsoft.Data.SqlClient.UseLegacyRetryLogicAssemblyResolution as an escape hatch that restores the process-lifetime handler. The switch restores lifetime only; the probing directory remains AppContext.BaseDirectory, so it cannot re-enable the binary planting vector. 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.
🟡 Changes recommended
A few small correctness/robustness issues were found in the changed regions (whitespace-only config handling, a trace-message typo, and functional-test cleanup resilience).
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConfigurableRetryLogicTest.cs:125
- The test’s best-effort cleanup only catches IOException. File.Delete can also throw UnauthorizedAccessException (e.g., read-only output directory or restricted permissions), which would make the test fail for cleanup rather than behavior under test.
- Files reviewed: 7/7 changed files
- Comments generated: 2
- Review effort level: Lite
A whitespace value has no type to resolve, so it previously installed the resolving handler, attempted resolution and then fell back to the built-in factory. Skipping the subscription reaches the same provider without changing assembly resolution behavior on the application's behalf. 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.
🟡 Changes recommended
The PR description’s stated handler lifetime does not match the implementation (it remains installed through provider construction), and the description should be updated to avoid misleading future readers.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 7/7 changed files
- Comments generated: 1
- Review effort level: Lite
paulmedynski
left a comment
There was a problem hiding this comment.
Restricting probing to the app directory is a breaking change regardless of the new app context switch. Do we need to update our API docs to explain this?
The switch restored the process-lifetime assembly resolving handler for the one case that scoping cannot cover: a custom retry logic provider whose private dependency is first touched after the provider has been constructed. Shipping a supported way to permanently reinstate a process-wide handler on AssemblyLoadContext.Default works against the point of the change. The driver should not be altering assembly resolution for the whole application on behalf of configurable retry logic, and an affected provider has a simple fix of its own: reference the dependency normally so it lands in deps.json, or register a resolving handler in the application. The handler is now always subscribed only while a configured provider is being resolved and constructed, and only when a custom retry logic type has been configured. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e8018b20-0e43-4de9-93dd-c8080a81a801
|
@paulmedynski two updates following our discussion with the team: Handler lifetime: We will not provide a compatibility path that restores the process-wide handler. #4623 tracks the proper long-term solution. Docs: The probing directory was never documented as a contract, so no documentation change is needed. The release note already calls out the new base-directory behavior. Ready for another look. |
There was a problem hiding this comment.
🟡 Changes recommended
The resolving handler can remain subscribed even after falling back to the built-in factory (and a best-effort cleanup block should catch UnauthorizedAccessException), so a small refinement is needed to fully align with the scoping goal and keep tests robust.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConfigurableRetryLogicTest.cs:125
- The cleanup in this functional test is intended to be best-effort, but File.Delete can throw UnauthorizedAccessException (e.g., file marked read-only or permission issues). Catch it as well so cleanup failures don’t fail the test.
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Lite
Encapsulate the temporary AssemblyLoadContext resolving handler in an IDisposable subscription so cleanup is tied to a using scope. Remove the handler immediately when custom type resolution falls back to the built-in factory, and add direct unit coverage for disposal. 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.
🔵 Needs a closer look
The new functional test’s “best effort” cleanup should also swallow UnauthorizedAccessException to avoid failing due to cleanup/environment permissions rather than product behavior.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConfigurableRetryLogicTest.cs:125
- The cleanup in this new functional test is intended to be best-effort, but File.Delete can also throw UnauthorizedAccessException (e.g., permission/attribute issues). If that happens, the test would fail due to cleanup rather than validating the loader behavior. Consider swallowing UnauthorizedAccessException here as well (similar to the UnitTests helper).
- Files reviewed: 3/3 changed files
- Comments generated: 0 new
- Review effort level: Lite
paulmedynski
left a comment
There was a problem hiding this comment.
Changes look good.
Here are the possible ways these changes could break existing apps:
| Scenario | Likelihood | Legitimate | Mitigation |
|---|---|---|---|
| Private deps loaded after construction | Low | Yes | Add to deps.json or register handler in app |
| CurrentDirectory probing for custom types | Low | Questionable | Move assembly to AppContext.BaseDirectory |
| Accidental benefit from process-wide hook | Very Low | No | Register proper handler in app |
Are we OK with this?
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 working directory is ambient process state that any code in the process can change and that 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.
On the one case scoping does not cover
A provider that loads a private dependency after it has been constructed — at retry time — will no longer find that dependency through the driver's handler. We intentionally do not keep the process-wide handler installed for that case because the driver should not alter assembly resolution for the whole application on behalf of configurable retry logic.
An affected provider can reference the dependency normally so it lands in
deps.json, or register a resolving handler in the application. The dedicatedAssemblyLoadContexttracked in #4623 closes this case 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.RetryLogicProviderDoesNotEnableCurrentDirectoryAssemblyProbing(FunctionalTests). Writes a non-assembly file named<name>.dllinto a temp directory, makes it the working directory, and assertsAssembly.LoadreportsFileNotFoundExceptionrather thanBadImageFormatException.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