refactor: cleanup sweep across the SDK #82

Merged
Grandiras merged 3 commits from cleanup/sweep-2026-08 into main 2026-08-06 02:39:18 +00:00
Owner

An extensive cleanup sweep: trim redundant code, remove dead weight, and fix the shortcomings that turned up along the way. 44 files, -323 net lines in src/ (-231 counting the new tests), all 1167 unit tests pass.

The bug worth reviewing first

XRPC array query parameters were sent comma-joined. 21 call sites built ?dids=a,b where XRPC specifies repeated keys (?dids=a&dids=b), so any endpoint taking an array silently received a single malformed element. AdminClient.GetAccountInfosAsync was the giveaway — it built the correct repeated-key list and then threw it away in favour of string.Join(",", dids).

Affected: feed.getPosts, feed.getFeedGenerators, actor.getProfiles, labeler.getServices, graph.getRelationships, graph.getStarterPacks, admin.getAccountInfos, label.queryLabels (two params), convo.getConvoForMembers/getConvoAvailability, ozone.signature.searchAccounts, and the eight array filters on ozone.moderation.queryEvents/querySubjects. Callers passing one element were fine; callers passing several were querying one bogus identifier.

How the fix and the cleanup are the same change

Lexicon clients hand-rolled a Dictionary<string, string?> at 78 call sites, complete with limit?.ToString() and includePins?.ToString()?.ToLowerInvariant(). A dictionary structurally cannot hold repeated keys, which is why the arrays got joined. They now use a new internal XrpcParams builder that drops nulls, formats invariantly, and offers AddAll for arrays.

Other deduplication:

  • XrpcClient's eight near-identical query/procedure overloads (public + proxied) delegate to one SendAsync pair. Declared-type JSON serialization is preserved via a content factory, since the DPoP/429 retry path replays the request and HttpContent cannot be sent twice.
  • RecordCollection's Get/GetFrom, List/ListFrom, Enumerate/EnumerateFrom — the *From variant is now the implementation.
  • AtProtoClient: four identical delete-by-AT-URI bodies → one helper; two relay-URL guards → one; three hand-copied new Session { … } blocks → new Session.With(...).
  • Both Aspire PDS hosting integrations share one generic Replace helper.

Dead code removed: XrpcQueryBuilder.BuildQueryString (unused outside its own tests, duplicating XrpcClient.BuildUrl), its ToDictionary alias, ModerationClient.AddListParams, Cid's never-called GeneratedRegex, and an ILogger field threaded through 21 Lexicon clients that none of them ever wrote to.

Other fixes found along the way

  • RecordCollection.ListAsync suppressed failed deserialization with value!, producing a RecordView<T> that violated its own required T Value contract and threw NullReferenceException somewhere downstream. Now throws where the failure is, naming the record URI.
  • AtProtoClientFactory leaked an ECDSA native handle on every failed client build — the AtProtoClient and DPoP session were constructed before ApplyOAuthSessionAsync took ownership, and nothing disposed them if it threw.
  • FileAtProtoTokenStore wrote token files in place (a crash mid-write loses the refresh token) and deleted them outside the lock guarding writes (a logout racing a rotation leaves the file behind). Now writes temp-then-move under one lock, and creates files owner-only on Unix.
  • The fallback AtProtoHttpException discarded the response body it had already read, exactly when the message was least useful. The parse failure was also caught with a bare catch (Exception).
  • Two comments described behaviour the code does not have — most notably AtProtoClientFactory claiming the XRPC client refreshes on ExpiredToken. It does not, anywhere; per-request clients silently never refreshed.

API surface

No signature changed. Two additive members: Session.With(...) and an init accessor on AtProtoHttpException.ResponseBody. The 21 de-loggered constructors are internal.

Tests

XrpcParams gets its own suite (ordering, null-dropping, invariant formatting, AddAll), XrpcQueryBuilder's tests are retargeted onto the surviving entry point plus a culture-sensitivity case, and LabelerClientTests now asserts the repeated-key encoding on the wire so the array bug cannot come back.

🤖 Generated with Claude Code

An extensive cleanup sweep: trim redundant code, remove dead weight, and fix the shortcomings that turned up along the way. **44 files, -323 net lines in `src/` (-231 counting the new tests), all 1167 unit tests pass.** ## The bug worth reviewing first **XRPC array query parameters were sent comma-joined.** 21 call sites built `?dids=a,b` where XRPC specifies repeated keys (`?dids=a&dids=b`), so any endpoint taking an array silently received a single malformed element. `AdminClient.GetAccountInfosAsync` was the giveaway — it built the correct repeated-key list and then threw it away in favour of `string.Join(",", dids)`. Affected: `feed.getPosts`, `feed.getFeedGenerators`, `actor.getProfiles`, `labeler.getServices`, `graph.getRelationships`, `graph.getStarterPacks`, `admin.getAccountInfos`, `label.queryLabels` (two params), `convo.getConvoForMembers`/`getConvoAvailability`, `ozone.signature.searchAccounts`, and the eight array filters on `ozone.moderation.queryEvents`/`querySubjects`. Callers passing one element were fine; callers passing several were querying one bogus identifier. ## How the fix and the cleanup are the same change Lexicon clients hand-rolled a `Dictionary<string, string?>` at **78 call sites**, complete with `limit?.ToString()` and `includePins?.ToString()?.ToLowerInvariant()`. A dictionary structurally cannot hold repeated keys, which is why the arrays got joined. They now use a new internal `XrpcParams` builder that drops nulls, formats invariantly, and offers `AddAll` for arrays. Other deduplication: - `XrpcClient`'s eight near-identical query/procedure overloads (public + proxied) delegate to one `SendAsync` pair. Declared-type JSON serialization is preserved via a content *factory*, since the DPoP/429 retry path replays the request and `HttpContent` cannot be sent twice. - `RecordCollection`'s `Get`/`GetFrom`, `List`/`ListFrom`, `Enumerate`/`EnumerateFrom` — the `*From` variant is now the implementation. - `AtProtoClient`: four identical delete-by-AT-URI bodies → one helper; two relay-URL guards → one; three hand-copied `new Session { … }` blocks → new `Session.With(...)`. - Both Aspire PDS hosting integrations share one generic `Replace` helper. Dead code removed: `XrpcQueryBuilder.BuildQueryString` (unused outside its own tests, duplicating `XrpcClient.BuildUrl`), its `ToDictionary` alias, `ModerationClient.AddListParams`, `Cid`'s never-called `GeneratedRegex`, and an `ILogger` field threaded through 21 Lexicon clients that none of them ever wrote to. ## Other fixes found along the way - `RecordCollection.ListAsync` suppressed failed deserialization with `value!`, producing a `RecordView<T>` that violated its own `required T Value` contract and threw `NullReferenceException` somewhere downstream. Now throws where the failure is, naming the record URI. - `AtProtoClientFactory` leaked an ECDSA native handle on every failed client build — the `AtProtoClient` and DPoP session were constructed before `ApplyOAuthSessionAsync` took ownership, and nothing disposed them if it threw. - `FileAtProtoTokenStore` wrote token files in place (a crash mid-write loses the refresh token) and deleted them outside the lock guarding writes (a logout racing a rotation leaves the file behind). Now writes temp-then-move under one lock, and creates files owner-only on Unix. - The fallback `AtProtoHttpException` discarded the response body it had already read, exactly when the message was least useful. The parse failure was also caught with a bare `catch (Exception)`. - Two comments described behaviour the code does not have — most notably `AtProtoClientFactory` claiming the XRPC client refreshes on `ExpiredToken`. It does not, anywhere; per-request clients silently never refreshed. ## API surface No signature changed. Two additive members: `Session.With(...)` and an `init` accessor on `AtProtoHttpException.ResponseBody`. The 21 de-loggered constructors are `internal`. ## Tests `XrpcParams` gets its own suite (ordering, null-dropping, invariant formatting, `AddAll`), `XrpcQueryBuilder`'s tests are retargeted onto the surviving entry point plus a culture-sensitivity case, and `LabelerClientTests` now asserts the repeated-key encoding on the wire so the array bug cannot come back. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
refactor: cleanup sweep across the SDK
All checks were successful
CI / pds-integration (pull_request) Successful in 27s
CI / build-and-test (pull_request) Successful in 52s
6af73c8311
Fixes XRPC array query parameters, which 21 call sites sent as a single
comma-joined value (?dids=a,b) where XRPC specifies repeated keys
(?dids=a&dids=b) — so every endpoint taking an array saw one malformed
element. AdminClient.GetAccountInfosAsync was even building the correct
repeated-key list and then discarding it.

The fix comes with the deduplication: Lexicon clients now build query
parameters through a new internal XrpcParams builder (null-dropping,
invariant-culture, AddAll for arrays) instead of hand-rolling a
Dictionary at 78 call sites. XrpcClient's eight near-identical
query/procedure overloads delegate to one SendAsync pair,
RecordCollection's three duplicated method pairs collapse onto their
*From variants, AtProtoClient loses four identical delete-by-URI bodies
and three hand-copied Session blocks (new Session.With), and both Aspire
PDS integrations share one generic Replace helper.

Also fixed: RecordCollection list operations returning RecordView<T>
with a null Value; an ECDSA handle leak when per-request client setup
throws; non-atomic token-file writes and an unsynchronized delete in
FileAtProtoTokenStore; a discarded response body on non-envelope XRPC
errors; culture-sensitive query formatting; and two comments describing
behaviour the code does not have.

Dead code removed: XrpcQueryBuilder.BuildQueryString, its ToDictionary
alias, ModerationClient.AddListParams, Cid's uncalled GeneratedRegex,
and an unused ILogger field threaded through 21 Lexicon clients.

Net -400 lines. All 1167 unit tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Collaborator

🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the task runs.

🤖 Reviewing this PR — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/167); the reply will link the full session transcript. 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/pr-82/run-167.md) — updating every ~5 minutes while the task runs.
claude-reviewer approved these changes 2026-08-06 02:34:36 +00:00
Dismissed
claude-reviewer left a comment

Verdict: approve — no blocking issues found.

This is a well-executed, carefully-scoped cleanup sweep. I traced the core claim — that hand-rolled Dictionary<string,string?> construction with string.Join(",", ...) was silently mangling XRPC array parameters — and confirmed the fix is real and consistently applied: every listed call site now uses the new XrpcParams/AddAll builder, which emits repeated keys as XRPC requires, and LabelerClientTests now pins the wire format so this can't silently regress.

The deduplication is sound:

  • XrpcClient's SendAsync/SendCoreAsync collapse the eight overloads correctly, and the Func<HttpContent> factory is necessary (not just tidiness) because SendWithDPoPRetryAsync can replay the request on a 401/429 and a consumed HttpContent can't be resent.
  • EnsureSuccessAsync's narrowed catch (Exception ex) when (ex is JsonException or NotSupportedException) is a genuine improvement over the previous bare catch, and ResponseBody is now preserved on the fallback exception path.
  • RecordCollection.GetAsync/ListAsync/ToView correctly throw instead of the old value! null-suppression, matching what GetAsync already did.
  • FileAtProtoTokenStore's temp-file-then-move write and taking the same _lock in RemoveAsync correctly close the crash/race windows described.
  • AtProtoClientFactory's new try/catch disposes oauthSession and client on a failed ApplyOAuthSessionAsync; I checked whether this double-disposes _oauthSession when the failure happens after ApplyOAuthSessionAsync has already assigned _oauthSession = oauthSession (before _sessionStore.SaveAsync) — client.Dispose() would then also call _oauthSession?.Dispose(). This isn't a bug: Dispose() on ECDsa/OAuthSessionResult is expected to be idempotent per standard .NET convention, and the explicit oauthSession.Dispose() is needed to cover the case where the throw happens before that assignment (e.g. in _xrpc.SetBaseUrl), where the client never took ownership.
  • The Aspire Replace dedup, Session.With, and the DeleteByUriAsync helper are straightforward and preserve prior behavior.

Only a trivial inconsistency found (noted below), not worth blocking on. Nothing in the diff looks like a correctness or security regression, and the changelog entries accurately describe the behavior changes.


⏱ 1m 49s · 19 turns · tokens: 98.0k in / 7.4k out (+2011.0k cache-read)
📜 Full session transcript

✅ **Verdict: approve** — no blocking issues found. This is a well-executed, carefully-scoped cleanup sweep. I traced the core claim — that hand-rolled `Dictionary<string,string?>` construction with `string.Join(",", ...)` was silently mangling XRPC array parameters — and confirmed the fix is real and consistently applied: every listed call site now uses the new `XrpcParams`/`AddAll` builder, which emits repeated keys as XRPC requires, and `LabelerClientTests` now pins the wire format so this can't silently regress. The deduplication is sound: - `XrpcClient`'s `SendAsync`/`SendCoreAsync` collapse the eight overloads correctly, and the `Func<HttpContent>` factory is necessary (not just tidiness) because `SendWithDPoPRetryAsync` can replay the request on a 401/429 and a consumed `HttpContent` can't be resent. - `EnsureSuccessAsync`'s narrowed `catch (Exception ex) when (ex is JsonException or NotSupportedException)` is a genuine improvement over the previous bare catch, and `ResponseBody` is now preserved on the fallback exception path. - `RecordCollection.GetAsync`/`ListAsync`/`ToView` correctly throw instead of the old `value!` null-suppression, matching what `GetAsync` already did. - `FileAtProtoTokenStore`'s temp-file-then-move write and taking the same `_lock` in `RemoveAsync` correctly close the crash/race windows described. - `AtProtoClientFactory`'s new try/catch disposes `oauthSession` and `client` on a failed `ApplyOAuthSessionAsync`; I checked whether this double-disposes `_oauthSession` when the failure happens after `ApplyOAuthSessionAsync` has already assigned `_oauthSession = oauthSession` (before `_sessionStore.SaveAsync`) — `client.Dispose()` would then also call `_oauthSession?.Dispose()`. This isn't a bug: `Dispose()` on `ECDsa`/`OAuthSessionResult` is expected to be idempotent per standard .NET convention, and the explicit `oauthSession.Dispose()` is needed to cover the case where the throw happens *before* that assignment (e.g. in `_xrpc.SetBaseUrl`), where the client never took ownership. - The Aspire `Replace` dedup, `Session.With`, and the `DeleteByUriAsync` helper are straightforward and preserve prior behavior. Only a trivial inconsistency found (noted below), not worth blocking on. Nothing in the diff looks like a correctness or security regression, and the changelog entries accurately describe the behavior changes. --- ⏱ 1m 49s · 19 turns · tokens: 98.0k in / 7.4k out (+2011.0k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/pr-82/run-167.md) <!-- claude-verdict:approve --> <!-- claude-reviewed-head:6af73c8311cc68004d8beef2912df3a56a695a36 -->
@ -205,3 +201,1 @@
["includeUsed"] = includeUsed?.ToString().ToLowerInvariant(),
["createAvailable"] = createAvailable?.ToString().ToLowerInvariant(),
};
var parameters = new XrpcParams()
Collaborator

GetAccountInviteCodesAsync still does .Add("includeUsed", includeUsed?.ToString().ToLowerInvariant()) / same for createAvailable, using the string overload instead of the new Add(string, bool?) overload that every other call site in this sweep switched to. Output is identical, so this is purely a leftover inconsistency, not a bug.

`GetAccountInviteCodesAsync` still does `.Add("includeUsed", includeUsed?.ToString().ToLowerInvariant())` / same for `createAvailable`, using the string overload instead of the new `Add(string, bool?)` overload that every other call site in this sweep switched to. Output is identical, so this is purely a leftover inconsistency, not a bug.
refactor: use the bool overload in GetAccountInviteCodesAsync
All checks were successful
CI / pds-integration (pull_request) Successful in 20s
CI / build-and-test (pull_request) Successful in 50s
c2a95e5f10
Leftover from the sweep: the conversion handled
`?.ToString()?.ToLowerInvariant()` but not the variant without the
second null-conditional, so these two stayed on the string overload.
Identical output; consistency only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Author
Owner

@claude /review

@claude /review
Collaborator

🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the task runs.

🤖 Reviewing this PR — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/168); the reply will link the full session transcript. 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/pr-82/run-168.md) — updating every ~5 minutes while the task runs.
docs: correct the line-count claim in the cleanup changelog entry
All checks were successful
CI / pds-integration (pull_request) Successful in 20s
CI / build-and-test (pull_request) Successful in 49s
Sync Closures to GitHub / sync-closure (pull_request) Successful in 6s
7b96f338c1
The entry said ~400 net lines removed; the actual figures are 323 in
src/ and 231 counting the new tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Author
Owner

@claude /review

@claude /review
Collaborator

🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the task runs.

🤖 Reviewing this PR — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/169); the reply will link the full session transcript. 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/pr-82/run-169.md) — updating every ~5 minutes while the task runs.
claude-reviewer approved these changes 2026-08-06 02:38:11 +00:00
Dismissed
claude-reviewer left a comment

Verdict: approve — no blocking issues found.

Both follow-up commits since my last pass are small and directly resolve the only note I left (the leftover .ToString().ToLowerInvariant() calls in ServerClient.GetAccountInviteCodesAsync now use the XrpcParams.Add(string, bool?) overload like every other call site) plus a changelog accuracy fix (the line-count claim now matches the PR description's actual figures). No new code paths were touched, output is unchanged, and nothing new raises correctness or security concerns. Approving.


⏱ 0m 26s · 6 turns · tokens: 78.0k in / 1.7k out (+548.6k cache-read)
📜 Full session transcript

✅ **Verdict: approve** — no blocking issues found. Both follow-up commits since my last pass are small and directly resolve the only note I left (the leftover `.ToString().ToLowerInvariant()` calls in `ServerClient.GetAccountInviteCodesAsync` now use the `XrpcParams.Add(string, bool?)` overload like every other call site) plus a changelog accuracy fix (the line-count claim now matches the PR description's actual figures). No new code paths were touched, output is unchanged, and nothing new raises correctness or security concerns. Approving. --- ⏱ 0m 26s · 6 turns · tokens: 78.0k in / 1.7k out (+548.6k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/pr-82/run-169.md) <!-- claude-verdict:approve --> <!-- claude-reviewed-head:7b96f338c192d1426f34311be4a56614314c2dc6 -->
claude-reviewer left a comment

Verdict: approve — no blocking issues found.

Re-review of the same cleanup sweep. The one issue flagged in my previous pass — ServerClient.GetAccountInviteCodesAsync still using the string-formatting overload (includeUsed?.ToString().ToLowerInvariant()) instead of the new XrpcParams.Add(string, bool?) overload — is now fixed; it uses .Add("includeUsed", includeUsed) / .Add("createAvailable", createAvailable) like every other call site.

I re-checked the core claims in this diff:

  • The XrpcParams/AddAll builder is applied consistently everywhere the description lists, including the previously-buggy AdminClient.GetAccountInfosAsync, LabelClient.QueryLabelsAsync (both uriPatterns and sources), and the eight array filters on the two tools.ozone moderation query methods — all now emit repeated keys instead of comma-joined strings.
  • XrpcClient's SendAsync/SendCoreAsync/SendCoreAsync<T> consolidation correctly threads a Func<HttpContent> factory through SendWithDPoPRetryAsync, which is necessary (not just cosmetic) since that path can replay the request on a DPoP nonce retry or 429, and a consumed HttpContent can't be resent.
  • EnsureSuccessAsync now parses rate-limit headers before the early-return, keeps ResponseBody on the non-envelope fallback exception, and narrows the swallow-all catch to JsonException or NotSupportedException — a real improvement over the previous bare catch.
  • The Aspire Replace helper is correctly generalized to a shared PdsParameterOverrides.Replace<TResource> used by both PDS resource types, preserving the original null-safety (current is ParameterResource) and remark comment.
  • AtProtoClientFactory's new try/catch disposing oauthSession/client on a failed ApplyOAuthSessionAsync, and FileAtProtoTokenStore's temp-then-move write plus lock-guarded delete, both match what I verified in the prior pass and look unchanged here.
  • Session.With(...) and the AtProtoHttpException.ResponseBody init accessor are additive as claimed; no signature changes elsewhere in the visible diff.

Nothing new in this diff looks like a correctness or security regression. Approving.


⏱ 1m 48s · 21 turns · tokens: 87.8k in / 7.0k out (+2165.3k cache-read)
📜 Full session transcript

✅ **Verdict: approve** — no blocking issues found. Re-review of the same cleanup sweep. The one issue flagged in my previous pass — `ServerClient.GetAccountInviteCodesAsync` still using the string-formatting overload (`includeUsed?.ToString().ToLowerInvariant()`) instead of the new `XrpcParams.Add(string, bool?)` overload — is now fixed; it uses `.Add("includeUsed", includeUsed)` / `.Add("createAvailable", createAvailable)` like every other call site. I re-checked the core claims in this diff: - The `XrpcParams`/`AddAll` builder is applied consistently everywhere the description lists, including the previously-buggy `AdminClient.GetAccountInfosAsync`, `LabelClient.QueryLabelsAsync` (both `uriPatterns` and `sources`), and the eight array filters on the two `tools.ozone` moderation query methods — all now emit repeated keys instead of comma-joined strings. - `XrpcClient`'s `SendAsync`/`SendCoreAsync`/`SendCoreAsync<T>` consolidation correctly threads a `Func<HttpContent>` factory through `SendWithDPoPRetryAsync`, which is necessary (not just cosmetic) since that path can replay the request on a DPoP nonce retry or 429, and a consumed `HttpContent` can't be resent. - `EnsureSuccessAsync` now parses rate-limit headers before the early-return, keeps `ResponseBody` on the non-envelope fallback exception, and narrows the swallow-all catch to `JsonException or NotSupportedException` — a real improvement over the previous bare catch. - The Aspire `Replace` helper is correctly generalized to a shared `PdsParameterOverrides.Replace<TResource>` used by both PDS resource types, preserving the original null-safety (`current is ParameterResource`) and remark comment. - `AtProtoClientFactory`'s new try/catch disposing `oauthSession`/`client` on a failed `ApplyOAuthSessionAsync`, and `FileAtProtoTokenStore`'s temp-then-move write plus lock-guarded delete, both match what I verified in the prior pass and look unchanged here. - `Session.With(...)` and the `AtProtoHttpException.ResponseBody` `init` accessor are additive as claimed; no signature changes elsewhere in the visible diff. Nothing new in this diff looks like a correctness or security regression. Approving. --- ⏱ 1m 48s · 21 turns · tokens: 87.8k in / 7.0k out (+2165.3k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/pr-82/run-168.md) <!-- claude-verdict:approve --> <!-- claude-reviewed-head:c2a95e5f1026d62183573c7242caaab430de22d4 -->
Grandiras deleted branch cleanup/sweep-2026-08 2026-08-06 02:39:18 +00:00
Sign in to join this conversation.
No description provided.