Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f2fe906
chore(packaging): disable NU5017-tripping empty snupkg generation und…
ottobolyos Jun 11, 2026
f300f9a
ci(workflow): add release-pack matrix gate to prevent multi-TFM regre…
ottobolyos Jun 11, 2026
45f3796
docs(applications): fully qualify NLog.LogLevel cref to silence CS1574
ottobolyos Jun 12, 2026
f256bd4
test(tls): pin SYSLIB0057 X509CertificateLoader migration (RED)
ottobolyos Jun 11, 2026
c45bc47
test(http): pin CA2022 short-read handling for body drain + post body…
ottobolyos Jun 12, 2026
baef0bb
fix(http): complete CA2022 short-read handling + restore net4x lifeti…
ottobolyos Jul 15, 2026
8a38d90
test(http): RED pin — TrimEnd-on-fixed-buffer must stay deleted
ottobolyos Aug 18, 2026
6968d66
fix(http): unify ReadRequestBytes on the short-read accumulator on ev…
ottobolyos Aug 18, 2026
b3ee8be
test(http): pin CA2022 short-read boundary and failure-path FLOOR
ottobolyos Aug 18, 2026
e64330a
docs(tests): add XML doc summaries on CA2022 + TLS loader fixtures
ottobolyos Jun 13, 2026
7cfacae
fix(http): unify DiscardAllAsync short-read handling on every TFM
ottobolyos Aug 20, 2026
7d77bf4
test(http): wire MTConnect.NET-HTTP reference for CA2022 fixtures
ottobolyos Aug 20, 2026
66e24f5
docs(testing): document release-pack multi-TFM CI gate
ottobolyos Aug 20, 2026
fd3574a
test(http): pin pre-cancelled + mid-drip token cases — dime F-IMP-002
ottobolyos Aug 21, 2026
f73fb24
fix(http): thread CancellationToken through ReadRequestBytes — dime F…
ottobolyos Aug 21, 2026
041445c
chore(ci): widen release-pack error/warning grep — dime F-IMP-004
ottobolyos Aug 21, 2026
65a0281
fix(http): thread CancellationToken through vendored Ceen body-read s…
ottobolyos Aug 21, 2026
d137fe0
fix(http): trace-log swallowed transport exceptions + correct stale m…
ottobolyos Aug 21, 2026
27237c2
chore(ci): mixed-case regex + truncation hint + doc alignment — dime …
ottobolyos Aug 21, 2026
33dc843
fix(http): unify SimpleProxyHandler response copy on the tokenful ove…
ottobolyos Aug 21, 2026
d793ae8
fix(http): thread CancellationToken through response WriteAllAsync pa…
ottobolyos Aug 21, 2026
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
90 changes: 90 additions & 0 deletions .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -489,3 +489,93 @@ jobs:
TestResults/**/coverage.cobertura.xml
if-no-files-found: warn
retention-days: 14

# ------------------------------------------------------------------
# Job 4 — Release-pack matrix gate. The build-and-test job above only
# exercises Debug, which compiles each csproj against a single TFM
# (net8.0 — see the per-project `<PropertyGroup Condition="…Debug">`
# blocks). Release / Package configurations multi-target net4x →
# net9.0; some diagnostics only fire on the legacy TFMs (CS0162 from
# `#if NET5_0_OR_GREATER` branches that go unreachable on net4x), and
# some only on net9.0 (SYSLIB0057 X509Certificate2 ctor obsoletion).
# The May-22 regression slipped through CI precisely because Debug
# never built net4x; this job runs `dotnet pack -c Release` across
# the full TFM matrix to catch the next regression class at PR time.
#
# Single-process (no shard), no docs dependency, same draft-skip
# gate as build-and-test. The pack output is written to a workflow-
# local `dist/` directory and discarded — the produced .nupkg files
# are not uploaded as artefacts; the gate's only output is the exit
# code.
# ------------------------------------------------------------------
release-pack:
name: release-pack
if: github.event_name == 'push' || github.event.pull_request.draft == false
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

- name: Setup .NET 8.0 + 9.0
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
with:
dotnet-version: |
8.0.x
9.0.x

- name: Restore solution
run: dotnet restore MTConnect.NET.sln

- name: Pack -c Release across multi-TFM
run: |
rm -rf dist
mkdir -p dist
dotnet pack MTConnect.NET.sln -c Release \
-p:VersionSuffix=ci-check \
-p:ContinuousIntegrationBuild=true \
-o dist 2>&1 | tee pack.log
shell: bash

# Surface a compact summary of every error / warning that
# tripped the pack. The `dotnet pack` step above pipes into
# `tee pack.log`; the unmasked exit propagates via the
# pipeline's PIPESTATUS through `set -o pipefail`, which is
# the GitHub-default for bash steps. This step only runs on
# failure, by design — when the pack is green, the produced
# nupkgs are not inspected further.
#
# The regex matches any MSBuild-shaped diagnostic —
# `error XX0000` / `warning XX0000` where XX is one or more
# letters (CS, CA, NU, SYSLIB, MSB, NETSDK, IL, XA, StyleCop,
# Roslyn analyzers, and mixed-case analyzer families like
# `xUnit1004` / `nunit1001` that ship lowercase prefixes).
# The prior `(CS|CA|NU|SYSLIB|MSB)` enumeration silently
# dropped every family outside that set and produced empty
# summaries when the pack failed on any of them. The
# broadened pattern is a strict superset so no failure
# family is masked. `[A-Za-z]+[0-9]+` keeps the "letters
# then digits" shape so bare `warning:` prose lines and
# counter lines like `2 Warning(s)` are not matched.
#
# The `head -100` cap is a paste-into-summary safety valve;
# when it triggers, a `showing 100 of N` breadcrumb makes
# the truncation explicit so a maintainer triaging a
# summary-visible slice knows the full failure set is
# larger than what is rendered.
- name: Surface pack errors (if any)
if: failure()
run: |
diag_pattern='\b(error|warning)[[:space:]]+[A-Za-z]+[0-9]+'
diag_total=$(grep -cE "$diag_pattern" pack.log 2>/dev/null || echo 0)
diag_unique=$(grep -E "$diag_pattern" pack.log 2>/dev/null | sort -u | wc -l)
echo "### Release-pack diagnostics" >> "$GITHUB_STEP_SUMMARY"
if [ "$diag_unique" -gt 100 ]; then
echo "_Showing first 100 of ${diag_unique} unique diagnostics (${diag_total} total matching lines)._" >> "$GITHUB_STEP_SUMMARY"
elif [ "$diag_unique" -gt 0 ]; then
echo "_${diag_unique} unique diagnostics (${diag_total} total matching lines)._" >> "$GITHUB_STEP_SUMMARY"
fi
echo '```' >> "$GITHUB_STEP_SUMMARY"
grep -E "$diag_pattern" pack.log \
| sort -u | head -100 >> "$GITHUB_STEP_SUMMARY" || true
echo '```' >> "$GITHUB_STEP_SUMMARY"
shell: bash
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public class MTConnectAdapterApplication : IMTConnectAdapterApplication

#if NET5_0_OR_GREATER
/// <summary>NLog log-level applied to every internal logger.
/// Defaults to <see cref="LogLevel.Debug"/>; the <c>debug</c>
/// Defaults to <see cref="NLog.LogLevel.Debug"/>; the <c>debug</c>
/// CLI command overrides it.</summary>
protected LogLevel _logLevel = LogLevel.Debug;
#else
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ public class MTConnectAgentApplication : IMTConnectAgentApplication

#if NET5_0_OR_GREATER
/// <summary>NLog log-level applied to every internal logger.
/// Defaults to <see cref="LogLevel.Debug"/>; the <c>debug</c>
/// Defaults to <see cref="NLog.LogLevel.Debug"/>; the <c>debug</c>
/// and <c>trace</c> CLI commands override it.</summary>
protected LogLevel _logLevel = LogLevel.Debug;
#else
Expand Down
16 changes: 16 additions & 0 deletions docs/testing/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,22 @@ package-write privileges.
The sweep is opt-in via
`dotnet test tests/Compliance/MTConnect-Compliance-Tests/MTConnect-Compliance-Tests.csproj --filter "Category=XsdLoadStrict"`.

## CI workflow — `release-pack` (multi-TFM Release-pack gate)

Sibling job in `.github/workflows/dotnet.yml`. Runs on every push to `master` and every non-draft PR. Executes `dotnet pack MTConnect.NET.sln -c Release` across the full net461 → net9.0 TFM matrix — a stricter surface than `build-test-coverage`, because Release configuration enables NuGet package generation (`.nupkg` + `.snupkg`), rich API doc surfaces on every TFM, and the multi-TFM `SupportedOSPlatform` / `LangVersion` gates the Debug matrix does not exercise.

**Purpose:** guard against the class of regressions the 2026-05-22 landing bypassed — a Debug-only CI floor let CS-family Release-only diagnostics ship as PR-level warnings that only surfaced during release packaging. The gate turns every such regression into a red PR check.

**Exit contract:** must be RC=0 (zero errors, zero MSBuild-shaped diagnostics — every `error XX0000` / `warning XX0000` family, e.g. CS / CA / NU / SYSLIB / MSB / NETSDK / IL / StyleCop / xUnit / NUnit / third-party analyzers) to merge. The remaining MSB3277 assembly-conflict + NETSDK1138 EOL-TFM + transitive-package net461-compat build-summary warnings are pre-existing and are not tracked by this gate; the FLOOR is only against new code-level diagnostics. When the pack fails, the `Surface pack errors` step renders a step-summary table of the unique matching lines (capped at 100 with an explicit truncation breadcrumb naming the full total).

**Local repro** (matches CI):

```bash
dotnet pack MTConnect.NET.sln -c Release
```

The command runs across every TFM configured in each project's `TargetFrameworks`. Failing quickly on net461 (the strictest TFM for `SupportedOSPlatform` / `LangVersion` guards) is the fastest sanity check when triaging a Release-only diagnostic locally.

## Why the integration project is a separate CI step

The integration project drives the in-process Agent + embedded HTTP
Expand Down
10 changes: 10 additions & 0 deletions libraries/MTConnect.NET-HTTP/Ceen/Common/Interfaces.cs
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,16 @@ internal interface IHttpResponse
/// <param name="contenttype">An optional content type to set. Throws an exception if the headers are already sent.</param>
Task WriteAllAsync(Stream data, string contenttype = null);

/// <summary>
/// Copies the stream to the output while honouring the supplied cancellation token. Note that the stream is copied from the current position to the end, and the stream must report the length.
/// Same bug class as MTConnectPostResponseHandler.ReadRequestBytes (dime F-IMP-001): a client abort mid-response must short-circuit the copy rather than fully drain the response body into a disconnected socket.
/// </summary>
/// <returns>The awaitable task</returns>
/// <param name="data">The stream to copy.</param>
/// <param name="cancellationToken">Cancellation token forwarded to the underlying CopyToAsync.</param>
/// <param name="contenttype">An optional content type to set. Throws an exception if the headers are already sent.</param>
Task WriteAllAsync(Stream data, System.Threading.CancellationToken cancellationToken, string contenttype = null);

/// <summary>
/// Writes the byte array to the output.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,17 @@ public async Task<bool> HandleAsync(IHttpContext context, CancellationToken canc
wr.Method = context.Request.Method;
if (context.Request.ContentLength > 0)
using (var rs = await wr.GetRequestStreamAsync())
await context.Request.Body.CopyToAsync(rs);
// Same bug class as MTConnectPostResponseHandler.ReadRequestBytes
// (dime F-IMP-001): a request-body drain must honour the outer
// cancellation token so a client abort short-circuits the copy
// rather than reading the full body into the proxied upstream.
// Uses the (Stream, int bufferSize, CancellationToken) overload
// (universal since .NET 4.5); the 2-arg (Stream, CancellationToken)
// shape is netstandard2.1 / net5+ only and cannot ship on the
// library's net4x / netstandard2.0 targets. The 81920 buffer
// matches the .NET runtime default for the tokenless
// CopyToAsync overload so throughput is unchanged.
await context.Request.Body.CopyToAsync(rs, 81920, cancellationToken);

using (var res = await GetResponseWithoutExceptionAsync(wr))
{
Expand All @@ -83,12 +93,19 @@ public async Task<bool> HandleAsync(IHttpContext context, CancellationToken canc
await context.Response.FlushHeadersAsync();
using (var r = context.Response.GetResponseStream())
using (var rr = res.GetResponseStream())

#if NET5_0_OR_GREATER
await rr.CopyToAsync(r, context.Request.TimeoutCancellationToken);
#else
await rr.CopyToAsync(r);
#endif
// Same bug class as the request-body drain 21 lines above
// (dime F-IMP-005) and the sibling
// MTConnectPostResponseHandler.ReadRequestBytes (dime F-IMP-001):
// a slow upstream response must not block a client abort.
// Uses the universal 3-arg (Stream, int bufferSize,
// CancellationToken) overload so net461-net48 /
// netstandard2.0 / net6.0 also honour the token — the
// pre-existing #if NET5_0_OR_GREATER guard left those six
// TFMs on a tokenless CopyToAsync, letting a slow-response
// upstream fully drain after cancel. The 81920 buffer
// matches the runtime default for the tokenless overload
// so throughput on the happy path is unchanged.
await rr.CopyToAsync(r, 81920, context.Request.TimeoutCancellationToken);
}

return true;
Expand Down
14 changes: 13 additions & 1 deletion libraries/MTConnect.NET-HTTP/Ceen/Httpd/HttpResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -453,12 +453,24 @@ internal async Task FlushAsErrorAsync()
/// <param name="data">The stream to copy.</param>
/// <param name="contenttype">An optional content type to set. Throws an exception if the headers are already sent.</param>
public Task WriteAllAsync(Stream data, string contenttype = null)
=> WriteAllAsync(data, System.Threading.CancellationToken.None, contenttype);

/// <summary>
/// Copies the stream to the output while honouring the supplied cancellation token. Note that the stream is copied from the current position to the end, and the stream must report the length.
/// Same bug class as MTConnectPostResponseHandler.ReadRequestBytes (dime F-IMP-001): a client abort mid-response must short-circuit the copy rather than fully drain the response body into a disconnected socket.
/// Uses the universal (Stream, int bufferSize, CancellationToken) CopyToAsync overload (since .NET 4.5) so every supported TFM honours the token; the 81920 buffer matches the runtime default for the tokenless overload.
/// </summary>
/// <returns>The awaitable task</returns>
/// <param name="data">The stream to copy.</param>
/// <param name="cancellationToken">Cancellation token forwarded to the underlying CopyToAsync.</param>
/// <param name="contenttype">An optional content type to set. Throws an exception if the headers are already sent.</param>
public Task WriteAllAsync(Stream data, System.Threading.CancellationToken cancellationToken, string contenttype = null)
{
if (contenttype != null)
ContentType = contenttype;
if (!HasSentHeaders)
ContentLength = data.Length - data.Position;
return data.CopyToAsync(m_wrappedoutstream);
return data.CopyToAsync(m_wrappedoutstream, 81920, cancellationToken);
}

/// <summary>
Expand Down
18 changes: 16 additions & 2 deletions libraries/MTConnect.NET-HTTP/Ceen/Httpd/HttpServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -203,17 +203,31 @@ public bool WaitForStop(TimeSpan waitdelay)
/// </summary>
public int ActiveClients { get { return Controller.ActiveClients; } }

#if NET5_0_OR_GREATER
/// <summary>
/// Initializes the lifetime service.
/// </summary>
/// <returns>The lifetime service.</returns>
/// <remarks>
/// The base <see cref="MarshalByRefObject.InitializeLifetimeService"/> is
/// only marked obsolete on .NET 5 and newer (CoreCLR removed the .NET
/// Remoting lifetime-service infrastructure there). On .NET Framework
/// the base is not obsolete, and applying
/// <see cref="ObsoleteAttribute"/> on the override would trigger CS0809
/// (obsolete override of non-obsolete base). The attribute is therefore
/// conditioned on net5+ — silencing the net5+ CS0672 (non-obsolete
/// override of obsolete member) without forbidding the override on
/// net4x where remoting is still live. Returning <c>null</c> pins the
/// server object's lifetime to the process on every TFM (without this
/// override, .NET Framework Remoting hands the server a five-minute
/// default lease and may collect it after idle expiry).
/// </remarks>
#if NET5_0_OR_GREATER
[Obsolete("InitializeLifetimeService is obsolete in .NET 5+; the override exists for legacy AppDomain remoting compatibility.")]
#endif
public override object InitializeLifetimeService()
{
return null;
}
#endif
}

/// <summary>
Expand Down
23 changes: 18 additions & 5 deletions libraries/MTConnect.NET-HTTP/Ceen/Httpd/LimitedBodyStream.cs
Original file line number Diff line number Diff line change
Expand Up @@ -153,11 +153,24 @@ public async Task<bool> DiscardAllAsync(System.Threading.CancellationToken cance
var buf = new byte[1024 * 8];
while (m_bytesleft > 0)
{
#if NET9_0_OR_GREATER
await ReadExactlyAsync(buf, 0, buf.Length, cancellationToken);
#else
await ReadAsync(buf, 0, buf.Length, cancellationToken);
#endif
// CA2022 short-read handling — TFM-uniform. Every supported TFM
// lands on the same shape: loop ReadAsync until the transport
// signals EOF (return 0) or the whole body is drained. ReadAsync
// may return fewer bytes than requested on multi-segment TCP
// arrivals; the loop keeps calling until m_bytesleft hits zero
// (drain complete → return true) or a 0-byte read indicates
// premature EOF (return false so the caller can propagate the
// drain failure to the outer HTTP handler).
//
// Do NOT re-introduce a ReadExactlyAsync-into-fixed-buffer shape
// on any TFM: when the remaining body is smaller than buf.Length
// (the common case on the final iteration and on any body
// smaller than 8 KB), ReadExactlyAsync throws
// EndOfStreamException and propagates up through the outer
// HttpServer catch, killing keep-alive and 500-ing the client.
var read = await ReadAsync(buf, 0, buf.Length, cancellationToken);
if (read == 0)
return false;
}

return true;
Expand Down
30 changes: 30 additions & 0 deletions libraries/MTConnect.NET-HTTP/Ceen/Mvc/RestApiHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,24 @@ public virtual async Task<IResult> Post(IHttpContext context)
TData item;
// TODO: Accept non-utf8 ?
// TODO: Get the Json Async version
// Same bug class as MTConnectPostResponseHandler.ReadRequestBytes
// (dime F-IMP-001): the request-body drain honours the request-timeout /
// abort cancellation token surfaced on IHttpRequestInternal so a client
// abort short-circuits the read rather than blocking on the drained
// StreamReader. Precheck the token before starting so a
// pre-cancelled request short-circuits without allocating a reader;
// on .NET 7+, StreamReader.ReadToEndAsync(CancellationToken) then
// honours cancellation mid-read. Older TFMs (net4x, netstandard2.0,
// net6.0) only have the tokenless overload — the precheck is the
// best-effort surface until the vendored Ceen tree drops those TFMs.
context.Request.TimeoutCancellationToken.ThrowIfCancellationRequested();
using (var sr = new StreamReader(context.Request.Body, System.Text.Encoding.UTF8, false))
{
#if NET7_0_OR_GREATER
var str = await sr.ReadToEndAsync(context.Request.TimeoutCancellationToken);
#else
var str = await sr.ReadToEndAsync();
#endif
item = JsonSerializer.Deserialize<TData>(str);
}

Expand Down Expand Up @@ -206,9 +221,24 @@ public virtual async Task<IResult> PutDetail(IHttpContext context, TKey id)
return Status(HttpStatusCode.BadRequest, "Invalid ID");

TData item;
// Same bug class as MTConnectPostResponseHandler.ReadRequestBytes
// (dime F-IMP-001): the request-body drain honours the request-timeout /
// abort cancellation token surfaced on IHttpRequestInternal so a client
// abort short-circuits the read rather than blocking on the drained
// StreamReader. Precheck the token before starting so a
// pre-cancelled request short-circuits without allocating a reader;
// on .NET 7+, StreamReader.ReadToEndAsync(CancellationToken) then
// honours cancellation mid-read. Older TFMs (net4x, netstandard2.0,
// net6.0) only have the tokenless overload — the precheck is the
// best-effort surface until the vendored Ceen tree drops those TFMs.
context.Request.TimeoutCancellationToken.ThrowIfCancellationRequested();
using (var sr = new StreamReader(context.Request.Body, System.Text.Encoding.UTF8, false))
{
#if NET7_0_OR_GREATER
var str = await sr.ReadToEndAsync(context.Request.TimeoutCancellationToken);
#else
var str = await sr.ReadToEndAsync();
#endif
item = JsonSerializer.Deserialize<TData>(str);

}
Expand Down
Loading
Loading