diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..acd3bc6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +# If this file is renamed, the incrementing run attempt number will be reset. + +name: CI + +on: + push: + branches: [ "dev", "main" ] + pull_request: + branches: [ "dev", "main" ] + +env: + CI_BUILD_NUMBER_BASE: ${{ github.run_number }} + CI_TARGET_BRANCH: ${{ github.head_ref || github.ref_name }} + +jobs: + build: + + # The build must run on Windows so that .NET Framework targets can be built and tested. + runs-on: windows-latest + + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + - name: Setup + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + - name: Compute build number + shell: bash + run: | + echo "CI_BUILD_NUMBER=$(($CI_BUILD_NUMBER_BASE+2300))" >> $GITHUB_ENV + - name: Build and Publish + env: + DOTNET_CLI_TELEMETRY_OPTOUT: true + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: pwsh + run: | + ./Build.ps1 diff --git a/Build.ps1 b/Build.ps1 index 06a36af..e798284 100644 --- a/Build.ps1 +++ b/Build.ps1 @@ -1,44 +1,79 @@ +Write-Output "build: Tool versions follow" + +dotnet --version +dotnet --list-sdks + Write-Output "build: Build started" Push-Location $PSScriptRoot +try { + if(Test-Path .\artifacts) { + Write-Output "build: Cleaning ./artifacts" + Remove-Item ./artifacts -Force -Recurse + } -if(Test-Path .\artifacts) { - Write-Output "build: Cleaning ./artifacts" - Remove-Item ./artifacts -Force -Recurse -} + & dotnet restore --no-cache -& dotnet restore --no-cache + $dbp = [Xml] (Get-Content .\Directory.Version.props) + $versionPrefix = $dbp.Project.PropertyGroup.VersionPrefix -$branch = @{ $true = $env:APPVEYOR_REPO_BRANCH; $false = $(git symbolic-ref --short -q HEAD) }[$NULL -ne $env:APPVEYOR_REPO_BRANCH]; -$revision = @{ $true = "{0:00000}" -f [convert]::ToInt32("0" + $env:APPVEYOR_BUILD_NUMBER, 10); $false = "local" }[$NULL -ne $env:APPVEYOR_BUILD_NUMBER]; -$suffix = @{ $true = ""; $false = "$($branch.Substring(0, [math]::Min(10,$branch.Length)))-$revision"}[$branch -eq "main" -and $revision -ne "local"] + Write-Output "build: Package version prefix is $versionPrefix" -Write-Output "build: Package version suffix is $suffix" + $branch = @{ $true = $env:CI_TARGET_BRANCH; $false = $(git symbolic-ref --short -q HEAD) }[$NULL -ne $env:CI_TARGET_BRANCH]; + $revision = @{ $true = "{0:00000}" -f [convert]::ToInt32("0" + $env:CI_BUILD_NUMBER, 10); $false = "local" }[$NULL -ne $env:CI_BUILD_NUMBER]; + $suffix = @{ $true = ""; $false = "$($branch.Substring(0, [math]::Min(10,$branch.Length)) -replace '([^a-zA-Z0-9\-]*)', '')-$revision"}[$branch -eq "main" -and $revision -ne "local"] + $commitHash = $(git rev-parse --short HEAD) + $buildSuffix = @{ $true = "$($suffix)-$($commitHash)"; $false = "$($branch)-$($commitHash)" }[$suffix -ne ""] -foreach ($src in Get-ChildItem src/*) { - Push-Location $src + Write-Output "build: Package version suffix is $suffix" + Write-Output "build: Build version suffix is $buildSuffix" - Write-Output "build: Packaging project in $src" + & dotnet build -c Release --version-suffix=$buildSuffix /p:ContinuousIntegrationBuild=true + if($LASTEXITCODE -ne 0) { throw "Build failed" } - if ($suffix) { - & dotnet pack -c Release --include-source -o ../../artifacts --version-suffix=$suffix - } else { - & dotnet pack -c Release --include-source -o ../../artifacts + foreach ($src in Get-ChildItem src/*) { + Push-Location $src + + Write-Output "build: Packaging project in $src" + + if ($suffix) { + & dotnet pack -c Release --no-build --no-restore -o ../../artifacts --version-suffix=$suffix + } else { + & dotnet pack -c Release --no-build --no-restore -o ../../artifacts + } + if($LASTEXITCODE -ne 0) { throw "Packaging failed" } + + Pop-Location } - if($LASTEXITCODE -ne 0) { throw "Packaging failed" } - Pop-Location -} + foreach ($test in Get-ChildItem test/*.Tests) { + Push-Location $test + + Write-Output "build: Testing project in $test" + + & dotnet test -c Release --no-build --no-restore + if($LASTEXITCODE -ne 0) { throw "Testing failed" } + + Pop-Location + } + + if ($env:NUGET_API_KEY) { + # GitHub Actions will only supply this to branch builds and not PRs. We publish + # builds from any branch this action targets (i.e. main and dev). -foreach ($test in Get-ChildItem test/*.Tests) { - Push-Location $test + Write-Output "build: Publishing NuGet packages" - Write-Output "build: Testing project in $test" + foreach ($nupkg in Get-ChildItem artifacts/*.nupkg) { + & dotnet nuget push -k $env:NUGET_API_KEY -s https://api.nuget.org/v3/index.json "$nupkg" + if($LASTEXITCODE -ne 0) { throw "Publishing failed" } + } - & dotnet test -c Release - if($LASTEXITCODE -ne 0) { throw "Testing failed" } + if (!($suffix)) { + Write-Output "build: Creating release for version $versionPrefix" + iex "gh release create v$versionPrefix --title v$versionPrefix --generate-notes $(get-item ./artifacts/*.nupkg) $(get-item ./artifacts/*.snupkg)" + } + } +} finally { Pop-Location } - -Pop-Location diff --git a/Directory.Build.props b/Directory.Build.props index 0248539..c114992 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,13 +1,21 @@ + + latest True - true - true + + true $(MSBuildThisFileDirectory)assets/Serilog.snk + false enable enable - latest + true + true + true + true + snupkg diff --git a/Directory.Version.props b/Directory.Version.props new file mode 100644 index 0000000..a7a8629 --- /dev/null +++ b/Directory.Version.props @@ -0,0 +1,5 @@ + + + 7.0.0 + + diff --git a/README.md b/README.md index 1ac6ed7..1feed22 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Serilog.Sinks.File [![Build status](https://ci.appveyor.com/api/projects/status/hh9gymy0n6tne46j/branch/dev?svg=true)](https://ci.appveyor.com/project/serilog/serilog-sinks-file/branch/dev) [![NuGet Version](http://img.shields.io/nuget/v/Serilog.Sinks.File.svg?style=flat)](https://www.nuget.org/packages/Serilog.Sinks.File/) [![Documentation](https://img.shields.io/badge/docs-wiki-yellow.svg)](https://github.com/serilog/serilog/wiki) +# Serilog.Sinks.File [![Build status](https://github.com/serilog/serilog-sinks-file/actions/workflows/ci.yml/badge.svg?branch=dev)](https://github.com/serilog/serilog-sinks-file/actions) [![NuGet Version](http://img.shields.io/nuget/v/Serilog.Sinks.File.svg?style=flat)](https://www.nuget.org/packages/Serilog.Sinks.File/) [![Documentation](https://img.shields.io/badge/docs-wiki-yellow.svg)](https://github.com/serilog/serilog/wiki) Writes [Serilog](https://serilog.net) events to one or more text files. diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index 42f5a75..0000000 --- a/appveyor.yml +++ /dev/null @@ -1,31 +0,0 @@ -version: '{build}' -skip_tags: true -image: - - Visual Studio 2022 - - Ubuntu2204 -build_script: -- pwsh: ./Build.ps1 -for: -- - matrix: - only: - - image: Ubuntu - build_script: - - sh build.sh -test: off -artifacts: -- path: artifacts/Serilog.*.nupkg -- path: artifacts/Serilog.*.snupkg -deploy: -- provider: NuGet - api_key: - secure: sDnchSg4TZIOK7oIUI6BJwFPNENTOZrGNsroGO1hehLJSvlHpFmpTwiX8+bgPD+Q - on: - branch: /^(main|dev)$/ -- provider: GitHub - auth_token: - secure: p4LpVhBKxGS5WqucHxFQ5c7C8cP74kbNB0Z8k9Oxx/PMaDQ1+ibmoexNqVU5ZlmX - artifact: /Serilog.*(\.|\.s)nupkg/ - tag: v$(appveyor_build_version) - on: - branch: main diff --git a/build.sh b/build.sh deleted file mode 100755 index cf241dc..0000000 --- a/build.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/bash - -set -e -dotnet --info -dotnet --list-sdks -dotnet restore - -echo "🤖 Attempting to build..." -for path in src/**/*.csproj; do - dotnet build -f netstandard1.3 -c Release ${path} - dotnet build -f netstandard2.0 -c Release ${path} -done - -echo "🤖 Running tests..." -for path in test/*.Tests/*.csproj; do - dotnet test -f netcoreapp2.0 -c Release ${path} -done diff --git a/global.json b/global.json new file mode 100644 index 0000000..ed7ea04 --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "9.0.200", + "allowPrerelease": false, + "rollForward": "latestFeature" + } +} diff --git a/serilog-sinks-file.sln b/serilog-sinks-file.sln index b996c93..909a693 100644 --- a/serilog-sinks-file.sln +++ b/serilog-sinks-file.sln @@ -5,16 +5,16 @@ VisualStudioVersion = 17.5.33424.131 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{037440DE-440B-4129-9F7A-09B42D00397E}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "assets", "assets", "{E9D1B5E1-DEB9-4A04-8BAB-24EC7240ADAF}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "sln", "sln", "{E9D1B5E1-DEB9-4A04-8BAB-24EC7240ADAF}" ProjectSection(SolutionItems) = preProject .editorconfig = .editorconfig - appveyor.yml = appveyor.yml - Build.ps1 = Build.ps1 - build.sh = build.sh Directory.Build.props = Directory.Build.props Directory.Build.targets = Directory.Build.targets README.md = README.md assets\Serilog.snk = assets\Serilog.snk + global.json = global.json + Build.ps1 = Build.ps1 + Directory.Version.props = Directory.Version.props EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{7B927378-9F16-4F6F-B3F6-156395136646}" @@ -27,6 +27,13 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Serilog.Sinks.File.Tests", EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sample", "example\Sample\Sample.csproj", "{A34235A2-A717-4A1C-BF5C-F4A9E06E1260}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{3827A9BD-6D28-4A12-B1C0-32A9BD246EA6}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{E5028523-6E46-4A86-AAB9-BF4B1FA5D41D}" + ProjectSection(SolutionItems) = preProject + .github\workflows\ci.yml = .github\workflows\ci.yml + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -53,6 +60,7 @@ Global {57E0ED0E-0F45-48AB-A73D-6A92B7C32095} = {037440DE-440B-4129-9F7A-09B42D00397E} {3C2D8E01-5580-426A-BDD9-EC59CD98E618} = {7B927378-9F16-4F6F-B3F6-156395136646} {A34235A2-A717-4A1C-BF5C-F4A9E06E1260} = {196B1544-C617-4D7C-96D1-628713BDD52A} + {E5028523-6E46-4A86-AAB9-BF4B1FA5D41D} = {3827A9BD-6D28-4A12-B1C0-32A9BD246EA6} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {EA0197B4-FCA8-4DF2-BF34-274FA41333D1} diff --git a/src/Serilog.Sinks.File/FileLoggerConfigurationExtensions.cs b/src/Serilog.Sinks.File/FileLoggerConfigurationExtensions.cs index 5440792..e3e8bcf 100644 --- a/src/Serilog.Sinks.File/FileLoggerConfigurationExtensions.cs +++ b/src/Serilog.Sinks.File/FileLoggerConfigurationExtensions.cs @@ -549,17 +549,26 @@ static LoggerConfiguration ConfigureFile( } catch (Exception ex) { - SelfLog.WriteLine("Unable to open file sink for {0}: {1}", path, ex); + // No logging failure listener can be configured here; in future we might allow for a static + // default listener, but in the meantime this improves `SelfLog` usefulness and consistency. + SelfLog.FailureListener.OnLoggingFailed( + typeof(FileLoggerConfigurationExtensions), + LoggingFailureKind.Final, + $"unable to open file sink for {path}", + events: null, + ex); if (propagateExceptions) throw; - return addSink(new NullSink(), LevelAlias.Maximum, null); + return addSink(new FailedSink(), restrictedToMinimumLevel, levelSwitch); } if (flushToDiskInterval.HasValue) { #pragma warning disable 618 + // `LoggerSinkConfiguration.Wrap()` is not used here because the target sink is expected + // to support `ILogEventSink`. sink = new PeriodicFlushToDiskSink(sink, flushToDiskInterval.Value); #pragma warning restore 618 } diff --git a/src/Serilog.Sinks.File/Serilog.Sinks.File.csproj b/src/Serilog.Sinks.File/Serilog.Sinks.File.csproj index a7629b8..2b744c1 100644 --- a/src/Serilog.Sinks.File/Serilog.Sinks.File.csproj +++ b/src/Serilog.Sinks.File/Serilog.Sinks.File.csproj @@ -2,31 +2,22 @@ Write Serilog events to text files in plain or JSON format. - 6.0.0 Serilog Contributors net471;net462 - $(TargetFrameworks);net8.0;net6.0;netstandard2.0 - true + $(TargetFrameworks);net9.0;net8.0;net6.0;netstandard2.0 serilog;file - serilog-sink-nuget.png - https://serilog.net/images/serilog-sink-nuget.png https://github.com/serilog/serilog-sinks-file Apache-2.0 - https://github.com/serilog/serilog-sinks-file - git - Serilog - true - True - snupkg + serilog-sink-nuget.png README.md - + @@ -46,6 +37,10 @@ $(DefineConstants);ENUMERABLE_MAXBY + + $(DefineConstants);ENUMERABLE_MAXBY + + diff --git a/src/Serilog.Sinks.File/Sinks/File/FailedSink.cs b/src/Serilog.Sinks.File/Sinks/File/FailedSink.cs new file mode 100644 index 0000000..36e673b --- /dev/null +++ b/src/Serilog.Sinks.File/Sinks/File/FailedSink.cs @@ -0,0 +1,34 @@ +// Copyright © Serilog Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Serilog.Core; +using Serilog.Debugging; +using Serilog.Events; + +namespace Serilog.Sinks.File; + +sealed class FailedSink : ILogEventSink, ISetLoggingFailureListener +{ + ILoggingFailureListener _failureListener = SelfLog.FailureListener; + + public void Emit(LogEvent logEvent) + { + _failureListener.OnLoggingFailed(this, LoggingFailureKind.Final, "the sink could not be initialized", [logEvent], exception: null); + } + + public void SetFailureListener(ILoggingFailureListener failureListener) + { + _failureListener = failureListener ?? throw new ArgumentNullException(nameof(failureListener)); + } +} diff --git a/src/Serilog.Sinks.File/Sinks/File/FileSink.cs b/src/Serilog.Sinks.File/Sinks/File/FileSink.cs index a246b66..32c0cd3 100644 --- a/src/Serilog.Sinks.File/Sinks/File/FileSink.cs +++ b/src/Serilog.Sinks.File/Sinks/File/FileSink.cs @@ -13,6 +13,8 @@ // limitations under the License. using System.Text; +using Serilog.Core; +using Serilog.Debugging; using Serilog.Events; using Serilog.Formatting; @@ -21,7 +23,7 @@ namespace Serilog.Sinks.File; /// /// Write log events to a disk file. /// -public sealed class FileSink : IFileSink, IDisposable +public sealed class FileSink : IFileSink, IDisposable, ISetLoggingFailureListener { readonly TextWriter _output; readonly FileStream _underlyingStream; @@ -31,6 +33,8 @@ public sealed class FileSink : IFileSink, IDisposable readonly object _syncRoot = new(); readonly WriteCountingStream? _countingStreamWrapper; + ILoggingFailureListener _failureListener = SelfLog.FailureListener; + /// Construct a . /// Path to the file. /// Formatter used to convert log events to text. @@ -91,8 +95,16 @@ internal FileSink( if (hooks != null) { - outputStream = hooks.OnFileOpened(path, outputStream, encoding) ?? - throw new InvalidOperationException($"The file lifecycle hook `{nameof(FileLifecycleHooks.OnFileOpened)}(...)` returned `null`."); + try + { + outputStream = hooks.OnFileOpened(path, outputStream, encoding) ?? + throw new InvalidOperationException($"The file lifecycle hook `{nameof(FileLifecycleHooks.OnFileOpened)}(...)` returned `null`."); + } + catch + { + outputStream.Dispose(); + throw; + } } _output = new StreamWriter(outputStream, encoding); @@ -124,7 +136,16 @@ bool IFileSink.EmitOrOverflow(LogEvent logEvent) /// When is null public void Emit(LogEvent logEvent) { - ((IFileSink) this).EmitOrOverflow(logEvent); + if (!((IFileSink)this).EmitOrOverflow(logEvent)) + { + // Support fallback chains without the overhead of throwing an exception. + _failureListener.OnLoggingFailed( + this, + LoggingFailureKind.Permanent, + "the log file size limit has been reached and no rolling behavior was specified", + [logEvent], + exception: null); + } } /// @@ -145,4 +166,9 @@ public void FlushToDisk() _underlyingStream.Flush(true); } } + + void ISetLoggingFailureListener.SetFailureListener(ILoggingFailureListener failureListener) + { + _failureListener = failureListener ?? throw new ArgumentNullException(nameof(failureListener)); + } } diff --git a/src/Serilog.Sinks.File/Sinks/File/PeriodicFlushToDiskSink.cs b/src/Serilog.Sinks.File/Sinks/File/PeriodicFlushToDiskSink.cs index c4272d9..82266f9 100644 --- a/src/Serilog.Sinks.File/Sinks/File/PeriodicFlushToDiskSink.cs +++ b/src/Serilog.Sinks.File/Sinks/File/PeriodicFlushToDiskSink.cs @@ -22,12 +22,14 @@ namespace Serilog.Sinks.File; /// A sink wrapper that periodically flushes the wrapped sink to disk. /// [Obsolete("This type will be removed from the public API in a future version; use `WriteTo.File(flushToDiskInterval:)` instead.")] -public class PeriodicFlushToDiskSink : ILogEventSink, IDisposable +public sealed class PeriodicFlushToDiskSink : ILogEventSink, IDisposable, ISetLoggingFailureListener { readonly ILogEventSink _sink; readonly Timer _timer; int _flushRequired; + ILoggingFailureListener _failureListener = SelfLog.FailureListener; + /// /// Construct a that wraps /// and flushes it at the specified . @@ -46,7 +48,17 @@ public PeriodicFlushToDiskSink(ILogEventSink sink, TimeSpan flushInterval) else { _timer = new Timer(_ => { }, null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); - SelfLog.WriteLine("{0} configured to flush {1}, but {2} not implemented", typeof(PeriodicFlushToDiskSink), sink, nameof(IFlushableFileSink)); + + // May be an opportunity to improve the failure listener API for these cases - the failure + // is important, but not exactly `Final`. + SelfLog.FailureListener.OnLoggingFailed( + // Class must be sealed in order for this to be safe - `this` may be partially constructed + // otherwise. + this, + LoggingFailureKind.Final, + $"configured to flush {sink}, but {nameof(IFlushableFileSink)} not implemented", + events: null, + exception: null); } } @@ -77,7 +89,21 @@ void FlushToDisk(IFlushableFileSink flushable) } catch (Exception ex) { - SelfLog.WriteLine("{0} could not flush the underlying sink to disk: {1}", typeof(PeriodicFlushToDiskSink), ex); + _failureListener.OnLoggingFailed( + this, + LoggingFailureKind.Temporary, + "could not flush the underlying file to disk", + events: null, + ex); + } + } + + void ISetLoggingFailureListener.SetFailureListener(ILoggingFailureListener failureListener) + { + _failureListener = failureListener ?? throw new ArgumentNullException(nameof(failureListener)); + if (_sink is ISetLoggingFailureListener setLoggingFailureListener) + { + setLoggingFailureListener.SetFailureListener(failureListener); } } } diff --git a/src/Serilog.Sinks.File/Sinks/File/RollingFileSink.cs b/src/Serilog.Sinks.File/Sinks/File/RollingFileSink.cs index 6c55d44..93c02c5 100644 --- a/src/Serilog.Sinks.File/Sinks/File/RollingFileSink.cs +++ b/src/Serilog.Sinks.File/Sinks/File/RollingFileSink.cs @@ -20,7 +20,7 @@ namespace Serilog.Sinks.File; -sealed class RollingFileSink : ILogEventSink, IFlushableFileSink, IDisposable +sealed class RollingFileSink : ILogEventSink, IFlushableFileSink, IDisposable, ISetLoggingFailureListener { readonly PathRoller _roller; readonly ITextFormatter _textFormatter; @@ -33,6 +33,8 @@ sealed class RollingFileSink : ILogEventSink, IFlushableFileSink, IDisposable readonly bool _rollOnFileSizeLimit; readonly FileLifecycleHooks? _hooks; + ILoggingFailureListener _failureListener = SelfLog.FailureListener; + readonly object _syncRoot = new(); bool _isDisposed; DateTime? _nextCheckpoint; @@ -72,6 +74,7 @@ public void Emit(LogEvent logEvent) { if (logEvent == null) throw new ArgumentNullException(nameof(logEvent)); + bool failed; lock (_syncRoot) { if (_isDisposed) throw new ObjectDisposedException("The log file has been disposed."); @@ -83,16 +86,29 @@ public void Emit(LogEvent logEvent) { AlignCurrentFileTo(now, nextSequence: true); } + + failed = _currentFile == null; + } + + if (failed) + { + // Support fallback chains without the overhead of throwing an exception. + _failureListener.OnLoggingFailed( + this, + LoggingFailureKind.Permanent, + "the target file could not be opened or created", + [logEvent], + exception: null); } } void AlignCurrentFileTo(DateTime now, bool nextSequence = false) { - if (!_nextCheckpoint.HasValue) + if (_currentFile == null && !_nextCheckpoint.HasValue) { OpenFile(now); } - else if (nextSequence || now >= _nextCheckpoint.Value) + else if (nextSequence || (_nextCheckpoint.HasValue && now >= _nextCheckpoint.Value)) { int? minSequence = null; if (nextSequence) @@ -112,68 +128,97 @@ void OpenFile(DateTime now, int? minSequence = null) { var currentCheckpoint = _roller.GetCurrentCheckpoint(now); - // We only try periodically because repeated failures - // to open log files REALLY slow an app down. - _nextCheckpoint = _roller.GetNextCheckpoint(now) ?? now.AddMinutes(30); + _nextCheckpoint = _roller.GetNextCheckpoint(now); - var existingFiles = Enumerable.Empty(); try { - if (Directory.Exists(_roller.LogFileDirectory)) + var existingFiles = Enumerable.Empty(); + try + { + if (Directory.Exists(_roller.LogFileDirectory)) + { + // ReSharper disable once ConvertClosureToMethodGroup + existingFiles = Directory.GetFiles(_roller.LogFileDirectory, _roller.DirectorySearchPattern) + .Select(f => Path.GetFileName(f)); + } + } + catch (DirectoryNotFoundException) { - // ReSharper disable once ConvertClosureToMethodGroup - existingFiles = Directory.GetFiles(_roller.LogFileDirectory, _roller.DirectorySearchPattern) - .Select(f => Path.GetFileName(f)); } - } - catch (DirectoryNotFoundException) { } - var latestForThisCheckpoint = _roller - .SelectMatches(existingFiles) - .Where(m => m.DateTime == currentCheckpoint) + var latestForThisCheckpoint = _roller + .SelectMatches(existingFiles) + .Where(m => m.DateTime == currentCheckpoint) #if ENUMERABLE_MAXBY .MaxBy(m => m.SequenceNumber); #else - .OrderByDescending(m => m.SequenceNumber) - .FirstOrDefault(); + .OrderByDescending(m => m.SequenceNumber) + .FirstOrDefault(); #endif - var sequence = latestForThisCheckpoint?.SequenceNumber; - if (minSequence != null) - { - if (sequence == null || sequence.Value < minSequence.Value) - sequence = minSequence; - } - - const int maxAttempts = 3; - for (var attempt = 0; attempt < maxAttempts; attempt++) - { - _roller.GetLogFilePath(now, sequence, out var path); + var sequence = latestForThisCheckpoint?.SequenceNumber; + if (minSequence != null) + { + if (sequence == null || sequence.Value < minSequence.Value) + sequence = minSequence; + } - try + const int maxAttempts = 3; + for (var attempt = 0; attempt < maxAttempts; attempt++) { - _currentFile = _shared ? + _roller.GetLogFilePath(now, sequence, out var path); + + try + { + _currentFile = _shared + ? #pragma warning disable 618 - new SharedFileSink(path, _textFormatter, _fileSizeLimitBytes, _encoding) : + new SharedFileSink(path, _textFormatter, _fileSizeLimitBytes, _encoding) + : #pragma warning restore 618 - new FileSink(path, _textFormatter, _fileSizeLimitBytes, _encoding, _buffered, _hooks); + new FileSink(path, _textFormatter, _fileSizeLimitBytes, _encoding, _buffered, _hooks); + + _currentFileSequence = sequence; + + if (_currentFile is ISetLoggingFailureListener setLoggingFailureListener) + { + setLoggingFailureListener.SetFailureListener(_failureListener); + } + } + catch (IOException ex) + { + if (IOErrors.IsLockedFile(ex)) + { + _failureListener.OnLoggingFailed( + this, + LoggingFailureKind.Temporary, + $"file target {path} was locked, attempting to open next in sequence (attempt {attempt + 1})", + events: null, + exception: null); + sequence = (sequence ?? 0) + 1; + continue; + } + + throw; + } - _currentFileSequence = sequence; + ApplyRetentionPolicy(path, now); + return; } - catch (IOException ex) + } + finally + { + if (_currentFile == null) { - if (IOErrors.IsLockedFile(ex)) + // We only try periodically because repeated failures + // to open log files REALLY slow an app down. + // If the next checkpoint would be earlier, keep it! + var retryCheckpoint = now.AddMinutes(30); + if (_nextCheckpoint == null || retryCheckpoint < _nextCheckpoint) { - SelfLog.WriteLine("File target {0} was locked, attempting to open next in sequence (attempt {1})", path, attempt + 1); - sequence = (sequence ?? 0) + 1; - continue; + _nextCheckpoint = retryCheckpoint; } - - throw; } - - ApplyRetentionPolicy(path, now); - return; } } @@ -188,7 +233,7 @@ void ApplyRetentionPolicy(string currentFilePath, DateTime now) // ReSharper disable once ConvertClosureToMethodGroup var potentialMatches = Directory.GetFiles(_roller.LogFileDirectory, _roller.DirectorySearchPattern) .Select(f => Path.GetFileName(f)) - .Union(new[] { currentFileName }); + .Union([currentFileName]); var newestFirst = _roller .SelectMatches(potentialMatches) @@ -211,7 +256,12 @@ void ApplyRetentionPolicy(string currentFilePath, DateTime now) } catch (Exception ex) { - SelfLog.WriteLine("Error {0} while processing obsolete log file {1}", ex, fullPath); + _failureListener.OnLoggingFailed( + this, + LoggingFailureKind.Temporary, + $"error while processing obsolete log file {fullPath}", + events: null, + ex); } } } @@ -258,4 +308,9 @@ public void FlushToDisk() _currentFile?.FlushToDisk(); } } + + public void SetFailureListener(ILoggingFailureListener failureListener) + { + _failureListener = failureListener ?? throw new ArgumentNullException(nameof(failureListener)); + } } diff --git a/src/Serilog.Sinks.File/Sinks/File/SharedFileSink.AtomicAppend.cs b/src/Serilog.Sinks.File/Sinks/File/SharedFileSink.AtomicAppend.cs index c753e46..485c1e4 100644 --- a/src/Serilog.Sinks.File/Sinks/File/SharedFileSink.AtomicAppend.cs +++ b/src/Serilog.Sinks.File/Sinks/File/SharedFileSink.AtomicAppend.cs @@ -16,6 +16,8 @@ using System.Security.AccessControl; using System.Text; +using Serilog.Core; +using Serilog.Debugging; using Serilog.Events; using Serilog.Formatting; @@ -25,7 +27,7 @@ namespace Serilog.Sinks.File; /// Write log events to a disk file. /// [Obsolete("This type will be removed from the public API in a future version; use `WriteTo.File(shared: true)` instead.")] -public sealed class SharedFileSink : IFileSink, IDisposable +public sealed class SharedFileSink : IFileSink, IDisposable, ISetLoggingFailureListener { readonly MemoryStream _writeBuffer; readonly string _path; @@ -34,6 +36,8 @@ public sealed class SharedFileSink : IFileSink, IDisposable readonly long? _fileSizeLimitBytes; readonly object _syncRoot = new(); + ILoggingFailureListener _failureListener = SelfLog.FailureListener; + // The stream is reopened with a larger buffer if atomic writes beyond the current buffer size are needed. FileStream _fileOutput; int _fileStreamBufferLength = DefaultFileStreamBufferLength; @@ -59,7 +63,7 @@ public sealed class SharedFileSink : IFileSink, IDisposable public SharedFileSink(string path, ITextFormatter textFormatter, long? fileSizeLimitBytes, Encoding? encoding = null) { if (fileSizeLimitBytes.HasValue && fileSizeLimitBytes < 1) - throw new ArgumentException("Invalid value provided; file size limit must be at least 1 byte, or null"); + throw new ArgumentException("Invalid value provided; file size limit must be at least 1 byte, or null."); _path = path ?? throw new ArgumentNullException(nameof(path)); _textFormatter = textFormatter ?? throw new ArgumentNullException(nameof(textFormatter)); @@ -149,7 +153,16 @@ bool IFileSink.EmitOrOverflow(LogEvent logEvent) /// When is null public void Emit(LogEvent logEvent) { - ((IFileSink)this).EmitOrOverflow(logEvent); + if (!((IFileSink)this).EmitOrOverflow(logEvent)) + { + // Support fallback chains without the overhead of throwing an exception. + _failureListener.OnLoggingFailed( + this, + LoggingFailureKind.Permanent, + "the log file size limit has been reached and no rolling behavior was specified", + [logEvent], + exception: null); + } } /// @@ -170,6 +183,11 @@ public void FlushToDisk() _fileOutput.Flush(true); } } + + void ISetLoggingFailureListener.SetFailureListener(ILoggingFailureListener failureListener) + { + _failureListener = failureListener ?? throw new ArgumentNullException(nameof(failureListener)); + } } #endif diff --git a/src/Serilog.Sinks.File/Sinks/File/SharedFileSink.OSMutex.cs b/src/Serilog.Sinks.File/Sinks/File/SharedFileSink.OSMutex.cs index 6f5dc5c..3d6a0a1 100644 --- a/src/Serilog.Sinks.File/Sinks/File/SharedFileSink.OSMutex.cs +++ b/src/Serilog.Sinks.File/Sinks/File/SharedFileSink.OSMutex.cs @@ -15,6 +15,7 @@ #if OS_MUTEX using System.Text; +using Serilog.Core; using Serilog.Events; using Serilog.Formatting; using Serilog.Debugging; @@ -25,7 +26,7 @@ namespace Serilog.Sinks.File; /// Write log events to a disk file. /// [Obsolete("This type will be removed from the public API in a future version; use `WriteTo.File(shared: true)` instead.")] -public sealed class SharedFileSink : IFileSink, IDisposable +public sealed class SharedFileSink : IFileSink, IDisposable, ISetLoggingFailureListener { readonly TextWriter _output; readonly FileStream _underlyingStream; @@ -33,6 +34,8 @@ public sealed class SharedFileSink : IFileSink, IDisposable readonly long? _fileSizeLimitBytes; readonly object _syncRoot = new(); + ILoggingFailureListener _failureListener = SelfLog.FailureListener; + const string MutexNameSuffix = ".serilog"; const int MutexWaitTimeout = 10000; readonly Mutex _mutex; @@ -81,7 +84,11 @@ bool IFileSink.EmitOrOverflow(LogEvent logEvent) lock (_syncRoot) { if (!TryAcquireMutex()) - return true; // We didn't overflow, but, roll-on-size should not be attempted + { + // Support fallback chains. + throw new LoggingFailedException( + $"The shared file mutex could not be acquired within {MutexWaitTimeout} ms."); + } try { @@ -111,7 +118,16 @@ bool IFileSink.EmitOrOverflow(LogEvent logEvent) /// When is null public void Emit(LogEvent logEvent) { - ((IFileSink)this).EmitOrOverflow(logEvent); + if (!((IFileSink)this).EmitOrOverflow(logEvent)) + { + // Support fallback chains without the overhead of throwing an exception. + _failureListener.OnLoggingFailed( + this, + LoggingFailureKind.Permanent, + "the log file size limit has been reached and no rolling behavior was specified", + [logEvent], + exception: null); + } } /// @@ -149,13 +165,12 @@ bool TryAcquireMutex() { if (!_mutex.WaitOne(MutexWaitTimeout)) { - SelfLog.WriteLine("Shared file mutex could not be acquired within {0} ms", MutexWaitTimeout); return false; } } catch (AbandonedMutexException) { - SelfLog.WriteLine("Inherited shared file mutex after abandonment by another process"); + SelfLog.WriteLine("inherited the shared file mutex after abandonment by another process"); } return true; @@ -165,6 +180,11 @@ void ReleaseMutex() { _mutex.ReleaseMutex(); } + + void ISetLoggingFailureListener.SetFailureListener(ILoggingFailureListener failureListener) + { + _failureListener = failureListener ?? throw new ArgumentNullException(nameof(failureListener)); + } } #endif diff --git a/test/Serilog.Sinks.File.Tests/FileLoggerConfigurationExtensionsTests.cs b/test/Serilog.Sinks.File.Tests/FileLoggerConfigurationExtensionsTests.cs index b3ce3e2..374ef94 100644 --- a/test/Serilog.Sinks.File.Tests/FileLoggerConfigurationExtensionsTests.cs +++ b/test/Serilog.Sinks.File.Tests/FileLoggerConfigurationExtensionsTests.cs @@ -17,6 +17,20 @@ public void WhenWritingCreationExceptionsAreSuppressed() .CreateLogger(); } + [Fact] + public void WhenWritingCreationExceptionsAreReported() + { + var listener = new CapturingLoggingFailureListener(); + + var logger = new LoggerConfiguration() + .WriteTo.Fallible(wt => wt.File(InvalidPath), listener) + .CreateLogger(); + + logger.Information("Hello"); + + Assert.Single(listener.FailedEvents); + } + [Fact] public void WhenAuditingCreationExceptionsPropagate() { diff --git a/test/Serilog.Sinks.File.Tests/FileSinkTests.cs b/test/Serilog.Sinks.File.Tests/FileSinkTests.cs index 0c76349..b42a562 100644 --- a/test/Serilog.Sinks.File.Tests/FileSinkTests.cs +++ b/test/Serilog.Sinks.File.Tests/FileSinkTests.cs @@ -1,5 +1,6 @@ using System.IO.Compression; using System.Text; +using Serilog.Core; using Xunit; using Serilog.Formatting.Json; using Serilog.Sinks.File.Tests.Support; @@ -59,8 +60,10 @@ public void WhenLimitIsSpecifiedFileSizeIsRestricted() var path = tmp.AllocateFilename("txt"); var evt = Some.LogEvent(new string('n', maxBytes / eventsToLimit)); + var listener = new CapturingLoggingFailureListener(); using (var sink = new FileSink(path, new JsonFormatter(), maxBytes)) { + ((ISetLoggingFailureListener)sink).SetFailureListener(listener); for (var i = 0; i < eventsToLimit * 2; i++) { sink.Emit(evt); @@ -70,6 +73,7 @@ public void WhenLimitIsSpecifiedFileSizeIsRestricted() var size = new FileInfo(path).Length; Assert.True(size > maxBytes); Assert.True(size < maxBytes * 2); + Assert.NotEmpty(listener.FailedEvents); } [Fact] diff --git a/test/Serilog.Sinks.File.Tests/RollingFileSinkTests.cs b/test/Serilog.Sinks.File.Tests/RollingFileSinkTests.cs index 5739983..191e614 100644 --- a/test/Serilog.Sinks.File.Tests/RollingFileSinkTests.cs +++ b/test/Serilog.Sinks.File.Tests/RollingFileSinkTests.cs @@ -4,11 +4,25 @@ using Serilog.Sinks.File.Tests.Support; using Serilog.Configuration; using Serilog.Core; +using Serilog.Debugging; +using Xunit.Abstractions; namespace Serilog.Sinks.File.Tests; -public class RollingFileSinkTests +public class RollingFileSinkTests : IDisposable { + readonly ITestOutputHelper _testOutputHelper; + + public RollingFileSinkTests(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + } + + public void Dispose() + { + SelfLog.Disable(); + } + [Fact] public void LogEventsAreEmittedToTheFileNamedAccordingToTheEventTimestamp() { @@ -145,6 +159,116 @@ public void WhenRetentionCountAndArchivingHookIsSetOldFilesAreCopiedAndOriginalD }); } + [Fact] + public void WhenFirstOpeningFailedWithLockRetryDelayedUntilNextCheckpoint() + { + var fileName = Some.String() + ".txt"; + using var temp = new TempFolder(); + using var log = new LoggerConfiguration() + .WriteTo.File(Path.Combine(temp.Path, fileName), rollOnFileSizeLimit: true, fileSizeLimitBytes: 1, rollingInterval: RollingInterval.Minute, hooks: new FailOpeningHook(true, 2, 3, 4)) + .CreateLogger(); + LogEvent e1 = Some.InformationEvent(new DateTime(2012, 10, 28)), + e2 = Some.InformationEvent(e1.Timestamp.AddSeconds(1)), + e3 = Some.InformationEvent(e1.Timestamp.AddMinutes(5)), + e4 = Some.InformationEvent(e1.Timestamp.AddMinutes(31)); + LogEvent[] logEvents = new[] { e1, e2, e3, e4 }; + + foreach (var logEvent in logEvents) + { + Clock.SetTestDateTimeNow(logEvent.Timestamp.DateTime); + log.Write(logEvent); + } + + var files = Directory.GetFiles(temp.Path) + .OrderBy(p => p, StringComparer.OrdinalIgnoreCase) + .ToArray(); + var pattern = "yyyyMMddHHmm"; + + Assert.Equal(6, files.Length); + // Successful write of e1: + Assert.True(files[0].EndsWith(ExpectedFileName(fileName, e1.Timestamp, pattern)), files[0]); + // Failing writes for e2, will be dropped and logged to SelfLog: + Assert.True(files[1].EndsWith("_001.txt"), files[1]); + Assert.True(files[2].EndsWith("_002.txt"), files[2]); + Assert.True(files[3].EndsWith("_003.txt"), files[3]); + // Successful write of e3: + Assert.True(files[4].EndsWith(ExpectedFileName(fileName, e3.Timestamp, pattern)), files[4]); + // Successful write of e4: + Assert.True(files[5].EndsWith(ExpectedFileName(fileName, e4.Timestamp, pattern)), files[5]); + } + + [Fact] + public void WhenFirstOpeningFailedWithLockRetryDelayed30Minutes() + { + var fileName = Some.String() + ".txt"; + using var temp = new TempFolder(); + using var log = new LoggerConfiguration() + .WriteTo.File(Path.Combine(temp.Path, fileName), rollOnFileSizeLimit: true, fileSizeLimitBytes: 1, rollingInterval: RollingInterval.Hour, hooks: new FailOpeningHook(true, 2, 3, 4)) + .CreateLogger(); + LogEvent e1 = Some.InformationEvent(new DateTime(2012, 10, 28)), + e2 = Some.InformationEvent(e1.Timestamp.AddSeconds(1)), + e3 = Some.InformationEvent(e1.Timestamp.AddMinutes(5)), + e4 = Some.InformationEvent(e1.Timestamp.AddMinutes(31)); + LogEvent[] logEvents = new[] { e1, e2, e3, e4 }; + + SelfLog.Enable(_testOutputHelper.WriteLine); + foreach (var logEvent in logEvents) + { + Clock.SetTestDateTimeNow(logEvent.Timestamp.DateTime); + log.Write(logEvent); + } + + var files = Directory.GetFiles(temp.Path) + .OrderBy(p => p, StringComparer.OrdinalIgnoreCase) + .ToArray(); + var pattern = "yyyyMMddHH"; + + Assert.Equal(4, files.Length); + // Successful write of e1: + Assert.True(files[0].EndsWith(ExpectedFileName(fileName, e1.Timestamp, pattern)), files[0]); + // Failing writes for e2, will be dropped and logged to SelfLog; on lock it will try it three times: + Assert.True(files[1].EndsWith("_001.txt"), files[1]); + Assert.True(files[2].EndsWith("_002.txt"), files[2]); + /* e3 will be dropped and logged to SelfLog without new file as it's in the 30 minutes cooldown and roller only starts on next hour! */ + // Successful write of e4, the third file will be retried after failing initially: + Assert.True(files[3].EndsWith("_003.txt"), files[3]); + } + + [Fact] + public void WhenFirstOpeningFailedWithoutLockRetryDelayed30Minutes() + { + var fileName = Some.String() + ".txt"; + using var temp = new TempFolder(); + using var log = new LoggerConfiguration() + .WriteTo.File(Path.Combine(temp.Path, fileName), rollOnFileSizeLimit: true, fileSizeLimitBytes: 1, rollingInterval: RollingInterval.Hour, hooks: new FailOpeningHook(false, 2)) + .CreateLogger(); + LogEvent e1 = Some.InformationEvent(new DateTime(2012, 10, 28)), + e2 = Some.InformationEvent(e1.Timestamp.AddSeconds(1)), + e3 = Some.InformationEvent(e1.Timestamp.AddMinutes(5)), + e4 = Some.InformationEvent(e1.Timestamp.AddMinutes(31)); + LogEvent[] logEvents = new[] { e1, e2, e3, e4 }; + + SelfLog.Enable(_testOutputHelper.WriteLine); + foreach (var logEvent in logEvents) + { + Clock.SetTestDateTimeNow(logEvent.Timestamp.DateTime); + log.Write(logEvent); + } + + var files = Directory.GetFiles(temp.Path) + .OrderBy(p => p, StringComparer.OrdinalIgnoreCase) + .ToArray(); + var pattern = "yyyyMMddHH"; + + Assert.Equal(2, files.Length); + // Successful write of e1: + Assert.True(files[0].EndsWith(ExpectedFileName(fileName, e1.Timestamp, pattern)), files[0]); + /* Failing writes for e2, will be dropped and logged to SelfLog; on non-lock it will try it once */ + /* e3 will be dropped and logged to SelfLog without new file as it's in the 30 minutes cooldown and roller only starts on next hour! */ + // Successful write of e4, the file will be retried after failing initially: + Assert.True(files[1].EndsWith("_001.txt"), files[1]); + } + [Fact] public void WhenSizeLimitIsBreachedNewFilesCreated() { @@ -279,7 +403,7 @@ static void TestRollingEventSequence( Clock.SetTestDateTimeNow(@event.Timestamp.DateTime); log.Write(@event); - var expected = pathFormat.Replace(".txt", @event.Timestamp.ToString("yyyyMMdd") + ".txt"); + var expected = ExpectedFileName(pathFormat, @event.Timestamp, "yyyyMMdd"); Assert.True(System.IO.File.Exists(expected)); verified.Add(expected); @@ -292,4 +416,9 @@ static void TestRollingEventSequence( Directory.Delete(folder, true); } } + + static string ExpectedFileName(string fileName, DateTimeOffset timestamp, string pattern) + { + return fileName.Replace(".txt", timestamp.ToString(pattern) + ".txt"); + } } diff --git a/test/Serilog.Sinks.File.Tests/Serilog.Sinks.File.Tests.csproj b/test/Serilog.Sinks.File.Tests/Serilog.Sinks.File.Tests.csproj index 9ad3db7..1d366c6 100644 --- a/test/Serilog.Sinks.File.Tests/Serilog.Sinks.File.Tests.csproj +++ b/test/Serilog.Sinks.File.Tests/Serilog.Sinks.File.Tests.csproj @@ -1,8 +1,9 @@ - net48;net8.0 + net48;net8.0;net9.0 true + false diff --git a/test/Serilog.Sinks.File.Tests/SharedFileSinkTests.cs b/test/Serilog.Sinks.File.Tests/SharedFileSinkTests.cs index dff0a0d..b784d2f 100644 --- a/test/Serilog.Sinks.File.Tests/SharedFileSinkTests.cs +++ b/test/Serilog.Sinks.File.Tests/SharedFileSinkTests.cs @@ -1,4 +1,5 @@ -using Xunit; +using Serilog.Core; +using Xunit; using Serilog.Formatting.Json; using Serilog.Sinks.File.Tests.Support; @@ -56,8 +57,10 @@ public void WhenLimitIsSpecifiedFileSizeIsRestricted() var path = tmp.AllocateFilename("txt"); var evt = Some.LogEvent(new string('n', maxBytes / eventsToLimit)); + var listener = new CapturingLoggingFailureListener(); using (var sink = new SharedFileSink(path, new JsonFormatter(), maxBytes)) { + ((ISetLoggingFailureListener)sink).SetFailureListener(listener); for (var i = 0; i < eventsToLimit * 2; i++) { sink.Emit(evt); @@ -67,6 +70,7 @@ public void WhenLimitIsSpecifiedFileSizeIsRestricted() var size = new FileInfo(path).Length; Assert.True(size > maxBytes); Assert.True(size < maxBytes * 2); + Assert.NotEmpty(listener.FailedEvents); } [Fact] diff --git a/test/Serilog.Sinks.File.Tests/Support/CapturingLoggingFailureListener.cs b/test/Serilog.Sinks.File.Tests/Support/CapturingLoggingFailureListener.cs new file mode 100644 index 0000000..7bb4622 --- /dev/null +++ b/test/Serilog.Sinks.File.Tests/Support/CapturingLoggingFailureListener.cs @@ -0,0 +1,17 @@ +using Serilog.Core; +using Serilog.Events; + +namespace Serilog.Sinks.File.Tests.Support; + +class CapturingLoggingFailureListener: ILoggingFailureListener +{ + public List FailedEvents { get; } = []; + + public void OnLoggingFailed(object sender, LoggingFailureKind kind, string message, IReadOnlyCollection? events, Exception? exception) + { + if (kind != LoggingFailureKind.Temporary && events != null) + { + FailedEvents.AddRange(events); + } + } +} diff --git a/test/Serilog.Sinks.File.Tests/Support/FailOpeningHook.cs b/test/Serilog.Sinks.File.Tests/Support/FailOpeningHook.cs new file mode 100644 index 0000000..54ce65b --- /dev/null +++ b/test/Serilog.Sinks.File.Tests/Support/FailOpeningHook.cs @@ -0,0 +1,36 @@ +using System.Text; + +namespace Serilog.Sinks.File.Tests.Support; + +/// +/// +/// Demonstrates the use of , by failing to open for the given amount of times. +/// +class FailOpeningHook : FileLifecycleHooks +{ + readonly bool _asFileLocked; + readonly int[] _failingInstances; + + public int TimesOpened { get; private set; } + + public FailOpeningHook(bool asFileLocked, params int[] failingInstances) + { + _asFileLocked = asFileLocked; + _failingInstances = failingInstances; + } + + public override Stream OnFileOpened(string path, Stream stream, Encoding encoding) + { + TimesOpened++; + if (_failingInstances.Contains(TimesOpened)) + { + var message = $"We failed on try {TimesOpened}, the file was locked: {_asFileLocked}"; + + throw _asFileLocked + ? new IOException(message) + : new Exception(message); + } + + return base.OnFileOpened(path, stream, encoding); + } +}