Skip to content

Commit 5c8f429

Browse files
committed
Harden DI and STJ edge cases and coverage
1 parent b1c5040 commit 5c8f429

34 files changed

Lines changed: 1493 additions & 415 deletions

.github/workflows/build-linux.yml

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,15 @@ jobs:
2525
echo "MINVERVERSIONOVERRIDE=$version" >> $GITHUB_ENV
2626
- name: Build
2727
run: dotnet build --configuration Release Exceptionless.Net.NonWindows.slnx
28-
- name: Run Tests
29-
run: dotnet test --configuration Release --no-build Exceptionless.Net.NonWindows.slnx
28+
- name: Run Tests with Coverage
29+
run: dotnet test --configuration Release --no-build --collect:"XPlat Code Coverage" --results-directory artifacts/test-results Exceptionless.Net.NonWindows.slnx
30+
- name: Upload Coverage
31+
if: always()
32+
uses: actions/upload-artifact@v7
33+
with:
34+
name: coverage-linux
35+
path: artifacts/test-results/**/coverage.cobertura.xml
36+
if-no-files-found: error
3037
- name: Pack Core for NativeAOT Test
3138
run: dotnet pack --configuration Release --no-build --output artifacts/aot-package -p:SolutionDir="$GITHUB_WORKSPACE/" src/Exceptionless/Exceptionless.csproj
3239
- name: NativeAOT Smoke Test

docs/plans/2026-07-12-modern-di-stj-plan.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,13 @@
4040
## Execution results
4141

4242
- Baseline: 300 core tests and 10 MessagePack tests passed; 18 existing tests were skipped.
43-
- Final non-Windows suite: 324 core tests and 11 MessagePack tests passed; 0 failed; 18 tests were skipped.
44-
- Focused DI coverage now includes singleton and transient compatibility, constructor injection, isolated containers, late replacement, `IServiceCollection` overrides, open generics, container disposal, external instance ownership, and host-provider disposal of the default client.
45-
- Serializer coverage retains exact wire-format assertions and adds regressions for exclusions inside collections and settings, depth limits, reference loops in string and stream paths, public fields, the legacy ignore attribute, `DataDictionary` interface mutation, structured/raw values, settings coercion, and MessagePack round-trips.
43+
- Final non-Windows suite: 370 core tests and 12 MessagePack tests passed; 0 failed; 18 existing tests were skipped.
44+
- Focused DI coverage now includes singleton and transient compatibility, constructor injection, isolated containers, coherent provider snapshots after late replacement, `IServiceCollection` overrides and enumeration, open generics, exact disposal of every provider snapshot, external instance ownership, per-host client isolation, and host-versus-caller disposal ownership.
45+
- Serializer coverage retains exact wire-format assertions and adds regressions for exclusions inside collections and settings, named floating-point values, declared-type depth limits, cycles, malformed getters, predicates, converters, and iterators, STJ custom converters/number handling/extension data, stream/string parity, public fields, the legacy ignore attribute, every `DataDictionary` interface path, structured/raw values, settings and POST-data coercion, and MessagePack raw-object round-trips.
46+
- Linux CI now collects and uploads Cobertura coverage for both test assemblies. The final core report is 74% line / 50% branch overall and 88.1% of executable production lines changed by the PR are covered; the migration-critical components are substantially higher: `JsonValueWriter` 97% line / 93% branch, `DataDictionaryConverter` 93% / 81%, `JsonElementValueConverter` 96% / 96%, `SettingsDictionaryConverter` 89% / 83%, `DataDictionary` 99% / 92%, and the Microsoft DI resolver 84% / 61%.
4647
- `net462` core and `net472` test assemblies build with 0 warnings and 0 errors when Windows targeting is forced on macOS. Runtime execution of the .NET Framework tests still belongs in Windows CI.
4748
- The non-Windows solution packs successfully. A Windows-shaped core package containing both `net462` and `netstandard2.0` assets also packs successfully with explicit `SolutionDir`.
48-
- `net8.0` and `net10.0` core builds pass with 0 AOT/trim/single-file warnings. Real NativeAOT executables now cover the packed NuGet assets, default Microsoft DI/default serialization, source-generated custom properties and fields, storage round-trips, queued submission, identifiable exception frames, and Generic Host startup/shutdown on both target frameworks. Linux CI publishes and executes the package and hosting smoke applications with linker warnings treated as errors.
49+
- `net8.0` and `net10.0` core builds pass with 0 AOT/trim/single-file warnings. Real NativeAOT executables now cover the packed NuGet assets, default Microsoft DI/default serialization, source-generated custom properties and fields, event-batch serialization, storage round-trips, queued submission, identifiable exception frames across rethrow boundaries, and Generic Host startup/shutdown on both target frameworks. The `net10.0` smoke found and fixed an offset-only runtime frame with no usable identity; those empty frames are now omitted. Linux CI publishes and executes the package and hosting smoke applications with linker warnings treated as errors.
4950
- SDK ApiCompat is clean from the previous `netstandard2.0` asset to both the new `netstandard2.0` and `net8.0` assets. Compatibility shims retain the legacy ignore attribute, serializer delegates, and `AssemblyHelper.GetTypes` API.
5051
- Release builds now actually treat warnings as errors; the previous `WarningsAsErrors=true` property was a warning-code list rather than the boolean build gate.
5152

@@ -54,15 +55,17 @@
5455
- The non-Windows core package grows from 596,508 bytes to 686,321 bytes (about 15%) because it contains `netstandard2.0`, `net8.0`, and `net10.0` assets instead of one asset. Its uncompressed contents shrink from 2,261,763 bytes to 1,792,690 bytes (about 21%), and the `netstandard2.0` assembly shrinks from 998,912 bytes to 348,160 bytes (about 65%).
5556
- Every package inherits `Microsoft.Extensions.DependencyInjection` 8 through the core package. The `netstandard2.0`, `net462`, and `net8.0` dependency groups also carry System.Text.Json 10, which is the largest deployment and binding-risk change for .NET Framework applications.
5657
- The existing `IDependencyResolver` and public setup APIs remain compatible. Microsoft DI backs the client-owned resolver and exposes an `IServiceCollection` customization seam; registrations from an application's root Generic Host provider are not automatically imported into the client's isolated provider.
58+
- Configuration-based hosting overloads now create one client owned by each host provider instead of mutating the process-wide `ExceptionlessClient.Default`. Explicit client overloads remain caller-owned. Configuration-based logging providers likewise own an isolated client, while providers given an existing client only flush it and do not dispose it. This removes cross-host configuration/disposal interference but changes code that intentionally depended on those overloads mutating the global default.
5759
- Custom payloads now follow System.Text.Json contracts. Public fields and `ExceptionlessIgnoreAttribute` remain supported, but consumers relying on Newtonsoft-specific contracts such as `DataContract`/`DataMember` behavior should migrate to STJ attributes and source-generated metadata.
60+
- Filtered event serialization remains streaming for normal objects, dictionaries, and collections. Converter-backed leaf values are preflighted before their property name is written so a failing converter cannot corrupt the containing event; this adds a small per-leaf allocation only for custom converter-backed values.
5861
- The `net8.0` and later core assets use normal runtime stack traces instead of the legacy IL/PDB demystifier. NativeAOT frames retain parsed method identity, but modern JIT consumers also lose enhanced demystification.
5962
- Although ApiCompat is clean, the serializer behavior, transitive dependency graph, target assets, and modern-stack-trace change are substantial enough to recommend a 7.0 release rather than shipping the work as 6.2.1.
6063

6164
## Remaining release gates
6265

6366
- Run the net462/net472 tests and package-load checks on a real Windows runner; macOS cross-compilation passes but cannot prove .NET Framework runtime binding behavior.
6467
- Require the Linux, macOS, and Windows build jobs in branch protection. The repository currently requires only `license/cla`, so the new regression gates are not merge-enforced yet.
65-
- Push/open the branch only with awareness that non-PR Windows pushes publish prerelease packages to GitHub Packages and Feedz.
68+
- Keep draft PR #368 open until the real Windows runtime checks are green. Be aware that additional non-PR Windows pushes publish prerelease packages to GitHub Packages and Feedz.
6669

6770
## Historical NativeAOT comparison
6871

src/Exceptionless/Dependency/DefaultDependencyResolver.cs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ namespace Exceptionless.Dependency {
99
public sealed class DefaultDependencyResolver : IDependencyResolver {
1010
private readonly object _lock = new object();
1111
private readonly IServiceCollection _services;
12-
private readonly List<ServiceProvider> _providers = new List<ServiceProvider>();
12+
// Microsoft DI providers are immutable. Registrations made after resolution create a
13+
// new coherent provider snapshot; older snapshots stay alive so services already
14+
// returned to callers are not disposed underneath them.
15+
private readonly List<ServiceProvider> _providerSnapshots = new List<ServiceProvider>();
1316
private ServiceProvider _provider;
1417
private bool _disposed;
1518

@@ -136,10 +139,10 @@ public void Dispose() {
136139
return;
137140

138141
_disposed = true;
139-
foreach (var provider in _providers)
142+
foreach (var provider in _providerSnapshots)
140143
provider.Dispose();
141144

142-
_providers.Clear();
145+
_providerSnapshots.Clear();
143146
_provider = null;
144147
}
145148
}
@@ -149,7 +152,7 @@ private ServiceProvider GetProvider() {
149152
return _provider;
150153

151154
_provider = _services.BuildServiceProvider();
152-
_providers.Add(_provider);
155+
_providerSnapshots.Add(_provider);
153156
return _provider;
154157
}
155158

src/Exceptionless/ExceptionlessClient.cs

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -294,11 +294,6 @@ protected internal void OnSubmittedEvent(EventSubmittedEventArgs e) {
294294
}
295295

296296
void IDisposable.Dispose() {
297-
// The process-wide client can be registered as an externally owned singleton in a host.
298-
// A host service provider must not make ExceptionlessClient.Default unusable for the rest of the process.
299-
if (_defaultClient.IsValueCreated && ReferenceEquals(_defaultClient.Value, this))
300-
return;
301-
302297
Configuration.Changed -= OnConfigurationChanged;
303298
if (_queue.IsValueCreated)
304299
_queue.Value.EventsPosted -= OnQueueEventsPosted;

src/Exceptionless/Extensions/ExceptionlessConfigurationExtensions.cs

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -439,24 +439,25 @@ public static void ReadFromConfiguration(this ExceptionlessConfiguration config,
439439
if (!String.IsNullOrEmpty(storageSerializer)) {
440440
try {
441441
Type serializerType = null;
442+
bool canResolveSerializerType = true;
442443
#if NET8_0_OR_GREATER
443444
if (!RuntimeFeature.IsDynamicCodeSupported) {
444445
config.Resolver.GetLog().Error(typeof(ExceptionlessConfigurationExtensions), "StorageSerializer cannot be activated from a type name in NativeAOT. Register IStorageSerializer with IServiceCollection instead.");
446+
canResolveSerializerType = false;
445447
}
446-
else
447-
serializerType = ResolveConfiguredType(storageSerializer);
448-
#else
449-
serializerType = ResolveConfiguredType(storageSerializer);
450448
#endif
451-
if (serializerType != null) {
452-
if (!typeof(IStorageSerializer).GetTypeInfo().IsAssignableFrom(serializerType))
449+
if (canResolveSerializerType) {
450+
serializerType = ResolveConfiguredType(storageSerializer);
451+
if (serializerType == null)
452+
config.Resolver.GetLog().Error(typeof(ExceptionlessConfigurationExtensions), $"The storage serializer {storageSerializer} type could not be resolved.");
453+
else if (!typeof(IStorageSerializer).GetTypeInfo().IsAssignableFrom(serializerType))
453454
config.Resolver.GetLog().Error(typeof(ExceptionlessConfigurationExtensions), $"The storage serializer {storageSerializer} does not implement interface {typeof(IStorageSerializer)}.");
454455
else
455456
config.Resolver.Register(typeof(IStorageSerializer), serializerType);
456457
}
457458
}
458459
catch (Exception ex) {
459-
config.Resolver.GetLog().Error(typeof(ExceptionlessConfigurationExtensions), ex, $"The storage serializer {storageSerializer} type could not be resolved: ${ex.Message}");
460+
config.Resolver.GetLog().Error(typeof(ExceptionlessConfigurationExtensions), ex, $"The storage serializer {storageSerializer} type could not be resolved: {ex.Message}");
460461
}
461462
}
462463

src/Exceptionless/Extensions/ToErrorModelExtensions.cs

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,10 @@ private static void PopulateStackTrace(this Error error, Error root, Exception e
218218

219219
string[] rawStackFrames = (exception.StackTrace ?? String.Empty)
220220
.Split(new[] { Environment.NewLine }, StringSplitOptions.RemoveEmptyEntries);
221+
rawStackFrames = rawStackFrames
222+
.Select(line => line.Trim())
223+
.Where(line => line.StartsWith("at ", StringComparison.Ordinal))
224+
.ToArray();
221225

222226
for (int frameIndex = 0; frameIndex < frames.Length; frameIndex++) {
223227
StackFrame frame = frames[frameIndex];
@@ -253,6 +257,16 @@ private static void PopulateStackTrace(this Error error, Error root, Exception e
253257
if (String.IsNullOrEmpty(stackFrame.Name) && frameIndex < rawStackFrames.Length)
254258
stackFrame.PopulateMethodIdentity(rawStackFrames[frameIndex]);
255259

260+
// NativeAOT can expose runtime frames that have no method, source, or line
261+
// metadata and do not appear in Exception.StackTrace. An offset-only frame
262+
// cannot be displayed or grouped meaningfully, so omit it instead of
263+
// emitting an empty stack frame.
264+
if (String.IsNullOrEmpty(stackFrame.Name)
265+
&& String.IsNullOrEmpty(stackFrame.DeclaringType)
266+
&& String.IsNullOrEmpty(stackFrame.FileName)
267+
&& stackFrame.LineNumber == 0)
268+
continue;
269+
256270
error.StackTrace.Add(stackFrame);
257271
}
258272
}
@@ -265,14 +279,14 @@ private static void PopulateMethodIdentity(this Method method, string stackTrace
265279
if (identity.StartsWith("at ", StringComparison.Ordinal))
266280
identity = identity.Substring(3);
267281

268-
int argumentsStart = identity.IndexOf('(');
269-
if (argumentsStart >= 0)
270-
identity = identity.Substring(0, argumentsStart);
271-
272282
int locationStart = identity.IndexOf(" in ", StringComparison.Ordinal);
273283
if (locationStart >= 0)
274284
identity = identity.Substring(0, locationStart);
275285

286+
int argumentsStart = identity.IndexOf('(');
287+
if (argumentsStart >= 0)
288+
identity = identity.Substring(0, argumentsStart);
289+
276290
int methodSeparator = identity.LastIndexOf('.');
277291
if (methodSeparator < 0) {
278292
method.Name = identity;

src/Exceptionless/Models/Collections/DataDictionary.cs

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,20 @@ public class DataDictionary : Dictionary<string, object>, IDictionary<string, ob
99
public DataDictionary() : base(StringComparer.OrdinalIgnoreCase) {}
1010

1111
public DataDictionary(IEnumerable<KeyValuePair<string, object>> values) : base(StringComparer.OrdinalIgnoreCase) {
12-
foreach (var kvp in values)
13-
Add(kvp.Key, kvp.Value);
12+
if (values == null)
13+
throw new ArgumentNullException(nameof(values));
14+
15+
if (values is DataDictionary dataDictionary) {
16+
foreach (var entry in dataDictionary) {
17+
if (dataDictionary.IsRawJson(entry.Key, entry.Value))
18+
SetRawJson(entry.Key, (string)entry.Value);
19+
else
20+
Add(entry.Key, entry.Value);
21+
}
22+
} else {
23+
foreach (var entry in values)
24+
Add(entry.Key, entry.Value);
25+
}
1426
}
1527

1628
public new object this[string key] {
@@ -63,7 +75,7 @@ public string GetString(string name, string @default) {
6375
}
6476

6577
internal bool IsRawJson(string key, object value) {
66-
return !String.IsNullOrEmpty(key)
78+
return key != null
6779
&& value is string stringValue
6880
&& _rawJsonValues.TryGetValue(key, out string rawJson)
6981
&& ReferenceEquals(stringValue, rawJson);
@@ -72,12 +84,12 @@ internal bool IsRawJson(string key, object value) {
7284
internal void SetRawJson(string key, string value) {
7385
base[key] = value;
7486

75-
if (!String.IsNullOrEmpty(key))
87+
if (key != null)
7688
_rawJsonValues[key] = value;
7789
}
7890

7991
private void ClearRawJson(string key) {
80-
if (!String.IsNullOrEmpty(key))
92+
if (key != null)
8193
_rawJsonValues.Remove(key);
8294
}
8395

src/Exceptionless/Serializer/DataDictionaryConverter.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ public override DataDictionary Read(ref Utf8JsonReader reader, Type typeToConver
6565
}
6666
}
6767

68-
return dictionary;
68+
throw new JsonException("Unexpected end of JSON while reading a data dictionary.");
6969
}
7070

7171
public override void Write(Utf8JsonWriter writer, DataDictionary value, JsonSerializerOptions options) {

0 commit comments

Comments
 (0)