Skip to content

Commit b5eb2d8

Browse files
thomhurstclaude
andcommitted
fix: Improve pipeline context isolation and thread safety (#1417)
This PR addresses issue #1417 by implementing proper module-scoped isolation: **Breaking Changes:** - `IEnvironmentContext.ContentDirectory` and `WorkingDirectory` are now readonly (changed from `{ get; set; }` to `{ get; }`) **Improvements:** - Add AsyncLocal cleanup in ModuleRunner to prevent potential context leaks - Replace `List<T>` with `ConcurrentBag<T>` in ModuleLoggerContainer for lock-free thread-safe operations during high-concurrency module execution - Add documentation explaining PipelineContext scoped lifetime and logger caching safety **Files Changed:** - ModuleRunner.cs: Added try/finally to clear AsyncLocal logger context - IEnvironmentContext.cs: Made ContentDirectory/WorkingDirectory readonly with docs - EnvironmentContext.cs: Implementation matches interface changes - PipelineContext.cs: Added XML documentation for scoped DI behavior - ModuleLoggerContainer.cs: Replaced List+locks with ConcurrentBag+Interlocked Closes #1417 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent a888b4f commit b5eb2d8

5 files changed

Lines changed: 73 additions & 28 deletions

File tree

src/ModularPipelines/Context/EnvironmentContext.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@ public EnvironmentContext(IHostEnvironment hostEnvironment,
2727

2828
public Folder AppDomainDirectory { get; } = AppDomain.CurrentDomain.BaseDirectory!;
2929

30-
public Folder ContentDirectory { get; set; }
30+
public Folder ContentDirectory { get; }
3131

32-
public Folder WorkingDirectory { get; set; } = Environment.CurrentDirectory!;
32+
public Folder WorkingDirectory { get; } = Environment.CurrentDirectory!;
3333

3434
public IEnvironmentVariables EnvironmentVariables { get; }
3535

src/ModularPipelines/Context/IEnvironmentContext.cs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,20 @@ public interface IEnvironmentContext
3030
public Folder AppDomainDirectory { get; }
3131

3232
/// <inheritdoc cref="IHostEnvironment.ContentRootPath"/>
33-
public Folder ContentDirectory { get; set; }
33+
/// <remarks>
34+
/// This property is immutable after pipeline initialization.
35+
/// If you need to change the working directory for command execution,
36+
/// use command options or <see cref="System.Environment.CurrentDirectory"/> directly.
37+
/// </remarks>
38+
public Folder ContentDirectory { get; }
3439

3540
/// <inheritdoc cref="Environment.CurrentDirectory"/>
36-
public Folder WorkingDirectory { get; set; }
41+
/// <remarks>
42+
/// This property captures the working directory at pipeline initialization time and is immutable.
43+
/// If you need to change the working directory for command execution,
44+
/// use command options or <see cref="System.Environment.CurrentDirectory"/> directly.
45+
/// </remarks>
46+
public Folder WorkingDirectory { get; }
3747

3848
/// <inheritdoc cref="Environment.GetFolderPath(System.Environment.SpecialFolder)"/>
3949
public Folder? GetFolder(Environment.SpecialFolder specialFolder);

src/ModularPipelines/Context/PipelineContext.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,31 @@
1010

1111
namespace ModularPipelines.Context;
1212

13+
/// <summary>
14+
/// Provides context and services for module execution.
15+
/// </summary>
16+
/// <remarks>
17+
/// This class is registered as Scoped in the DI container, meaning each module execution
18+
/// gets its own instance. This ensures proper isolation between concurrent module executions.
19+
/// </remarks>
1320
internal class PipelineContext : IPipelineContext
1421
{
1522
private readonly IModuleLoggerProvider _moduleLoggerProvider;
23+
24+
/// <summary>
25+
/// Cached logger instance for this context.
26+
/// </summary>
27+
/// <remarks>
28+
/// This caching is safe because PipelineContext is Scoped (one instance per module execution).
29+
/// Each module gets a fresh PipelineContext, so the cached logger is inherently module-scoped.
30+
/// </remarks>
1631
private IModuleLogger? _logger;
1732

33+
/// <inheritdoc />
34+
/// <remarks>
35+
/// Logger is lazily initialized and cached for the lifetime of this context.
36+
/// Since PipelineContext is Scoped, this provides a module-specific logger.
37+
/// </remarks>
1838
public IModuleLogger Logger => _logger ??= _moduleLoggerProvider.GetLogger();
1939

2040
public IServiceProvider ServiceProvider { get; }

src/ModularPipelines/Engine/Execution/ModuleRunner.cs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,9 +147,31 @@ private async Task ExecuteModuleWithPipeline(ModuleState moduleState, IServicePr
147147
var logger = GetOrCreateLogger(moduleType, scopedServiceProvider);
148148
var moduleContext = new ModuleContext(pipelineContext, module, executionContext, logger);
149149

150-
// Set up logging
150+
// Set up logging - use try/finally to ensure cleanup of AsyncLocal context
151151
ModuleLogger.Values.Value = logger;
152152

153+
try
154+
{
155+
await ExecuteModuleLifecycle(moduleState, scopedServiceProvider, pipelineContext, executionContext, moduleContext, cancellationToken).ConfigureAwait(false);
156+
}
157+
finally
158+
{
159+
// Clear AsyncLocal to prevent potential leaks in edge cases (thread pool reuse, long-running contexts)
160+
ModuleLogger.Values.Value = null;
161+
}
162+
}
163+
164+
private async Task ExecuteModuleLifecycle(
165+
ModuleState moduleState,
166+
IServiceProvider scopedServiceProvider,
167+
IPipelineContext pipelineContext,
168+
ModuleExecutionContext executionContext,
169+
IModuleContext moduleContext,
170+
CancellationToken cancellationToken)
171+
{
172+
var module = moduleState.Module;
173+
var moduleType = moduleState.ModuleType;
174+
153175
// Before module hooks
154176
await _pipelineSetupExecutor.OnBeforeModuleStartAsync(moduleState).ConfigureAwait(false);
155177

Lines changed: 16 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
using System.Collections.Concurrent;
2+
13
namespace ModularPipelines.Logging;
24

35
/// <summary>
@@ -8,29 +10,24 @@ namespace ModularPipelines.Logging;
810
/// This container tracks all module loggers created during pipeline execution
911
/// and ensures they are properly flushed and disposed in the correct order
1012
/// (ordered by last log written time) to maintain logical output ordering.
13+
/// Uses ConcurrentBag for lock-free thread-safe operations during high-concurrency
14+
/// module execution.
1115
/// </remarks>
1216
internal class ModuleLoggerContainer : IModuleLoggerContainer, IDisposable
1317
{
14-
private readonly List<ModuleLogger> _loggers = new();
15-
private bool _isDisposed;
18+
private readonly ConcurrentBag<ModuleLogger> _loggers = new();
19+
private int _isDisposed;
1620

1721
public void FlushAndDisposeAll()
1822
{
19-
lock (_loggers)
23+
// Use Interlocked to ensure only one thread disposes
24+
if (Interlocked.Exchange(ref _isDisposed, 1) == 1)
2025
{
21-
if (_isDisposed)
22-
{
23-
return;
24-
}
25-
26-
_isDisposed = true;
26+
return;
2727
}
2828

29-
List<ModuleLogger> snapshot;
30-
lock (_loggers)
31-
{
32-
snapshot = _loggers.ToList();
33-
}
29+
// ToArray() is thread-safe on ConcurrentBag
30+
var snapshot = _loggers.ToArray();
3431

3532
foreach (var logger in snapshot.Where(x => x != null).OrderBy(x => x.LastLogWritten))
3633
{
@@ -40,22 +37,18 @@ public void FlushAndDisposeAll()
4037

4138
public IModuleLogger? GetLogger(Type type)
4239
{
43-
lock (_loggers)
44-
{
45-
return _loggers.FirstOrDefault(logger => logger.GetType() == type);
46-
}
40+
// ToArray() creates a snapshot for safe enumeration
41+
return _loggers.ToArray().FirstOrDefault(logger => logger.GetType() == type);
4742
}
4843

4944
public void AddLogger(ModuleLogger logger)
5045
{
51-
lock (_loggers)
52-
{
53-
_loggers.Add(logger);
54-
}
46+
// ConcurrentBag.Add is lock-free and thread-safe
47+
_loggers.Add(logger);
5548
}
5649

5750
public void Dispose()
5851
{
5952
FlushAndDisposeAll();
6053
}
61-
}
54+
}

0 commit comments

Comments
 (0)