Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ charset = utf-8
indent_style = space
indent_size = 4
trim_trailing_whitespace = false
dotnet_diagnostic.CA1001.severity = warning
dotnet_diagnostic.CA2213.severity = warning
dotnet_diagnostic.CA1063.severity = warning
dotnet_diagnostic.CA1816.severity = warning
dotnet_diagnostic.CA2215.severity = warning

[*.razor]
indent_style = space
Expand Down
1 change: 1 addition & 0 deletions CODING_GUIDELINES.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
## C# specific
- avoid null references
- methods should be preceded with standard comment header (///)
- follow the [Memory and Lifetimes](documentation/developer-docs/csharp/memory-and-lifetimes.md) guidelines for event handlers, subscriptions, timers and disposal

## Conventional Commits
The Conventional Commits specification is a lightweight convention on top of commit messages. It provides an easy set of rules for creating an explicit commit history; which makes it easier to write automated tools on top of
Expand Down
1 change: 1 addition & 0 deletions documentation/developer-docs/csharp/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# c# development

- [Memory and Lifetimes](memory-and-lifetimes.md)
- [Visual Studio (Code) Debugging](visual-studio/vscode-debugging.md)

![Namespace overview](fworch-csharp-namespaces.png)
Expand Down
21 changes: 21 additions & 0 deletions documentation/developer-docs/csharp/memory-and-lifetimes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
## C# Memory & Resource Management
- every long-lived registration must have a matching cleanup in the same type
- every `+=` must have a matching `-=`
- every `Subscribe(...)` must have a matching `Unsubscribe(...)`
- every created `CancellationTokenSource` must be cancelled and disposed
- every created `Timer`, `PeriodicTimer`, subscription or background runner must have a defined shutdown path
- if asynchronous cleanup is required, use `IAsyncDisposable` instead of `IDisposable`
- `Dispose()` and `DisposeAsync()` must be idempotent and must not fail on repeated calls
- `async void` should only be used for real UI event handlers
- avoid anonymous event handlers for long-lived publishers; use named handlers or stored delegates so they can be unsubscribed
- objects created from DI must not be disposed manually if their lifetime is managed by the container
- do not resolve short-lived `IDisposable` services from the root container
- classes owning disposable fields must dispose them explicitly
- avoid storing user, request or component state in singleton services
- collections in long-lived services must be bounded or cleaned up regularly
- do not overwrite running subscriptions, timers or background tasks without disposing the previous instance first
- do not keep large object graphs, result lists or caches alive longer than necessary
- background loops must support cancellation and must stop cleanly during shutdown
- JS interop references (`IJSObjectReference`, `DotNetObjectReference`) must always be disposed explicitly
- every change involving events, subscriptions, timers, background services or component lifecycle must be reviewed for memory retention risks
- add unit tests for lifecycle-sensitive code: start, stop, dispose, repeated dispose, and replacement of active instances
28 changes: 24 additions & 4 deletions roles/lib/files/FWO.Api.Client/APIConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ public abstract class ApiConnection : IDisposable
{
private bool disposed = false;

protected bool IsDisposed => disposed;

public event EventHandler<string>? OnAuthHeaderChanged;
public event EventHandler<string>? OnExecutionModeChanged;

Expand Down Expand Up @@ -112,20 +114,38 @@ public async Task<TResult> RunWithBestRole<TResult>(System.Security.Claims.Claim
public abstract GraphQlApiSubscription<SubscriptionResponseType> GetSubscription<SubscriptionResponseType>(Action<Exception> exceptionHandler,
GraphQlApiSubscription<SubscriptionResponseType>.SubscriptionUpdate subscriptionUpdateHandler, string subscription, object? variables = null, string? operationName = null);

protected abstract void Dispose(bool disposing);
protected virtual void Dispose(bool disposing)
{
if (disposed)
{
return;
}

if (disposing)
{
foreach (ApiSubscription subscription in subscriptions)
{
subscription.Dispose();
}

subscriptions.Clear();
OnAuthHeaderChanged = null;
OnExecutionModeChanged = null;
}

disposed = true;
}

public abstract void DisposeSubscriptions<T>();

~ApiConnection()
{
if (disposed) return;
Dispose(false);
}

public void Dispose()
{
if (disposed) return;
Dispose(true);
disposed = true;
GC.SuppressFinalize(this);
}
}
Expand Down
12 changes: 9 additions & 3 deletions roles/lib/files/FWO.Api.Client/ApiSubscription.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,19 @@ public abstract class ApiSubscription : IDisposable

internal abstract ApiSubscription Recreate(GraphQLHttpClient graphQlClient);

protected abstract void Dispose(bool disposing);
protected virtual void Dispose(bool disposing)
{
if (_disposed)
{
return;
}

_disposed = true;
}

public void Dispose()
{
if (_disposed) return;
Dispose(true);
_disposed = true;
GC.SuppressFinalize(this);
}
}
Expand Down
10 changes: 3 additions & 7 deletions roles/lib/files/FWO.Api.Client/GraphQlApiConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -817,18 +817,14 @@ protected override void Dispose(bool disposing)
{
if (disposing)
{
foreach (ApiSubscription subscription in subscriptions)
{
subscription.Dispose();
}

subscriptions.Clear();

graphQlClient?.Dispose();
graphQlClient = null;
graphQlSubscriptionClient?.Dispose();
graphQlSubscriptionClient = null;
_reconnectLock.Dispose();
}

base.Dispose(disposing);
}

public override void DisposeSubscriptions<T>()
Expand Down
14 changes: 12 additions & 2 deletions roles/lib/files/FWO.Api.Client/GraphQlApiSubscription.cs
Original file line number Diff line number Diff line change
Expand Up @@ -162,16 +162,26 @@ internal override ApiSubscription Recreate(GraphQLHttpClient graphQlClient)

protected override void Dispose(bool disposing)
{
if (!disposing) return;
if (!disposing)
{
return;
}

lock (_lock)
{
if (_disposed) return;
if (_disposed)
{
return;
}

_disposed = true;
_subscription?.Dispose();
_subscription = null;
_subscriptionStream = null;
OnUpdate = null;
}

base.Dispose(disposing);
}
}
}
31 changes: 30 additions & 1 deletion roles/lib/files/FWO.Report/ReportCompliance.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@

namespace FWO.Report
{
public class ReportCompliance : ReportBase
public class ReportCompliance : ReportBase, IDisposable
{
private bool _disposed;

#region Properties

Expand All @@ -29,6 +30,7 @@ public class ReportCompliance : ReportBase
protected virtual string InternalQuery => RuleQueries.getRulesWithCurrentViolationsByChunk;
protected DebugConfig DebugConfig;
protected readonly GlobalConfig GlobalConfig;
private readonly bool _ownsGlobalConfig;

#endregion

Expand Down Expand Up @@ -56,10 +58,12 @@ public ReportCompliance(DynGraphqlQuery query, UserConfig userConfig, ReportType
if (userConfig.GlobalConfig != null)
{
GlobalConfig = userConfig.GlobalConfig;
_ownsGlobalConfig = false;
}
else
{
GlobalConfig = new();
_ownsGlobalConfig = true;
}

_maxDegreeOfParallelism = GlobalConfig.ComplianceCheckAvailableProcessors > Environment.ProcessorCount ? Environment.ProcessorCount : GlobalConfig.ComplianceCheckAvailableProcessors;
Expand Down Expand Up @@ -623,6 +627,31 @@ public override string ExportToHtml()
throw new NotImplementedException();
}

protected virtual void Dispose(bool disposing)
{
if (_disposed)
{
return;
}

if (disposing)
{
_semaphore.Dispose();
if (_ownsGlobalConfig)
{
GlobalConfig.Dispose();
}
}

_disposed = true;
}

public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}

#endregion
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using FWO.Api.Client;
using FWO.Api.Client;
using FWO.Api.Client.Queries;
using FWO.Data.Workflow;

Expand Down
24 changes: 23 additions & 1 deletion roles/lib/files/FWO.Services/Workflow/TicketCreator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@

namespace FWO.Services.Workflow
{
public class TicketCreator
public class TicketCreator : IDisposable
{
private readonly WfHandler wfHandler;
private readonly UserConfig userConfig;
private readonly ApiConnection apiConnection;
private bool disposed;
private int stateId;
private string ticketTitle = "";
private string ticketReason = "";
Expand Down Expand Up @@ -316,5 +317,26 @@ private static void LogMessage(Exception? exception = null, string title = "", s
Log.WriteError(title, message, exception);
}
}

protected virtual void Dispose(bool disposing)
{
if (disposed)
{
return;
}

if (disposing)
{
wfHandler.Dispose();
}

disposed = true;
}

public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
}
}
2 changes: 1 addition & 1 deletion roles/lib/files/FWO.Services/Workflow/WfDbAccess.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using FWO.Data;
using FWO.Data;
using FWO.Data.Workflow;
using FWO.Config.Api;
using FWO.Api.Client;
Expand Down
29 changes: 28 additions & 1 deletion roles/lib/files/FWO.Services/Workflow/WfHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,11 @@ public enum ObjAction
displayPathAnalysis
}

public partial class WfHandler
public partial class WfHandler : IDisposable
{
private bool disposed;
private readonly bool ownsUserConfig;

public List<WfTicket> TicketList { get; set; } = [];
public WfTicket ActTicket { get; set; } = new();
public WfReqTask ActReqTask { get; set; } = new();
Expand Down Expand Up @@ -75,6 +78,7 @@ public partial class WfHandler
public WfHandler()
{
userConfig = new();
ownsUserConfig = true;
}

/// <summary>
Expand All @@ -91,6 +95,7 @@ public WfHandler(Action<Exception?, string, string, bool> displayMessageInUi, Us
MiddlewareClient = middlewareClient;
RequestedRulePolicyChecker = requestedRulePolicyChecker;
AuthUser = authUser;
ownsUserConfig = false;
}

/// <summary>
Expand All @@ -108,6 +113,7 @@ public WfHandler(UserConfig userConfig, ApiConnection apiConnection, WorkflowPha
RequestedRulePolicyChecker = requestedRulePolicyChecker;
WorkflowRecipientResolver = workflowRecipientResolver;
usedInMwServer = true;
ownsUserConfig = false;
}

public async Task<bool> Init(bool fetchData = false, List<int>? ownerIds = null, bool allStates = false, bool fullTickets = false)
Expand Down Expand Up @@ -282,5 +288,26 @@ public void DisplayMessage(Exception? exception = null, string title = "", strin
{
DisplayMessageInUi(exception, title, message, errorFlag);
}

protected virtual void Dispose(bool disposing)
{
if (disposed)
{
return;
}

if (disposing && ownsUserConfig)
{
userConfig.Dispose();
}

disposed = true;
}

public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -907,6 +907,7 @@ protected virtual void Dispose(bool disposing)
{
if (disposing)
{
wfHandler.Dispose();
// UserConfig is caller-owned and can be reused across multiple request handling steps.
// Disposing it here breaks subsequent handler instances that receive the same config.
}
Expand Down
Loading
Loading