├── .github
├── FUNDING.yml
├── renovate.json
├── release.yml
└── workflows
│ └── build.yml
├── images
└── icon.png
├── tests
├── TurnerSoftware.Aqueduct.Tests
│ ├── Usings.cs
│ ├── TurnerSoftware.Aqueduct.Tests.csproj
│ └── PipeBifurcationTests.cs
└── Directory.Build.props
├── src
├── TurnerSoftware.Aqueduct
│ ├── BifurcationException.cs
│ ├── TurnerSoftware.Aqueduct.csproj
│ ├── BifurcationSourceConfig.cs
│ ├── BifurcationExtensionMethods.cs
│ ├── PipeBifurcation.cs
│ └── BifurcationTargetConfig.cs
└── Directory.Build.props
├── CodeCoverage.runsettings
├── LICENSE.txt
├── README.md
├── .gitattributes
├── TurnerSoftware.Aqueduct.sln
├── .editorconfig
└── .gitignore
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: Turnerj
--------------------------------------------------------------------------------
/images/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TurnerSoftware/Aqueduct/HEAD/images/icon.png
--------------------------------------------------------------------------------
/tests/TurnerSoftware.Aqueduct.Tests/Usings.cs:
--------------------------------------------------------------------------------
1 | global using FluentAssertions;
2 | global using Microsoft.VisualStudio.TestTools.UnitTesting;
3 |
--------------------------------------------------------------------------------
/tests/Directory.Build.props:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Latest
5 |
6 |
7 |
--------------------------------------------------------------------------------
/.github/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3 | "extends": [
4 | "github>TurnerSoftware/.github:renovate-shared"
5 | ]
6 | }
7 |
--------------------------------------------------------------------------------
/.github/release.yml:
--------------------------------------------------------------------------------
1 | changelog:
2 | exclude:
3 | labels:
4 | - ignore-for-release
5 | categories:
6 | - title: ⚠ Breaking Changes
7 | labels:
8 | - breaking-change
9 | - title: Features and Improvements
10 | labels:
11 | - enhancement
12 | - title: Bug Fixes
13 | labels:
14 | - bug
15 | - title: Dependency Updates
16 | labels:
17 | - dependencies
18 | - title: Other Changes
19 | labels:
20 | - "*"
--------------------------------------------------------------------------------
/src/TurnerSoftware.Aqueduct/BifurcationException.cs:
--------------------------------------------------------------------------------
1 | namespace TurnerSoftware.Aqueduct;
2 |
3 | ///
4 | /// An exception specifically for capturing errors that arise from bifurcation.
5 | ///
6 | public class BifurcationException : Exception
7 | {
8 | internal BifurcationException() : base() { }
9 | internal BifurcationException(string? message) : base(message) { }
10 | internal BifurcationException(string? message, Exception? innerException) : base(message, innerException) { }
11 | }
12 |
--------------------------------------------------------------------------------
/CodeCoverage.runsettings:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | cobertura
8 | [TurnerSoftware.Aqueduct.Tests]*
9 | [TurnerSoftware.Aqueduct]*,[TurnerSoftware.Aqueduct.*]*
10 | Obsolete,GeneratedCodeAttribute
11 | true
12 | true
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/src/TurnerSoftware.Aqueduct/TurnerSoftware.Aqueduct.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net8.0
5 | TurnerSoftware.Aqueduct
6 | TurnerSoftware.Aqueduct
7 | Utilities and extension methods for working with streams and pipes
8 | $(PackageBaseTags)
9 | James Turner
10 |
11 | enable
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/tests/TurnerSoftware.Aqueduct.Tests/TurnerSoftware.Aqueduct.Tests.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net8.0
5 | enable
6 | enable
7 |
8 | false
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Turner Software
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/Directory.Build.props:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | TurnerSoftware.Aqueduct
5 |
6 | Turner Software
7 |
8 | $(AssemblyName)
9 | true
10 | MIT
11 | icon.png
12 | https://github.com/TurnerSoftware/Aqueduct
13 | stream;pipe;reader
14 |
15 |
16 | true
17 | true
18 | embedded
19 |
20 | Latest
21 | enable
22 | true
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | 
4 | # Aqueduct
5 | Utilities and extension methods for working with streams and pipes
6 |
7 | 
8 | [](https://codecov.io/gh/TurnerSoftware/Aqueduct)
9 | [](https://www.nuget.org/packages/TurnerSoftware.Aqueduct/)
10 |
11 |
12 | ## Overview
13 | Aqueduct provides some useful, albeit niche, utilities for working with streams and pipes.
14 |
15 | ### Pipe/Stream Bifurcation
16 |
17 | Allows you to read from a single pipe or stream into multiple targets.
18 | This is useful for cases where you can't buffer the original stream into memory or you're working with a source you can't seek.
19 | Internally it uses individual pipes per target to allow independent processing and minimal memory overhead.
20 |
21 | Each bifurcation target has individual options for:
22 | - The reader which processes the data
23 | - An exception handler triggered on failure of _any_ target
24 | - Control of the number of bytes for blocking/resuming writes to the target
25 | - The maximum number of bytes to write to the specific target
26 |
27 | Additionally, the bifurcation process overall has options for:
28 | - The minimum read buffer size of the source pipe or stream
29 | - Whether to leave the stream open after bifurcation
30 | - Allow exceptions from readers to bubble out to the calling code
31 | - Cancellation token for reading/writing process
32 |
33 | Example usage of pipe/stream bifurcation:
34 |
35 | ```csharp
36 | await myStream.BifurcatedReadAsync(
37 | new BifurcationTargetConfig(
38 | async (Stream stream, CancellationToken cancellationToken) =>
39 | {
40 | using var fileStream = File.OpenWrite("some-file-path.bin");
41 | await stream.CopyToAsync(fileStream);
42 | },
43 | maxTotalBytes: 1024
44 | ),
45 | new BifurcationTargetConfig(
46 | async (PipeReader reader, CancellationToken cancellationToken) =>
47 | {
48 | await someService.ProcessData(reader);
49 | }
50 | )
51 | );
52 | ```
53 |
54 | ## Licensing and Support
55 |
56 | Aqueduct is licensed under the MIT license. It is free to use in personal and commercial projects.
57 |
58 | There are [support plans](https://turnersoftware.com.au/support-plans) available that cover all active [Turner Software OSS projects](https://github.com/TurnerSoftware).
59 | Support plans provide private email support, expert usage advice for our projects, priority bug fixes and more.
60 | These support plans help fund our OSS commitments to provide better software for everyone.
61 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | ###############################################################################
2 | # Set default behavior to automatically normalize line endings.
3 | ###############################################################################
4 | * text=auto
5 |
6 | ###############################################################################
7 | # Set default behavior for command prompt diff.
8 | #
9 | # This is need for earlier builds of msysgit that does not have it on by
10 | # default for csharp files.
11 | # Note: This is only used by command line
12 | ###############################################################################
13 | #*.cs diff=csharp
14 |
15 | ###############################################################################
16 | # Set the merge driver for project and solution files
17 | #
18 | # Merging from the command prompt will add diff markers to the files if there
19 | # are conflicts (Merging from VS is not affected by the settings below, in VS
20 | # the diff markers are never inserted). Diff markers may cause the following
21 | # file extensions to fail to load in VS. An alternative would be to treat
22 | # these files as binary and thus will always conflict and require user
23 | # intervention with every merge. To do so, just uncomment the entries below
24 | ###############################################################################
25 | #*.sln merge=binary
26 | #*.csproj merge=binary
27 | #*.vbproj merge=binary
28 | #*.vcxproj merge=binary
29 | #*.vcproj merge=binary
30 | #*.dbproj merge=binary
31 | #*.fsproj merge=binary
32 | #*.lsproj merge=binary
33 | #*.wixproj merge=binary
34 | #*.modelproj merge=binary
35 | #*.sqlproj merge=binary
36 | #*.wwaproj merge=binary
37 |
38 | ###############################################################################
39 | # behavior for image files
40 | #
41 | # image files are treated as binary by default.
42 | ###############################################################################
43 | #*.jpg binary
44 | #*.png binary
45 | #*.gif binary
46 |
47 | ###############################################################################
48 | # diff behavior for common document formats
49 | #
50 | # Convert binary document formats to text before diffing them. This feature
51 | # is only available from the command line. Turn it on by uncommenting the
52 | # entries below.
53 | ###############################################################################
54 | #*.doc diff=astextplain
55 | #*.DOC diff=astextplain
56 | #*.docx diff=astextplain
57 | #*.DOCX diff=astextplain
58 | #*.dot diff=astextplain
59 | #*.DOT diff=astextplain
60 | #*.pdf diff=astextplain
61 | #*.PDF diff=astextplain
62 | #*.rtf diff=astextplain
63 | #*.RTF diff=astextplain
64 |
--------------------------------------------------------------------------------
/TurnerSoftware.Aqueduct.sln:
--------------------------------------------------------------------------------
1 |
2 | Microsoft Visual Studio Solution File, Format Version 12.00
3 | # Visual Studio Version 17
4 | VisualStudioVersion = 17.5.33209.295
5 | MinimumVisualStudioVersion = 10.0.40219.1
6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TurnerSoftware.Aqueduct", "src\TurnerSoftware.Aqueduct\TurnerSoftware.Aqueduct.csproj", "{8BB5B939-1425-4AEA-9438-29E20C5C5309}"
7 | EndProject
8 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{2AA4A059-93AC-4F2B-A62F-6F3C935A3203}"
9 | ProjectSection(SolutionItems) = preProject
10 | src\Directory.Build.props = src\Directory.Build.props
11 | EndProjectSection
12 | EndProject
13 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{A67C346D-6E1C-4186-90D1-94FF299B495B}"
14 | EndProject
15 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TurnerSoftware.Aqueduct.Tests", "tests\TurnerSoftware.Aqueduct.Tests\TurnerSoftware.Aqueduct.Tests.csproj", "{D4A86C4F-779E-4E70-AC8A-45BA2D419AEE}"
16 | EndProject
17 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "global", "global", "{6BA89924-AC44-48A7-9625-B31BE5A0AB3F}"
18 | ProjectSection(SolutionItems) = preProject
19 | .editorconfig = .editorconfig
20 | .gitignore = .gitignore
21 | CodeCoverage.runsettings = CodeCoverage.runsettings
22 | License.txt = License.txt
23 | README.md = README.md
24 | EndProjectSection
25 | EndProject
26 | Global
27 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
28 | Debug|Any CPU = Debug|Any CPU
29 | Release|Any CPU = Release|Any CPU
30 | EndGlobalSection
31 | GlobalSection(ProjectConfigurationPlatforms) = postSolution
32 | {8BB5B939-1425-4AEA-9438-29E20C5C5309}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
33 | {8BB5B939-1425-4AEA-9438-29E20C5C5309}.Debug|Any CPU.Build.0 = Debug|Any CPU
34 | {8BB5B939-1425-4AEA-9438-29E20C5C5309}.Release|Any CPU.ActiveCfg = Release|Any CPU
35 | {8BB5B939-1425-4AEA-9438-29E20C5C5309}.Release|Any CPU.Build.0 = Release|Any CPU
36 | {D4A86C4F-779E-4E70-AC8A-45BA2D419AEE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
37 | {D4A86C4F-779E-4E70-AC8A-45BA2D419AEE}.Debug|Any CPU.Build.0 = Debug|Any CPU
38 | {D4A86C4F-779E-4E70-AC8A-45BA2D419AEE}.Release|Any CPU.ActiveCfg = Release|Any CPU
39 | {D4A86C4F-779E-4E70-AC8A-45BA2D419AEE}.Release|Any CPU.Build.0 = Release|Any CPU
40 | EndGlobalSection
41 | GlobalSection(SolutionProperties) = preSolution
42 | HideSolutionNode = FALSE
43 | EndGlobalSection
44 | GlobalSection(NestedProjects) = preSolution
45 | {8BB5B939-1425-4AEA-9438-29E20C5C5309} = {2AA4A059-93AC-4F2B-A62F-6F3C935A3203}
46 | {D4A86C4F-779E-4E70-AC8A-45BA2D419AEE} = {A67C346D-6E1C-4186-90D1-94FF299B495B}
47 | EndGlobalSection
48 | GlobalSection(ExtensibilityGlobals) = postSolution
49 | SolutionGuid = {57E7526E-4A07-4AE4-8E56-5D577F4AD7AB}
50 | EndGlobalSection
51 | EndGlobal
52 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # Based on the EditorConfig from Roslyn
2 | # top-most EditorConfig file
3 | root = true
4 |
5 | [*.cs]
6 | indent_style = tab
7 |
8 | # Sort using and Import directives with System.* appearing first
9 | dotnet_sort_system_directives_first = true
10 | # Avoid "this." and "Me." if not necessary
11 | dotnet_style_qualification_for_field = false:suggestion
12 | dotnet_style_qualification_for_property = false:suggestion
13 | dotnet_style_qualification_for_method = false:suggestion
14 | dotnet_style_qualification_for_event = false:suggestion
15 |
16 | # Use language keywords instead of framework type names for type references
17 | dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion
18 | dotnet_style_predefined_type_for_member_access = true:suggestion
19 |
20 | # Suggest more modern language features when available
21 | dotnet_style_object_initializer = true:suggestion
22 | dotnet_style_collection_initializer = true:suggestion
23 | dotnet_style_coalesce_expression = true:suggestion
24 | dotnet_style_null_propagation = true:suggestion
25 | dotnet_style_explicit_tuple_names = true:suggestion
26 |
27 | # Prefer "var" everywhere
28 | csharp_style_var_for_built_in_types = true:suggestion
29 | csharp_style_var_when_type_is_apparent = true:suggestion
30 | csharp_style_var_elsewhere = true:suggestion
31 |
32 | # Prefer method-like constructs to have a block body
33 | csharp_style_expression_bodied_methods = false:none
34 | csharp_style_expression_bodied_constructors = false:none
35 | csharp_style_expression_bodied_operators = false:none
36 |
37 | # Prefer property-like constructs to have an expression-body
38 | csharp_style_expression_bodied_properties = when_on_single_line:suggestion
39 | csharp_style_expression_bodied_indexers = true:none
40 | csharp_style_expression_bodied_accessors = when_on_single_line:suggestion
41 |
42 | # Suggest more modern language features when available
43 | csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
44 | csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
45 | csharp_style_inlined_variable_declaration = true:suggestion
46 | csharp_style_throw_expression = true:suggestion
47 | csharp_style_conditional_delegate_call = true:suggestion
48 |
49 | # Newline settings
50 | csharp_new_line_before_open_brace = all
51 | csharp_new_line_before_else = true
52 | csharp_new_line_before_catch = true
53 | csharp_new_line_before_finally = true
54 | csharp_new_line_before_members_in_object_initializers = true
55 | csharp_new_line_before_members_in_anonymous_types = true
56 |
57 | # Misc
58 | csharp_space_after_keywords_in_control_flow_statements = true
59 | csharp_space_between_method_declaration_parameter_list_parentheses = false
60 | csharp_space_between_method_call_parameter_list_parentheses = false
61 | csharp_space_between_parentheses = false
62 | csharp_preserve_single_line_statements = false
63 | csharp_preserve_single_line_blocks = true
64 | csharp_indent_case_contents = true
65 | csharp_indent_switch_labels = true
66 | csharp_indent_labels = no_change
67 |
68 | # Custom naming conventions
69 | dotnet_naming_rule.non_field_members_must_be_capitalized.symbols = non_field_member_symbols
70 | dotnet_naming_symbols.non_field_member_symbols.applicable_kinds = property,method,event,delegate
71 | dotnet_naming_symbols.non_field_member_symbols.applicable_accessibilities = *
72 |
73 | dotnet_naming_rule.non_field_members_must_be_capitalized.style = pascal_case_style
74 | dotnet_naming_style.pascal_case_style.capitalization = pascal_case
75 |
76 | dotnet_naming_rule.non_field_members_must_be_capitalized.severity = suggestion
--------------------------------------------------------------------------------
/src/TurnerSoftware.Aqueduct/BifurcationSourceConfig.cs:
--------------------------------------------------------------------------------
1 | namespace TurnerSoftware.Aqueduct;
2 |
3 | ///
4 | /// Manages the configuration for bifurcation.
5 | ///
6 | public class BifurcationSourceConfig
7 | {
8 | internal const int DefaultMinReadBufferSize = 4096;
9 |
10 | ///
11 | /// The default configuration for .
12 | ///
13 | public static readonly BifurcationSourceConfig DefaultConfig = new();
14 |
15 | ///
16 | /// The minimum read buffer size before writing data to the targets. When -1 is set, there is no minimum read buffer size.
17 | ///
18 | public int MinReadBufferSize { get; }
19 | ///
20 | /// Whether to bubble exceptions during bifurcation to the calling code.
21 | ///
22 | public bool BubbleExceptions { get; }
23 | ///
24 | /// The token to monitor for cancellation requests.
25 | ///
26 | public CancellationToken CancellationToken { get; }
27 |
28 | ///
29 | /// Creates a new .
30 | ///
31 | /// The minimum read buffer size before writing data to the targets. Use -1 to specify no minimum read buffer size.
32 | /// Whether to bubble exceptions during bifurcation to the calling code.
33 | /// The token to monitor for cancellation requests.
34 | ///
35 | public BifurcationSourceConfig(
36 | int minReadBufferSize = DefaultMinReadBufferSize,
37 | bool bubbleExceptions = true,
38 | CancellationToken cancellationToken = default
39 | )
40 | {
41 | if (minReadBufferSize != -1 && minReadBufferSize <= 0)
42 | {
43 | throw new ArgumentException($"Invalid value for {nameof(MinReadBufferSize)}. Must be a value greater than 0, or if there is no restriction, -1.", nameof(minReadBufferSize));
44 | }
45 |
46 | MinReadBufferSize = minReadBufferSize;
47 | BubbleExceptions = bubbleExceptions;
48 | CancellationToken = cancellationToken;
49 | }
50 | }
51 |
52 | ///
53 | /// Manages the configuration for stream bifurcation.
54 | ///
55 | public class StreamBifurcationSourceConfig : BifurcationSourceConfig
56 | {
57 | ///
58 | /// The default configuration for .
59 | ///
60 | public static readonly StreamBifurcationSourceConfig DefaultStreamConfig = new();
61 |
62 | ///
63 | /// Whether to leave the stream open after reading has completed.
64 | ///
65 | public bool LeaveOpen { get; }
66 |
67 | ///
68 | /// Creates a new .
69 | ///
70 | /// Whether to leave the stream open after reading has completed.
71 | /// The minimum read buffer size before writing data to the targets. Use -1 to specify no minimum read buffer size.
72 | /// Whether to bubble exceptions during bifurcation to the calling code.
73 | /// The token to monitor for cancellation requests.
74 | ///
75 | public StreamBifurcationSourceConfig(
76 | bool leaveOpen = false,
77 | int minReadBufferSize = DefaultMinReadBufferSize,
78 | bool bubbleExceptions = true,
79 | CancellationToken cancellationToken = default
80 | ) : base(minReadBufferSize, bubbleExceptions, cancellationToken)
81 | {
82 | LeaveOpen = leaveOpen;
83 | }
84 | }
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: Build
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | pull_request:
7 | release:
8 | types: [ published ]
9 |
10 | env:
11 | # Disable the .NET logo in the console output.
12 | DOTNET_NOLOGO: true
13 | # Disable the .NET first time experience to skip caching NuGet packages and speed up the build.
14 | DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
15 | # Disable sending .NET CLI telemetry to Microsoft.
16 | DOTNET_CLI_TELEMETRY_OPTOUT: true
17 |
18 | BUILD_ARTIFACT_PATH: ${{github.workspace}}/build-artifacts
19 |
20 | jobs:
21 | build:
22 | name: Build ${{matrix.os}}
23 | runs-on: ${{matrix.os}}
24 | strategy:
25 | matrix:
26 | os: [ubuntu-latest, windows-latest, macOS-latest]
27 | steps:
28 | - name: Checkout
29 | uses: actions/checkout@v4
30 | - name: Setup dotnet SDK
31 | uses: actions/setup-dotnet@v4
32 | with:
33 | dotnet-version: |
34 | 8.0.x
35 | - name: Install dependencies
36 | run: dotnet restore
37 | - name: Build
38 | run: dotnet build --no-restore -c Release
39 | - name: Test with Coverage
40 | run: dotnet test --no-restore --logger trx --results-directory ${{env.BUILD_ARTIFACT_PATH}}/coverage --collect "XPlat Code Coverage" --settings CodeCoverage.runsettings /p:SkipBuildVersioning=true
41 | - name: Pack
42 | run: dotnet pack --no-build -c Release /p:PackageOutputPath=${{env.BUILD_ARTIFACT_PATH}}
43 | - name: Publish artifacts
44 | uses: actions/upload-artifact@v4
45 | with:
46 | name: ${{matrix.os}}
47 | path: ${{env.BUILD_ARTIFACT_PATH}}
48 |
49 | coverage:
50 | name: Process code coverage
51 | runs-on: ubuntu-latest
52 | needs: build
53 | steps:
54 | - name: Checkout
55 | uses: actions/checkout@v4
56 | - name: Download coverage reports
57 | uses: actions/download-artifact@v4
58 | - name: Install ReportGenerator tool
59 | run: dotnet tool install -g dotnet-reportgenerator-globaltool
60 | - name: Prepare coverage reports
61 | run: reportgenerator -reports:*/coverage/*/coverage.cobertura.xml -targetdir:./ -reporttypes:Cobertura
62 | - name: Upload coverage report
63 | uses: codecov/codecov-action@v5.3.1
64 | with:
65 | file: Cobertura.xml
66 | fail_ci_if_error: false
67 | - name: Save combined coverage report as artifact
68 | uses: actions/upload-artifact@v4
69 | with:
70 | name: coverage-report
71 | path: Cobertura.xml
72 |
73 | push-to-github-packages:
74 | name: 'Push GitHub Packages'
75 | needs: build
76 | if: github.ref == 'refs/heads/main' || github.event_name == 'release'
77 | environment:
78 | name: 'GitHub Packages'
79 | url: https://github.com/TurnerSoftware/Aqueduct/packages
80 | permissions:
81 | packages: write
82 | runs-on: ubuntu-latest
83 | steps:
84 | - name: 'Download build'
85 | uses: actions/download-artifact@v4
86 | with:
87 | name: 'ubuntu-latest'
88 | - name: 'Add NuGet source'
89 | run: dotnet nuget add source https://nuget.pkg.github.com/TurnerSoftware/index.json --name GitHub --username Turnerj --password ${{secrets.GITHUB_TOKEN}} --store-password-in-clear-text
90 | - name: 'Upload NuGet package'
91 | run: dotnet nuget push *.nupkg --api-key ${{secrets.GH_PACKAGE_REGISTRY_API_KEY}} --source GitHub --skip-duplicate
92 |
93 | push-to-nuget:
94 | name: 'Push NuGet Packages'
95 | needs: build
96 | if: github.event_name == 'release'
97 | environment:
98 | name: 'NuGet'
99 | url: https://www.nuget.org/packages/TurnerSoftware.Aqueduct
100 | runs-on: ubuntu-latest
101 | steps:
102 | - name: 'Download build'
103 | uses: actions/download-artifact@v4
104 | with:
105 | name: 'ubuntu-latest'
106 | - name: 'Upload NuGet package'
107 | run: dotnet nuget push *.nupkg --source https://api.nuget.org/v3/index.json --skip-duplicate --api-key ${{secrets.NUGET_API_KEY}}
108 |
--------------------------------------------------------------------------------
/src/TurnerSoftware.Aqueduct/BifurcationExtensionMethods.cs:
--------------------------------------------------------------------------------
1 | using System.IO.Pipelines;
2 |
3 | namespace TurnerSoftware.Aqueduct;
4 |
5 | ///
6 | /// Extension methods specific for bifurcation.
7 | ///
8 | public static class BifurcationExtensionMethods
9 | {
10 | ///
11 | /// Performs bifurcation with , splitting the resulting data into multiple .
12 | ///
13 | /// The source to read from.
14 | /// The targets to provide the bifurcated data to.
15 | /// A list of results from the targets, in the order the targets were provided.
16 | public static Task> BifurcatedReadAsync(this PipeReader sourceReader, params BifurcationTargetConfig[] targetConfigs)
17 | => BifurcatedReadAsync(sourceReader, BifurcationSourceConfig.DefaultConfig, targetConfigs);
18 | ///
19 | /// Performs bifurcation with , splitting the resulting data into multiple .
20 | ///
21 | /// The source to read from.
22 | /// Source-specific configuration for reading.
23 | /// The targets to provide the bifurcated data to.
24 | /// A list of results from the targets, in the order the targets were provided.
25 | public static Task> BifurcatedReadAsync(this PipeReader sourceReader, BifurcationSourceConfig sourceConfig, params BifurcationTargetConfig[] targetConfigs)
26 | => PipeBifurcation.BifurcatedReadAsync(sourceReader, sourceConfig, targetConfigs);
27 |
28 | ///
29 | /// Performs bifurcation with , splitting the resulting data into multiple .
30 | ///
31 | /// The source to read from.
32 | /// The targets to provide the bifurcated data to.
33 | /// A list of results from the targets, in the order the targets were provided.
34 | public static Task> BifurcatedReadAsync(this Stream sourceStream, params BifurcationTargetConfig[] targetConfigs)
35 | => BifurcatedReadAsync(sourceStream, StreamBifurcationSourceConfig.DefaultStreamConfig, targetConfigs);
36 | ///
37 | /// Performs bifurcation with , splitting the resulting data into multiple .
38 | ///
39 | /// The source to read from.
40 | /// Source-specific configuration for reading.
41 | /// The targets to provide the bifurcated data to.
42 | /// A list of results from the targets, in the order the targets were provided.
43 | public static Task> BifurcatedReadAsync(this Stream sourceStream, StreamBifurcationSourceConfig sourceConfig, params BifurcationTargetConfig[] targetConfigs)
44 | {
45 | var sourceReader = PipeReader.Create(sourceStream, new StreamPipeReaderOptions(leaveOpen: sourceConfig.LeaveOpen));
46 | return PipeBifurcation.BifurcatedReadAsync(sourceReader, sourceConfig, targetConfigs);
47 | }
48 |
49 | ///
50 | /// Performs bifurcation with , splitting the resulting data into multiple .
51 | ///
52 | /// The source to read from.
53 | /// The targets to provide the bifurcated data to.
54 | ///
55 | public static Task BifurcatedReadAsync(this PipeReader sourceReader, params BifurcationTargetConfig[] targetConfigs)
56 | => BifurcatedReadAsync(sourceReader, BifurcationSourceConfig.DefaultConfig, targetConfigs);
57 | ///
58 | /// Performs bifurcation with , splitting the resulting data into multiple .
59 | ///
60 | /// The source to read from.
61 | /// Source-specific configuration for reading.
62 | /// The targets to provide the bifurcated data to.
63 | ///
64 | public static Task BifurcatedReadAsync(this PipeReader sourceReader, BifurcationSourceConfig sourceConfig, params BifurcationTargetConfig[] targetConfigs)
65 | => PipeBifurcation.BifurcatedReadAsync(sourceReader, sourceConfig, targetConfigs);
66 |
67 | ///
68 | /// Performs bifurcation with , splitting the resulting data into multiple .
69 | ///
70 | /// The source to read from.
71 | /// The targets to provide the bifurcated data to.
72 | ///
73 | public static Task BifurcatedReadAsync(this Stream sourceStream, params BifurcationTargetConfig[] targetConfigs)
74 | => BifurcatedReadAsync(sourceStream, StreamBifurcationSourceConfig.DefaultStreamConfig, targetConfigs);
75 | ///
76 | /// Performs bifurcation with , splitting the resulting data into multiple .
77 | ///
78 | /// The source to read from.
79 | /// Source-specific configuration for reading.
80 | /// The targets to provide the bifurcated data to.
81 | ///
82 | public static Task BifurcatedReadAsync(this Stream sourceStream, StreamBifurcationSourceConfig sourceConfig, params BifurcationTargetConfig[] targetConfigs)
83 | {
84 | var sourceReader = PipeReader.Create(sourceStream, new StreamPipeReaderOptions(leaveOpen: sourceConfig.LeaveOpen));
85 | return PipeBifurcation.BifurcatedReadAsync(sourceReader, sourceConfig, targetConfigs);
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/src/TurnerSoftware.Aqueduct/PipeBifurcation.cs:
--------------------------------------------------------------------------------
1 | using System.Buffers;
2 | using System.IO.Pipelines;
3 |
4 | namespace TurnerSoftware.Aqueduct;
5 |
6 | internal static class PipeBifurcation
7 | {
8 | private class BifurcationState
9 | {
10 | private readonly Pipe Pipe;
11 |
12 | private Task? ReaderTask;
13 | private int RemainingBytes;
14 |
15 | public readonly BifurcationTargetConfig Config;
16 |
17 | public TResult? Result { get; private set; }
18 |
19 | public BifurcationState(BifurcationTargetConfig config)
20 | {
21 | Config = config;
22 |
23 | Pipe = new Pipe(new PipeOptions(
24 | pauseWriterThreshold: config.BlockAfter,
25 | resumeWriterThreshold: config.ResumeAfter
26 | ));
27 |
28 | RemainingBytes = config.MaxTotalBytes;
29 | }
30 |
31 | public bool IsCompleted { get; private set; }
32 |
33 | private async Task RunReaderWithCleanup(CancellationToken cancellationToken)
34 | {
35 | try
36 | {
37 | var result = await Config.Reader(Pipe.Reader, cancellationToken);
38 | await Pipe.Reader.CompleteAsync();
39 | return result;
40 | }
41 | catch (Exception ex)
42 | {
43 | await Pipe.Reader.CompleteAsync(ex);
44 | throw;
45 | }
46 | }
47 |
48 | public void StartReader(CancellationToken cancellationToken)
49 | {
50 | try
51 | {
52 | ReaderTask = RunReaderWithCleanup(cancellationToken);
53 | }
54 | catch (Exception ex)
55 | {
56 | ReaderTask = Task.FromException(ex);
57 | }
58 | }
59 |
60 | ///
61 | /// Using the , writes as much as configured to the bifurcation target.
62 | ///
63 | ///
64 | ///
65 | /// Whether the target can still be written to.
66 | public async ValueTask WriteAsync(ReadOnlySequence buffer, CancellationToken cancellationToken)
67 | {
68 | if (IsCompleted)
69 | {
70 | return false;
71 | }
72 |
73 | if (ReaderTask is not null)
74 | {
75 | //Await faulted readers to correctly bubble exceptions
76 | if (ReaderTask.IsFaulted)
77 | {
78 | await ReaderTask;
79 | }
80 |
81 | //If the reader task finishes early for some other reason
82 | if (ReaderTask.IsCompleted)
83 | {
84 | return false;
85 | }
86 | }
87 |
88 | var bytesToRead = (int)buffer.Length;
89 | if (RemainingBytes != -1)
90 | {
91 | bytesToRead = Math.Min(RemainingBytes, bytesToRead);
92 | }
93 |
94 | var destination = Pipe.Writer.GetMemory(bytesToRead);
95 | buffer.Slice(0, bytesToRead).CopyTo(destination.Span);
96 |
97 | Pipe.Writer.Advance(bytesToRead);
98 |
99 | var flushResult = await Pipe.Writer.FlushAsync(cancellationToken);
100 |
101 | if (RemainingBytes != -1)
102 | {
103 | RemainingBytes -= bytesToRead;
104 | if (RemainingBytes == 0)
105 | {
106 | return false;
107 | }
108 | }
109 |
110 | return !flushResult.IsCompleted;
111 | }
112 |
113 | ///
114 | /// Completes the bifurcation target and awaits the reader. Any exceptions the reader throws will bubble out.
115 | ///
116 | ///
117 | public async Task CompleteAsync()
118 | {
119 | if (IsCompleted)
120 | {
121 | return Result;
122 | }
123 |
124 | IsCompleted = true;
125 | await Pipe.Writer.CompleteAsync();
126 |
127 | //Run the task to completion
128 | if (ReaderTask is not null)
129 | {
130 | Result = await ReaderTask;
131 | }
132 |
133 | return Result;
134 | }
135 |
136 | ///
137 | /// Completes the bifurcation target in a faulted state, awaiting the reader and exception handler.
138 | /// No exceptions from either the reader or exception handler will bubble.
139 | ///
140 | /// The exception used to trigger the faulted state.
141 | ///
142 | public async Task CompleteWithExceptionAsync(Exception exception)
143 | {
144 | await Pipe.Writer.CompleteAsync(exception);
145 | if (ReaderTask is not null)
146 | {
147 | //Ensure that the reader task has completed execution (faulted or not)
148 | if (!ReaderTask.IsFaulted)
149 | {
150 | try
151 | {
152 | Result = await ReaderTask;
153 | }
154 | catch
155 | {
156 | //Ignore any exceptions
157 | }
158 | }
159 |
160 | //Trigger any custom exception handler
161 | if (Config.ExceptionHandler is not null)
162 | {
163 | try
164 | {
165 | await Config.ExceptionHandler(exception);
166 | }
167 | catch
168 | {
169 | //Ignore any exceptions
170 | }
171 | }
172 | }
173 |
174 | return Result;
175 | }
176 | }
177 |
178 | public static async Task> BifurcatedReadAsync(PipeReader sourceReader, BifurcationSourceConfig sourceConfig, params BifurcationTargetConfig[] targetConfigs)
179 | {
180 | if (targetConfigs.Length == 0)
181 | {
182 | throw new ArgumentException("No target configurations to bifurcate the source reader to", nameof(targetConfigs));
183 | }
184 |
185 | var earlyCompletedTargets = 0;
186 | var targets = new BifurcationState[targetConfigs.Length];
187 | var results = new TResult?[targetConfigs.Length];
188 |
189 | for (var i = 0; i < targetConfigs.Length; i++)
190 | {
191 | targets[i] = new(targetConfigs[i]);
192 | targets[i].StartReader(sourceConfig.CancellationToken);
193 | }
194 |
195 | try
196 | {
197 | while (true)
198 | {
199 | var result = await sourceReader.ReadAsync(sourceConfig.CancellationToken);
200 | var buffer = result.Buffer;
201 |
202 | if (buffer.IsEmpty && result.IsCompleted)
203 | {
204 | break;
205 | }
206 |
207 | //Ensure a minimum buffer size (if configured)
208 | if (!result.IsCompleted && sourceConfig.MinReadBufferSize != -1 && buffer.Length < sourceConfig.MinReadBufferSize)
209 | {
210 | sourceReader.AdvanceTo(buffer.Start, buffer.End);
211 | continue;
212 | }
213 |
214 | for (var i = 0; i < targets.Length; i++)
215 | {
216 | var target = targets[i];
217 | if (target.IsCompleted)
218 | {
219 | continue;
220 | }
221 |
222 | var canKeepWriting = await target.WriteAsync(buffer, sourceConfig.CancellationToken);
223 | if (!canKeepWriting)
224 | {
225 | await target.CompleteAsync();
226 | earlyCompletedTargets++;
227 | }
228 | }
229 |
230 | //Exit reading early if all targets have completed
231 | if (earlyCompletedTargets == targets.Length)
232 | {
233 | break;
234 | }
235 |
236 | sourceReader.AdvanceTo(buffer.End);
237 | }
238 |
239 | //Complete reader and all branch writers
240 | await sourceReader.CompleteAsync();
241 | for (var i = 0; i < targets.Length; i++)
242 | {
243 | var target = targets[i];
244 | results[i] = await target.CompleteAsync();
245 | }
246 |
247 | return results;
248 | }
249 | catch (Exception innerException)
250 | {
251 | var exception = new BifurcationException("An exception occurred during bifurcation", innerException);
252 |
253 | await sourceReader.CompleteAsync(exception);
254 | for (var i = 0; i < targets.Length; i++)
255 | {
256 | var target = targets[i];
257 | results[i] = await target.CompleteWithExceptionAsync(exception);
258 | }
259 |
260 | if (sourceConfig.BubbleExceptions)
261 | {
262 | throw exception;
263 | }
264 |
265 | return results;
266 | }
267 | }
268 | }
--------------------------------------------------------------------------------
/src/TurnerSoftware.Aqueduct/BifurcationTargetConfig.cs:
--------------------------------------------------------------------------------
1 | using System.IO.Pipelines;
2 |
3 | namespace TurnerSoftware.Aqueduct;
4 |
5 | ///
6 | /// Manages the configuration for a specific bifurcation target.
7 | ///
8 | public class BifurcationTargetConfig
9 | {
10 | internal const int DefaultBlockAfter = 32768;
11 | internal const int DefaultResumeAfter = 16384;
12 | internal const int DefaultMaxTotalBytes = -1;
13 |
14 | ///
15 | /// The reader function that will handle this specific bifurcation target.
16 | ///
17 | public Func> Reader { get; }
18 | ///
19 | /// The individual exception handler for this bifurcation target when exceptions occur in any target during bifurcation.
20 | ///
21 | public Func? ExceptionHandler { get; }
22 | ///
23 | /// The number of unread bytes before writing will block to the bifurcation target.
24 | ///
25 | public int BlockAfter { get; }
26 | ///
27 | /// The number of unread bytes before resuming writing to the bifurcation target.
28 | ///
29 | public int ResumeAfter { get; }
30 | ///
31 | /// The max number of bytes to write to the bifurcation target.
32 | ///
33 | public int MaxTotalBytes { get; }
34 |
35 | ///
36 | /// Creates a new for -based readers.
37 | ///
38 | /// The reader function that will handle this specific bifurcation target.
39 | /// The individual exception handler for this bifurcation target when exceptions occur in any target during bifurcation.
40 | /// The number of unread bytes before writing will block to the bifurcation target. This must be the same or greater than .
41 | /// The number of unread bytes before resuming writing to the bifurcation target. This must be the same or lower than .
42 | /// The max number of bytes to write to the bifurcation target. Use -1 to specify no limit.
43 | ///
44 | public BifurcationTargetConfig(
45 | Func> reader,
46 | Func? exceptionHandler = null,
47 | int blockAfter = DefaultBlockAfter,
48 | int resumeAfter = DefaultResumeAfter,
49 | int maxTotalBytes = DefaultMaxTotalBytes
50 | ) : this(
51 | (pipeReader, cancellationToken) => reader(pipeReader.AsStream(), cancellationToken),
52 | exceptionHandler,
53 | blockAfter,
54 | resumeAfter,
55 | maxTotalBytes
56 | )
57 | { }
58 |
59 | ///
60 | /// Creates a new for -based readers.
61 | ///
62 | /// The reader function that will handle this specific bifurcation target.
63 | /// The individual exception handler for this bifurcation target when exceptions occur during bifurcation.
64 | /// The number of unread bytes before writing will block to the bifurcation target. This must be the same or greater than .
65 | /// The number of unread bytes before resuming writing to the bifurcation target. This must be the same or lower than .
66 | /// The max number of bytes to write to the bifurcation target. Use -1 to specify no limit.
67 | ///
68 | public BifurcationTargetConfig(
69 | Func> reader,
70 | Func? exceptionHandler = null,
71 | int blockAfter = DefaultBlockAfter,
72 | int resumeAfter = DefaultResumeAfter,
73 | int maxTotalBytes = DefaultMaxTotalBytes
74 | )
75 | {
76 | if (blockAfter < resumeAfter)
77 | {
78 | throw new ArgumentException($"{nameof(BlockAfter)} must be equal to or greater than {nameof(ResumeAfter)}", nameof(blockAfter));
79 | }
80 |
81 | if (maxTotalBytes != -1 && maxTotalBytes <= 0)
82 | {
83 | throw new ArgumentException($"Invalid value for {nameof(MaxTotalBytes)}. Must be a value greater than 0, or if there is no limit, -1.", nameof(maxTotalBytes));
84 | }
85 |
86 | Reader = reader;
87 | ExceptionHandler = exceptionHandler;
88 | BlockAfter = blockAfter;
89 | ResumeAfter = resumeAfter;
90 | MaxTotalBytes = maxTotalBytes;
91 | }
92 | }
93 |
94 | ///
95 | /// Manages the configuration for a specific bifurcation target.
96 | ///
97 | public class BifurcationTargetConfig : BifurcationTargetConfig