Skip to content

Code-quality review: collapse PooledNntpClient, decompose NntpConnection, idiomatic cleanups (#154)#155

Merged
christiaanderidder merged 17 commits into
mainfrom
refactor/issue-154-code-quality
Jun 20, 2026
Merged

Code-quality review: collapse PooledNntpClient, decompose NntpConnection, idiomatic cleanups (#154)#155
christiaanderidder merged 17 commits into
mainfrom
refactor/issue-154-code-quality

Conversation

@christiaanderidder

Copy link
Copy Markdown
Collaborator

Closes #154.

Works through the code-quality review in order of structural impact, one concern per commit.

1. Collapse PooledNntpClient delegation, move HasError into NntpConnection

The pooled client was ~300 lines of hand-written per-command forwarders kept in lockstep with INntpClient, existing only to flip HasError on a throw and gate access. The faulted-transport flag now lives in NntpConnection (set on read/write IO failures and lost/invalid responses), so:

  • HasError comes from the connection for free.
  • PooledNntpClient collapses to a holder of connection/auth state.
  • the lease exposes the underlying NntpClient directly as its IPooledNntpClient surface.

NntpConnection now tracks disposal and throws ObjectDisposedException once disposed (preserving the pool's post-error contract), and LastActivity is stamped on return to the pool — the interval the idle monitor actually measures.

2. Extract line framing into a socket-free NntpLineFramer

NntpConnection had crossed 1k lines mixing transport/session, line framing and compression. The line-framing/data-block cluster moves into NntpLineFramer: pure byte→line logic over a swappable PipeReader, with its own read-byte counter and read-fault flag, unit-tested over an in-memory pipe with no socket. NntpConnection drops from ~1015 to ~745 lines. Counter overflow now resets only the overflowing counter.

3. De-duplicate the over-read replay in the two compression-install paths

The session-wide DEFLATE layer (RFC 8054) and the per-command decompression scope (ADR-0006) both replayed bytes over-read past the status line through a PrefixStream. That drift-prone step is now a shared ReplayInput helper; the codec choice stays at each call site.

4. Replace MultiValueDictionary with a plain set-valued dictionary

The bespoke type inherited Dictionary<,>, shadowed Count with new (an O(n) recompute and a base-type footgun), and carried Equals/operators/JSON only for its own tests. It was only used transiently to accumulate NZB metadata before NzbDocument made an immutable copy. Replaced with a standard Dictionary<string, ICollection<string>> plus a small AddValue extension; the now-unused MultiSetComparer is removed too.

5. Idiomatic modernizations

  • UsenetEncoding.DefaultEncoding.Latin1 (no code-page lookup).
  • NzbParser/StringExtensions regexes → source-generated [GeneratedRegex].
  • The IAsyncDisposable suggestion for the graceful QUIT is deliberately deferred: making the pooled-client dispose async ripples into IPooledNntpClientLease/the pool's documented dispose-outside-lock threading and touches public API, which is out of scope for a local cleanup.

6. Smaller readability cleanups

  • ArticleResponseParser ctor: split the assignment-in-expression into two statements.
  • NzbParser.ParseDocument: only re-query the nzb element on the no-namespace fallback.
  • Duplicated dot-unstuffing rules now carry a cross-reference doc comment; the both-counter overflow reset was split per-counter (in Bump System.Collections.Immutable from 5.0.0 to 8.0.0 #2).

Verification

  • dotnet csharpier check . clean.
  • dotnet build -c Release and dotnet test -c Release: 308 tests pass (incl. new NntpLineFramerTests).
  • Net −482 lines.

Move the faulted-transport flag down into NntpConnection (set on read/write
IO failures and lost/invalid responses), so the pool reads HasError straight
from the connection. This removes the ~300 lines of hand-written per-command
forwarders in PooledNntpClient: the pooled client collapses to a holder of the
connection/auth state, and the lease exposes the underlying NntpClient directly
as its IPooledNntpClient command surface.

NntpConnection now tracks disposal and throws ObjectDisposedException once
disposed, preserving the pool's post-error contract. LastActivity is stamped on
return to the pool, which is the interval the idle monitor actually measures.
NntpConnection had crossed 1k lines by mixing transport/session, line framing
and compression. Move the line-framing/data-block cluster (ReadLineAsync,
TryReadLine, DecodeLine, ReadDataBlockToBufferAsync, AppendLine(s),
EnsureCapacity, ProcessLine) into NntpLineFramer: pure byte->line logic over a
swappable PipeReader, with its own read-byte counter and read-fault flag.

The connection now holds a framer and swaps its Reader when rebuilding the
transport for compression. HasError is OR'd from the connection's write/response
faults and the framer's read faults. The framer is unit-tested over an in-memory
pipe with no socket. NntpConnection drops from ~1015 to ~745 lines.

Counter overflow now resets only the overflowing counter (read counting lives in
the framer, writes in the connection), which also addresses the surprising
both-counter reset noted in the review.
InstallDeflateLayer (session-wide DEFLATE, RFC 8054) and the per-command
InstallDecompressionScopeAsync (ADR-0006) both wrapped any bytes the plaintext
reader buffered past the status line in a PrefixStream over the live stream so a
coalesced segment still decodes. Factor that subtle, drift-prone step into a
shared ReplayInput helper; the codec choice (raw deflate vs the sniffed
zlib/gzip/deflate switch) stays at each call site.
MultiValueDictionary inherited Dictionary<,> and shadowed Count with 'new',
recomputing an O(n) flattened sum on every read and leaving a footgun for any
code that touched it through the base type. It was only ever used transiently in
NZB parsing/building to accumulate metadata before NzbDocument materialized an
immutable copy, and its Equals/operators/JSON surface existed solely for its own
tests.

Drop the bespoke class (and its now-unused MultiSetComparer) in favour of a
standard Dictionary<string, ICollection<string>> plus a small AddValue extension
that creates the backing HashSet on first use. NzbDocument and its
ToImmutableDictionaryWithHashSets conversion are unchanged.
- UsenetEncoding.Default now uses the static, allocation-free Encoding.Latin1
  instead of the Encoding.GetEncoding("iso-8859-1") code-page lookup (byte-for-byte
  identical on modern .NET).
- NzbParser.FileNameRegex and StringExtensions.WhitespaceRegex switch from
  RegexOptions.Compiled to source-generated [GeneratedRegex] partial methods: no
  runtime codegen, AOT-friendly and faster startup.
- ArticleResponseParser: split the assignment-in-expression
  (_successCode = (_requestType = requestType) switch ...) into two plain
  statements that are easier to scan.
- NzbParser.ParseDocument: only re-query the nzb element when falling back to the
  no-namespace form, instead of always doing the lookup twice.

(The duplicated dot-unstuffing rules now carry a cross-reference doc comment and
the both-counter overflow reset was split per-counter as part of the framer
extraction.)
The internal pool wrapper IInternalPooledNntpClient/PooledNntpClient was
easily confused with the public command surface IPooledNntpClient (impl
NntpClient), since the concrete PooledNntpClient implemented the *internal*
interface, not IPooledNntpClient.

Rename the pool-facing concept to read as a pool entry:
- IInternalPooledNntpClient -> INntpPoolEntry
- PooledNntpClient          -> NntpPoolEntry
- lease field _client       -> _entry

Public API (IPooledNntpClient, IPooledNntpClientLease, NntpClient) is
unchanged.
Document the NntpPoolEntry concept (renamed from PooledNntpClient) in
CONTEXT.md and docs/architecture.md so the ubiquitous-language glossary
stays complete and the wrapper isn't reconfused with the public
IPooledNntpClient command surface.
XZVER/XZHDR (compressed overview/header) were added to the composite
INntpClient but never to IPooledNntpClient, so pooled-lease callers could
not reach them without downcasting the leased client to NntpClient.

These are plain command surfaces like the RFC interfaces the lease already
exposes (the pool only deliberately withholds auth and connection control),
and compressed overview is exactly what pooled consumers fetch. Add
INntpClientXz to IPooledNntpClient. NntpClient already implements it, so no
other changes are needed; the public API tracking files are unaffected.
The namespace cleanup moved several public types (e.g. NntpHeaders ->
Usenet.Nntp.Models) and recorded the new-namespace entries in
PublicAPI.Unshipped.txt, but the corresponding old-namespace entries were
never removed from PublicAPI.Shipped.txt. On a clean build this left 154
stale RS0017 entries (symbol declared in the API file but no longer found).

Promote all unshipped entries into Shipped, apply the *REMOVED* markers, and
drop the 154 stale old-namespace lines. Verified against the analyzer as the
source of truth: clean build now reports RS0016=0 and RS0017=0 for both
net8.0 and net10.0. Unshipped is reset to the bare header.
The namespace-cleanup commit (1ffef2c) garbled three predicates in the pool,
apparently from a bad find/replace:

- ReturnClient dropped errored connections via HasError, but the check had
  become 'HasPendingStream || HasPendingStream', so a connection that errored
  (e.g. server reset mid-command) was handed back to the pool instead of being
  disposed. This regressed DisposeClientAfterError.
- BorrowClient connected only when '!Authenticated' instead of '!Connected',
  and the post-conditions/log read 'Authenticated' where 'Connected' was meant.

Restore the intended Connected/HasError logic. Full test suite green (308).
@github-actions

Copy link
Copy Markdown
🟢 Usenet.Tests (.NETCoreApp,Version=v10.0)

✓  Passed✘  Failed↷  Skipped∑  Total⧗  Elapsed
3083082s
### ✅ Usenet.Tests (.NET 10.0)

308 tests completed in 3.0s100.0% passed

Tip: You can have HTML reports uploaded automatically as artifacts. Learn more


@github-actions

Copy link
Copy Markdown

Summary

Summary
Generated on: 06/20/2026 - 11:01:26
Parser: Cobertura
Assemblies: 1
Classes: 92
Files: 82
Line coverage: 75.8% (3761 of 4960)
Covered lines: 3761
Uncovered lines: 1199
Coverable lines: 4960
Total lines: 8931
Branch coverage: 71.3% (1031 of 1446)
Covered branches: 1031
Total branches: 1446
Method coverage: Feature is only available for sponsors
Tag: 314_27869143420

Coverage

Usenet - 75.8%
Name Line Branch
Usenet 75.8% 71.3%
System.Text.RegularExpressions.Generated 61.1% 43.1%
Usenet.DependencyInjection.UsenetServiceCollectionExtensions 100% 100%
Usenet.Exceptions.InvalidNzbDataException 33.3%
Usenet.Exceptions.InvalidYencDataException 33.3%
Usenet.Exceptions.NntpException 33.3%
Usenet.Extensions.DictionaryExtensions 100% 87.5%
Usenet.Extensions.DictionaryExtensions<TKey, TValue> 100% 87.5%
Usenet.Extensions.LogExtensions 46.6% 28.8%
Usenet.Extensions.NntpMessageIdExtensions 100%
Usenet.Extensions.StreamExtensions 0% 0%
Usenet.Extensions.StreamWriterExtensions 0%
Usenet.Extensions.StringExtensions 100% 100%
Usenet.Nntp.Builders.NntpArticleBuilder 56.7% 52.7%
Usenet.Nntp.Builders.NntpGroupsBuilder 60% 66.6%
Usenet.Nntp.Client.NntpClient 42% 47.6%
Usenet.Nntp.Client.NntpClient 42% 47.6%
Usenet.Nntp.Client.NntpConnection 84.2% 77.1%
Usenet.Nntp.Client.NntpConnection.CountingBufferWriter 85.7%
Usenet.Nntp.Client.NntpConnection.PrefixStream 35.4% 50%
Usenet.Nntp.Client.NntpConnection 84.2% 77.1%
Usenet.Nntp.Client.NntpConnection 84.2% 77.1%
Usenet.Nntp.Client.NntpConnectionOptions 100%
Usenet.Nntp.Client.NntpLineFramer 80.8% 74.1%
Usenet.Nntp.Client.Pooling.NntpClientPool 75.9% 65.9%
Usenet.Nntp.Client.Pooling.NntpPoolEntry 95.1% 70%
Usenet.Nntp.Client.Pooling.NntpPoolOptions 100%
Usenet.Nntp.Client.Pooling.PooledNntpClientLease 91.6% 50%
Usenet.Nntp.Models.NntpArticle 83.8% 50%
Usenet.Nntp.Models.NntpArticleRange 87.5% 80%
Usenet.Nntp.Models.NntpCapabilities 100% 50%
Usenet.Nntp.Models.NntpDateTime 77.2% 0%
Usenet.Nntp.Models.NntpGroup 92.5% 80%
Usenet.Nntp.Models.NntpGroupOrigin 82.3% 62.5%
Usenet.Nntp.Models.NntpGroups 95.4% 75%
Usenet.Nntp.Models.NntpHeaderCollection 93.5% 88.8%
Usenet.Nntp.Models.NntpMessageId 89.4% 66.6%
Usenet.Nntp.Models.NntpOverviewFormat 100%
Usenet.Nntp.Parsers.ArticleResponseParser 95.9% 77.7%
Usenet.Nntp.Parsers.CapabilitiesResponseParser 100% 100%
Usenet.Nntp.Parsers.DateResponseParser 100% 100%
Usenet.Nntp.Parsers.DistribPatsResponseParser 100% 100%
Usenet.Nntp.Parsers.DistributionsResponseParser 100% 100%
Usenet.Nntp.Parsers.GroupOriginsResponseParser 82.1% 75%
Usenet.Nntp.Parsers.GroupResponseParser 92.3% 64.2%
Usenet.Nntp.Parsers.GroupsParser 100% 87.5%
Usenet.Nntp.Parsers.GroupsResponseParser 75.4% 68.7%
Usenet.Nntp.Parsers.HeaderDateParser 96.9% 71.5%
Usenet.Nntp.Parsers.LastResponseParser 86.9% 78.5%
Usenet.Nntp.Parsers.ModeratorsResponseParser 100% 100%
Usenet.Nntp.Parsers.ModeReaderResponseParser 94.7% 100%
Usenet.Nntp.Parsers.NextResponseParser 86.9% 78.5%
Usenet.Nntp.Parsers.NntpStreamLineParsers 66.9% 43.7%
Usenet.Nntp.Parsers.OverviewFormatResponseParser 100% 100%
Usenet.Nntp.Parsers.PostingStatusParser 100% 93.7%
Usenet.Nntp.Parsers.ResponseParser 100%
Usenet.Nntp.Parsers.StatResponseParser 86.9% 78.5%
Usenet.Nntp.Parsers.SubscriptionsResponseParser 100% 100%
Usenet.Nntp.Parsers.TextResponseParser 100%
Usenet.Nntp.Responses.NntpArticleResponse 100% 77.2%
Usenet.Nntp.Responses.NntpDateResponse 100%
Usenet.Nntp.Responses.NntpGroupOriginsResponse 100%
Usenet.Nntp.Responses.NntpGroupResponse 100%
Usenet.Nntp.Responses.NntpGroupsResponse 100%
Usenet.Nntp.Responses.NntpLastResponse 100%
Usenet.Nntp.Responses.NntpModeReaderResponse 100%
Usenet.Nntp.Responses.NntpNextResponse 100%
Usenet.Nntp.Responses.NntpResponse 100%
Usenet.Nntp.Responses.NntpStatResponse 100%
Usenet.Nntp.Responses.NntpStreamResponse 87% 61.1%
Usenet.Nntp.Responses.NntpStreamResponse 50%
Usenet.Nntp.Responses.NntpTextResponse 100%
Usenet.Nntp.Writers.ArticleWriter 79.5% 80%
Usenet.Nzb.NzbBuilder 94.1% 91.6%
Usenet.Nzb.NzbBuilder.File 100%
Usenet.Nzb.NzbDocument 60.4% 60%
Usenet.Nzb.NzbFile 87.5% 85%
Usenet.Nzb.NzbParser 91.5% 100%
Usenet.Nzb.NzbSegment 73.6% 70%
Usenet.Nzb.NzbWriter 99% 100%
Usenet.Nzb.TextWriterExtensions 75%
Usenet.Util.HashCode 33.3% 33.3%
Usenet.Util.HashCode<TKey, TValue> 33.3% 33.3%
Usenet.Util.PooledBuffer 86% 83.3%
Usenet.Util.PooledBufferDiagnostics 100%
Usenet.Util.UsenetEncoding 100%
Usenet.Yenc.YencDecoder 85.1% 77%
Usenet.Yenc.YencDecoder.LineReader 80.7% 70%
Usenet.Yenc.YencEncoder 97% 96.1%
Usenet.Yenc.YencFooter 100%
Usenet.Yenc.YencHeader 100%
Usenet.Yenc.YencMeta 62% 56.4%
Usenet.Yenc.YencPart 88.4% 50%

@christiaanderidder christiaanderidder merged commit 90856d1 into main Jun 20, 2026
1 check passed
@christiaanderidder christiaanderidder deleted the refactor/issue-154-code-quality branch June 20, 2026 11:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Code-quality review: collapse PooledNntpClient delegation, decompose NntpConnection (>1k LOC), and idiomatic-.NET cleanups

1 participant