Skip to content
Merged
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
4 changes: 2 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ These are behavioral contracts rather than formatting rules, which is why they l
- **Async methods carry the `Async` suffix** and take an optional `CancellationToken cancellationToken = default`, passed through to the underlying call rather than ignored. An async overload returning several values returns a tuple, since `out` parameters are unavailable there.
- **`Download`** reuses a thread-safe `Lazy<HttpClient>`. `GetContentInfo()` reads with `HttpCompletionOption.ResponseHeadersRead`, so asking for a size never fetches the body, while `DownloadString()` buffers the whole response and a large body belongs in `DownloadFile()` instead. A download to a file truncates and rewrites the destination in place, so its permissions, ownership, and any links to it survive. The destination is truncated once the response headers are accepted rather than once the body has arrived, so a request that fails before that leaves it untouched, while one that fails partway through the body leaves a short file.
- **`FileEx`** wraps its I/O in retry logic configured through the static `FileEx.Options`, a `FileExOptions`, and honors cancellation from both `FileEx.Options.Cancel` and the method's own token parameter.
- **`StringCompression`** uses Deflate, takes a configurable compression level, and passes `leaveOpen` so the caller keeps ownership of the stream it supplied.
- **`StringHistory`** retains at most `MaxFirstLines` from the head and `MaxLastLines` from the tail. Zero on a single side retains no lines on that side, and zero on both is the one unrestricted mode. Either limit rejects a negative value, and assigning one re-partitions the lines already stored, so the history never holds more than the limits then in force allow. Re-partitioning only discards: once a line has been dropped the head is closed, so a later, larger `MaxFirstLines` never promotes a retained tail line into it.
- **`StringCompression`** uses Deflate and takes a configurable compression level. It works in strings rather than streams, so a caller hands it no stream to own.
- **`StringHistory`** retains at most `MaxFirstLines` from the head and `MaxLastLines` from the tail. Zero on a single side retains no lines on that side, and zero on both is the one unrestricted mode. Either limit rejects a negative value, and assigning one re-partitions the lines already stored, so the history never holds more than the limits then in force allow. Re-partitioning only discards: once a line has been dropped the head is closed, so a later, larger `MaxFirstLines` never promotes a retained tail line into it. `SetLimits()` applies both limits in one re-partition, which assigning the two properties in turn cannot do.
- **`CompressExtensions`** uses the C# `extension` block form inside a static class for its string helpers, and the internal `LogExtensions` does the same for the logger helpers.
- **Logging is a seam, never a dependency.** The library depends on `Microsoft.Extensions.Logging.Abstractions` and takes an `ILoggerFactory` through `LogOptions`. It references no logging framework or sink, so a consumer chooses its own. `Serilog` appears only in `Sandbox` and the tests, where an application legitimately picks one.
3 changes: 2 additions & 1 deletion HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ Some useful and not so useful C# .NET utility classes.

- v4.1:
- Fixed `Download.DownloadFile()` and `DownloadFileAsync()` corrupting the destination file: both opened it with `File.OpenWrite()`, which does not truncate, so a download over a longer existing file left that file's trailing bytes after the downloaded content and reported success. Both now truncate the destination explicitly and rewrite it in place, which keeps its permissions, ownership, and any links to it. The truncation happens once the response headers are accepted rather than once the body has arrived, so a download that fails partway now leaves a short file where it previously left the original bytes behind the new ones.
- Added `StringHistory.SetLimits()`, which applies both limits in one re-partition. Assigning `MaxFirstLines` and `MaxLastLines` one after the other re-partitions twice, so the first assignment measures against the other limit's previous value and can discard lines the final pair would have retained. Which order avoids that, where either does, depends on the values and on what is stored, so no fixed ordering is safe.
- Tightened the `StringHistory` limit contract: `MaxFirstLines` and `MaxLastLines` now document zero as retaining no lines on that side rather than as no limit (both at zero remains the unrestricted mode), reject a negative value with `ArgumentOutOfRangeException` at the constructor and at the property rather than at a later `AppendLine()`, and re-partition the lines already stored when assigned, so a limit set after appending is honored instead of ignored. `AppendLine()` changed with them: what is retained is now always a prefix of the appended lines followed by a suffix of them, so once a line has been discarded the head is trimmed but never refilled, and a later, larger `MaxFirstLines` raises the ceiling without adopting retained tail lines as first lines.
- v4.0:
- Added `HttpClientFactory`, a reusable resilient HTTP client factory built on `Microsoft.Extensions.Http.Resilience` (Polly) with retry, circuit breaker, and connection pooling, tunable through the new `HttpClientOptions`. It exposes a shared singleton client, caller-owned clients, and the resilience handler for callers that build their own client with a custom base address or headers.
- Added `AssemblyInfo`, an AOT safe assembly and application identity helper whose `For<T>()` substitutes for `Assembly.GetExecutingAssembly()` (unreliable under Native AOT), and which supplies the consuming application name, version, and a default User-Agent.
- Added `AssemblyInfo`, an AOT-safe assembly and application identity helper whose `For<T>()` substitutes for `Assembly.GetExecutingAssembly()` (unreliable under Native AOT), and which supplies the consuming application name, version, and a default User-Agent.
- Reworked `Download` to build its `HttpClient` through `HttpClientFactory`, so downloads now flow through the shared retry and circuit-breaker pipeline. The `TimeoutSeconds` property and all method signatures are unchanged.
- Changed public members that exposed `List<T>` to safe collection types (a breaking API change): `FileEx.EnumerateDirectories()` / `EnumerateDirectory()` now return `Collection<T>` out parameters and accept `IEnumerable<string>`, and `StringHistory.StringList` is now a `ReadOnlyCollection<string>`.
- Gated the library's reference AOT verification behind an explicit `PublishAot` opt-in, and turned the `Sandbox` project into a Native AOT smoke test (published and run as AOT) that proves the resilience pipeline and assembly-identity resolution work under Native AOT.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Some useful and not so useful C# .NET utility classes.
**Version: 4.1**:

- Fixed `Download.DownloadFile()` and `DownloadFileAsync()` corrupting the destination when downloading over a longer existing file. The destination is now truncated and rewritten in place, keeping its permissions and any links to it. A download that fails partway leaves a short file rather than a mix of the new content and the old.
- Added `StringHistory.SetLimits()` to apply both limits in one re-partition, which assigning the two properties in turn cannot do.
- Fixed the `StringHistory` limit properties: `MaxFirstLines` and `MaxLastLines` now honor a limit assigned after lines have been appended, document zero on one side as retaining no lines on that side rather than as no limit, with both at zero remaining the one unrestricted mode, and reject a negative value with `ArgumentOutOfRangeException`.

See [Release History](./HISTORY.md) for complete release notes and older versions.
Expand Down
8 changes: 4 additions & 4 deletions Utilities/AssemblyInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@
namespace ptr727.Utilities;

/// <summary>
/// Provides AOT and trim safe access to assembly and consuming-application identity.
/// Provides AOT-safe and trim-safe access to assembly and consuming-application identity.
/// </summary>
/// <remarks>
/// Native AOT does not support <see cref="Assembly.GetExecutingAssembly"/> reliably
/// (see https://github.com/dotnet/runtime/issues/94200). Use <see cref="For{T}"/> as the
/// AOT safe substitute, passing a marker type from the assembly of interest. The
/// AOT-safe substitute, passing a marker type from the assembly of interest. The
/// application identity members make no assumptions about the hosting environment and
/// fall back gracefully when the managed entry assembly is unavailable.
/// </remarks>
Expand All @@ -21,7 +21,7 @@ public static class AssemblyInfo
/// <typeparam name="T">A marker type from the assembly of interest.</typeparam>
/// <returns>The assembly that defines <typeparamref name="T"/>.</returns>
/// <remarks>
/// This is the AOT safe replacement for <see cref="Assembly.GetExecutingAssembly"/>:
/// This is the AOT-safe replacement for <see cref="Assembly.GetExecutingAssembly"/>:
/// <c>typeof(T).Assembly</c> resolves at compile time with no stack walk or reflection.
/// </remarks>
public static Assembly For<T>() => typeof(T).Assembly;
Expand Down Expand Up @@ -49,7 +49,7 @@ public static string AppName
/// Gets the version of the consuming application (the entry assembly), or a fallback value.
/// </summary>
/// <remarks>
/// Uses the AOT safe <see cref="AssemblyName.Version"/> rather than reflecting over
/// Uses the AOT-safe <see cref="AssemblyName.Version"/> rather than reflecting over
/// informational-version attributes, which the trimmer or AOT compiler may strip.
/// </remarks>
public static string AppVersion =>
Expand Down
12 changes: 6 additions & 6 deletions Utilities/HttpClientFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ namespace ptr727.Utilities;

/// <summary>
/// Creates resilient <see cref="HttpClient"/> instances with retry and circuit-breaker
/// policies, connection pooling, and an AOT safe default User-Agent.
/// policies, connection pooling, and an AOT-safe default User-Agent.
/// </summary>
/// <remarks>
/// Use <see cref="GetClient"/> for a shared singleton, <see cref="CreateClient(HttpClientOptions)"/>
Expand Down Expand Up @@ -166,11 +166,11 @@ private static bool IsTransientFailure(Outcome<HttpResponseMessage> outcome)
&& IsTransientStatusCode((int)outcome.Result.StatusCode);
}

// Retry known-transient failures: a request timeout (a cancellation with an inner
// TimeoutException), a network or IO error (an HttpRequestException with no status, or an
// IOException), or an HttpRequestException whose own status code is transient (408, 429,
// >= 500). Caller cancellation, an open circuit, a 4xx status, and any other exception
// (including programming errors) are not retried.
// Retry only known-transient failures.
// - A request timeout, which is a cancellation with an inner TimeoutException.
// - A network or IO error, an HttpRequestException with no status or an IOException.
// - An HttpRequestException whose own status code is transient, 408, 429 or >= 500.
// Caller cancellation, an open circuit, and anything not listed above are not retried.
return outcome.Exception switch
{
OperationCanceledException canceled => canceled.InnerException is TimeoutException,
Expand Down
42 changes: 36 additions & 6 deletions Utilities/StringHistory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ public class StringHistory
/// <summary>
/// Initializes a new instance of the <see cref="StringHistory"/> class with specified limits.
/// </summary>
/// <param name="maxFirstLines">Maximum number of first lines to retain, or 0 to retain none.</param>
/// <param name="maxLastLines">Maximum number of last lines to retain, or 0 to retain none.</param>
/// <param name="maxFirstLines">Maximum number of first lines to retain, or 0 to retain none on that side.</param>
/// <param name="maxLastLines">Maximum number of last lines to retain, or 0 to retain none on that side.</param>
/// <exception cref="ArgumentOutOfRangeException">
/// Thrown when <paramref name="maxFirstLines"/> or <paramref name="maxLastLines"/> is negative.
/// </exception>
Expand Down Expand Up @@ -107,6 +107,36 @@ public override string ToString() =>
string.Join(Environment.NewLine, _stringList)
+ (_stringList.Count > 0 ? Environment.NewLine : string.Empty);

/// <summary>
/// Sets both limits together, re-partitioning the stored lines once against the pair.
/// </summary>
/// <param name="maxFirstLines">Maximum number of first lines to retain, or 0 to retain none on that side.</param>
/// <param name="maxLastLines">Maximum number of last lines to retain, or 0 to retain none on that side.</param>
/// <exception cref="ArgumentOutOfRangeException">
/// Thrown when <paramref name="maxFirstLines"/> or <paramref name="maxLastLines"/> is negative.
/// </exception>
/// <remarks>
/// Assigning <see cref="MaxFirstLines"/> and <see cref="MaxLastLines"/> one after the other
/// re-partitions twice, so the first assignment measures against the other limit's previous
/// value and can discard lines the final pair would have retained. Which order avoids that,
/// where either does, depends on the values and on what is stored, so no fixed ordering is safe
/// and this applies both before re-partitioning at all.
/// Both limits at zero is the one unrestricted mode, so passing zero twice retains every stored
/// line and every later one rather than discarding them.
/// </remarks>
public void SetLimits(int maxFirstLines, int maxLastLines)
{
// Validated here so the exception names the caller's own parameter rather than "value".
ArgumentOutOfRangeException.ThrowIfNegative(maxFirstLines);
ArgumentOutOfRangeException.ThrowIfNegative(maxLastLines);

// Assigned to the fields rather than through the setters.
// Those re-partition once each, which is the order dependence this method removes.
_maxFirstLines = maxFirstLines;
_maxLastLines = maxLastLines;
Repartition();
}

/// <summary>
/// Gets or sets the maximum number of first lines to retain.
/// Set to 0 to retain no first lines. Every line is retained only when both limits are 0.
Expand All @@ -115,8 +145,8 @@ public override string ToString() =>
/// Assigning this re-partitions the lines already stored against the limits then in force,
/// which discards whatever the new limits exclude and never recovers a line already dropped.
/// Setting both limits therefore applies them one at a time, and the first assignment can
/// discard lines the second would have retained. Prefer the two-argument constructor when both
/// limits are known up front.
/// discard lines the second would have retained. Use <see cref="SetLimits"/> to apply both at
/// once, or the two-argument constructor when both limits are known up front.
/// </remarks>
/// <exception cref="ArgumentOutOfRangeException">Thrown when the assigned value is negative.</exception>
public int MaxFirstLines
Expand All @@ -138,8 +168,8 @@ public int MaxFirstLines
/// Assigning this re-partitions the lines already stored against the limits then in force,
/// which discards whatever the new limits exclude and never recovers a line already dropped.
/// Setting both limits therefore applies them one at a time, and the first assignment can
/// discard lines the second would have retained. Prefer the two-argument constructor when both
/// limits are known up front.
/// discard lines the second would have retained. Use <see cref="SetLimits"/> to apply both at
/// once, or the two-argument constructor when both limits are known up front.
/// </remarks>
/// <exception cref="ArgumentOutOfRangeException">Thrown when the assigned value is negative.</exception>
public int MaxLastLines
Expand Down
Loading