Summary
AppServiceEnvironmentVariableMonitor uses a plain Dictionary<string, string?> that is mutated inside ExecuteAsync without any lock or concurrent collection. Under certain conditions (described below), this might cause:
System.InvalidOperationException: Operations that change non-concurrent collections must have exclusive access.
at System.Collections.Generic.Dictionary`2.TryInsert(...)
at Microsoft.Azure.Functions.Worker.ApplicationInsights.Initializers.AppServiceEnvironmentVariableMonitor+<ExecuteAsync>d__9.MoveNext()
Because BackgroundService defaults to BackgroundServiceExceptionBehavior.StopHost, this unhandled exception would tear down the entire Function App host.
Affected file
src/DotNetWorker.ApplicationInsights/Initializers/AppServiceEnvironmentVariableMonitor.cs
Details
The class is registered as a singleton IHostedService:
services.AddSingleton<AppServiceEnvironmentVariableMonitor>();
services.AddSingleton<IHostedService>(p => p.GetRequiredService<AppServiceEnvironmentVariableMonitor>());
The _monitoredVariableCache field is declared as a non-thread-safe Dictionary:
private readonly Dictionary<string, string?> _monitoredVariableCache = new(StringComparer.OrdinalIgnoreCase);
The dictionary is both read and written inside ExecuteAsync without synchronization:
_monitoredVariableCache.TryGetValue(envVar, out string? cachedVal);
// ...
_monitoredVariableCache[envVar] = currentVal;
It is worth noting that the fields immediately below in the same class — _cancellationTokenSource and _changeToken — are correctly protected against concurrent access using Interlocked.Exchange:
var oldTokenSource = Interlocked.Exchange(ref _cancellationTokenSource, new CancellationTokenSource());
Interlocked.Exchange(ref _changeToken, new CancellationChangeToken(_cancellationTokenSource.Token));
The dictionary, however, has no equivalent protection.
The FunctionEnvironmentReloadRequest or something similar could, directly or indirectly, cause the hosting infrastructure to invoke StartAsync more than once on the same singleton instance, resulting in two concurrent ExecuteAsync loops writing to the same dictionary. We were unable to confirm this from the code alone and would appreciate any insight into whether there is another path that could cause concurrent access.
Impact
- The unhandled exception propagates out of
ExecuteAsync, triggering StopHost behavior.
- The host begins tearing down. The logging infrastructure is disposed early in this process, causing subsequent log attempts from application code to throw
AggregateException, masking the original root cause.
- In-flight HTTP requests time out or drop, producing server error spikes.
Suggested fix
Replace the plain Dictionary with ConcurrentDictionary<string, string?>:
// Before
private readonly Dictionary<string, string?> _monitoredVariableCache = new(StringComparer.OrdinalIgnoreCase);
// After
private readonly ConcurrentDictionary<string, string?> _monitoredVariableCache = new(StringComparer.OrdinalIgnoreCase);
The write (_monitoredVariableCache[envVar] = currentVal) is valid on ConcurrentDictionary via the indexer, so no other changes to the loop body would be required.
Workaround (until a fix is released)
Callers can prevent the host from being torn down by configuring HostOptions:
builder.Services.Configure<HostOptions>(options =>
{
options.BackgroundServiceExceptionBehavior = BackgroundServiceExceptionBehavior.Ignore;
});
Note: this suppresses the crash but does not fix the race condition. If the exception fires repeatedly, the monitor will silently stop functioning and environment variable changes will no longer propagate to the telemetry initializers.
Package version
Microsoft.Azure.Functions.Worker.ApplicationInsights observed on v2.50.0.
Summary
AppServiceEnvironmentVariableMonitoruses a plainDictionary<string, string?>that is mutated insideExecuteAsyncwithout any lock or concurrent collection. Under certain conditions (described below), this might cause:Because
BackgroundServicedefaults toBackgroundServiceExceptionBehavior.StopHost, this unhandled exception would tear down the entire Function App host.Affected file
src/DotNetWorker.ApplicationInsights/Initializers/AppServiceEnvironmentVariableMonitor.csDetails
The class is registered as a singleton
IHostedService:The
_monitoredVariableCachefield is declared as a non-thread-safeDictionary:The dictionary is both read and written inside
ExecuteAsyncwithout synchronization:It is worth noting that the fields immediately below in the same class —
_cancellationTokenSourceand_changeToken— are correctly protected against concurrent access usingInterlocked.Exchange:The dictionary, however, has no equivalent protection.
The
FunctionEnvironmentReloadRequestor something similar could, directly or indirectly, cause the hosting infrastructure to invokeStartAsyncmore than once on the same singleton instance, resulting in two concurrentExecuteAsyncloops writing to the same dictionary. We were unable to confirm this from the code alone and would appreciate any insight into whether there is another path that could cause concurrent access.Impact
ExecuteAsync, triggeringStopHostbehavior.AggregateException, masking the original root cause.Suggested fix
Replace the plain
DictionarywithConcurrentDictionary<string, string?>:The write (
_monitoredVariableCache[envVar] = currentVal) is valid onConcurrentDictionaryvia the indexer, so no other changes to the loop body would be required.Workaround (until a fix is released)
Callers can prevent the host from being torn down by configuring
HostOptions:Note: this suppresses the crash but does not fix the race condition. If the exception fires repeatedly, the monitor will silently stop functioning and environment variable changes will no longer propagate to the telemetry initializers.
Package version
Microsoft.Azure.Functions.Worker.ApplicationInsightsobserved on v2.50.0.