Repository navigation
Fix TLS GraphStage buffer allocation and growth - #3728
Conversation
pjfanning
left a comment
There was a problem hiding this comment.
Thanks for this. Notes from a review pass. The inline comments cover changed lines; the points below are about unchanged code whose behaviour this PR changes. Up to now, a buffer larger than direct-buffer-size threw an exception. With this PR it falls back to the heap, so these paths can now be reached.
-
Overflow grows the buffer instead of flushing (doWrap, growTransportOutBuffer). On
BUFFER_OVERFLOWthe stage always doublestransportOutBuffer, even when the overflow happened only because deferred ciphertext is still in it (up toDeferredTransportFlushLimit= 16 deferred flushes). Sending the pending bytes would free the space. Each overflow inside that window doubles the buffer again, so it can grow geometrically for no reason. Suggestion: iftransportOutBuffer.position() > 0, callflushToTransport()and retry the wrap, and grow only when the buffer is already empty. -
A grown buffer is never shrunk (growTransportOutBuffer). After one transient overflow, the pooled direct buffer goes back to the pool and the connection holds a heap buffer of 2x that size until it closes. Every later wrap then copies between heap and native memory. On long-lived connections that doubles the memory used for transport output. Consider returning to a pooled-size buffer once the large one has been drained.
-
Inbound buffers have no headroom and never grow (allocateBuffers, doUnwrap, putBackUnreadBuffer). In the small-pool fallback,
transportInBufferandtransportUnreadBufferare heap buffers sized exactly to thegetPacketBufferSize()read before the handshake. Nothing grows them afterwards:BUFFER_UNDERFLOWandBUFFER_OVERFLOWonly flush or put data back. If the negotiated session needs larger packets (for example withjsse.SSLEngine.acceptLargeFragments), a full record can never fit, and the connection stalls orputBackUnreadBufferthrows. The old 128 KiB pooled buffers hid this. -
Batching still depends on the pool size (applicationBufferSize).
recordsThatFitTransportBufferis stillpooledBufferSize / packetSize. With an 8 KiB pool that is always 1 record per engine call, even aftertransportOutBufferhas grown, so throughput depends on an unrelated TCP setting. Deriving it from the transport buffer's actual capacity, or from the packet size, would remove that dependency. -
Heap allocation on every connection (allocateBuffers). With a small pool, each materialization allocates 3 heap buffers of about 16.7 KiB (~50 KiB per handshake), with no reuse. That adds GC pressure on servers with many short TLS connections. A TLS-specific pool sized to the packet size, or one allocation sliced into the three buffers, would help.
be1da0e to
52b44b1
Compare
|
Addressed in 52b44b1: flush before growth, resize inbound buffers for changed session requirements, and derive batching from actual capacity. Growth now occurs only when an empty buffer is too small, so shrinking would repeat the required allocation. A TLS-specific pool remains separate performance work; the fallback cost is documented. The inbound-growth tests now use fixed initial session sizes and assert overflow/underflow occurred. The 256 TLS tests and 9 focused cases on JDK 17/21/25 pass; all four inline threads are resolved. |
He-Pin
left a comment
There was a problem hiding this comment.
Deep review, comparing against TLSActor.
The premise checks out. TLSActor uses fixed heap buffers (16665 + 2048, TLSActor.scala:151-159) and never touches the TCP pool, so none of these failure modes can occur there. For the GraphStage engine:
- The default
pekko.io.tcp.direct-buffer-sizeis128 KiB(actor/src/main/resources/reference.conf:1018) and the pool isnew DirectByteBufferPool(Settings.DirectBufferSize, Settings.MaxDirectBufferPoolSize)(Tcp.scala:667). Measured on JDK 25:getPacketBufferSize() = 16709,getApplicationBufferSize() = 16704, identical before and afterbeginHandshake()for both TLSv1.2 and TLSv1.3. So with default settings the oldacquireTransportBuffernever throws - the initialization failure needs a lowereddirect-buffer-size, which is exactly what the newTlsGraphStageSmallBufferSpecexercises. - Flush-before-grow is the right order: the overflow is normally caused by ciphertext already sitting in
transportOutBuffer, and growing there would double the buffer for no reason.flush deferred cipher bytes before retrying an overflow without growing the bufferpins that down nicely, including thelargestOutputCapacity shouldBe poolBufferSizeassertion. - The pool-ownership assertion (acquire the whole free list, then check
isDirect, capacity and pairwise identity) is a genuinely good test:DirectByteBufferPool.releaseperforms no validation, so a double release or a foreign buffer would otherwise go unnoticed until some unrelated TCP connection misbehaves.
Findings: four suggestions and three nits inline, no blockers. The buffer mechanics themselves all check out - I traced acquire/release ownership, ensureReadBufferCapacity preserving read-mode leftovers, the growth ordering relative to putBackUnreadBuffer, and growUserOutBuffer being called only after flushToUser() has cleared userOutBuffer, and could not find a leak, a stale-reference use, or a BufferOverflowException window in expanded.put(buffer). I also confirmed the new BUFFER_OVERFLOW flush cannot trip the "TLS output slot already has pending data" guard, since every doWrap call site is gated on transportOutput.isDemandAvailable, which requires pending == null.
Conflict with #3727 - the two branches are independent, both based on a55a008, and both edit the same lines of stream-io.md, TlsGraphStage.scala and TlsGraphStageIsolatedSpec.scala. The spec conflict is the awkward one: both change ScriptedSSLEngine's constructor and newHarness's signature incompatibly, and both append cases to the same "TlsGraphStage deferred close handling" must { block. Worth agreeing an order and rebasing the second.
Note that Check / Tests (scala3) was still pending when I looked; everything else was green.
|
|
||
| case BUFFER_UNDERFLOW => | ||
| flushToUser() | ||
| ensureInboundBufferSizes() |
There was a problem hiding this comment.
BUFFER_UNDERFLOW is the ordinary "the record is not complete yet" path, so this now runs on every partial TLS record - engine.getSession() plus two accessors plus two capacity comparisons - and the same again in the BUFFER_OVERFLOW branch at line 480.
Measured on JDK 25, getPacketBufferSize()/getApplicationBufferSize() return 16709/16704 both before and after beginHandshake(), for TLSv1.2 and TLSv1.3 alike, so with the default provider this call is a guaranteed no-op and pure overhead on a hot path.
SSLEngineImpl.getSession() is not synchronized (checked the bytecode), so this is not a lock cost, but it is still worth guarding. A cheap precondition that keeps SunJSSE on the fast path: on underflow the unwrap consumed nothing, so growth can only be needed when the input buffer is completely full -
case BUFFER_UNDERFLOW =>
flushToUser()
if (transportInBuffer.remaining() == transportInBuffer.capacity()) ensureInboundBufferSizes()(For the BUFFER_OVERFLOW branch the equivalent signal is userOutBuffer being full, which has to be sampled before flushToUser() clears it.)
| val packetSize = math.max(1, session.getPacketBufferSize) | ||
| transportInBuffer = ensureReadBufferCapacity(transportInBuffer, packetSize) | ||
| transportUnreadBuffer = ensureReadBufferCapacity(transportUnreadBuffer, packetSize) | ||
| transportPacketSize = math.max(transportPacketSize, packetSize) |
There was a problem hiding this comment.
transportPacketSize is raised here but transportOutBuffer is never grown to match - it only grows reactively on BUFFER_OVERFLOW in doWrap.
Two outbound decisions key off transportPacketSize:
canContinueWrappingrequirestransportOutBuffer.remaining >= transportPacketSize(line 427);shouldFlushTransportNowrequirestransportOutBuffer.position() >= transportPacketSize(line 562).
Once transportPacketSize exceeds transportOutBuffer.capacity(), both are permanently false, so wrap batching and size-triggered flushing are silently disabled for the lifetime of that connection and it degrades to one record per ByteString. That is stronger than what the new stream-io.md paragraph promises ("can reduce batching, which is bounded by the actual transport buffer capacity").
Consider growing transportOutBuffer here as well - flushing first if it currently holds deferred ciphertext - or capping transportPacketSize at transportOutBuffer.capacity() so the two stay in step.
|
|
||
| val applicationSize = math.max(1, session.getApplicationBufferSize) | ||
| applicationRecordSize = math.max(applicationRecordSize, applicationSize) | ||
| maxUserInBufferSize = math.max(maxUserInBufferSize, applicationSize) |
There was a problem hiding this comment.
These use math.max(current, applicationSize) - a single record - whereas allocateBuffers (lines 141-143) and growTransportOutBuffer (lines 616-618) derive the same two fields from applicationBufferSize(applicationSize, packetSize), i.e. applicationSize * recordsPerEngineCall.
It is harmless today because this is a max and the batch value is normally larger, but having two formulas for one quantity is a trap for the next reader: if a session's application size ever grew past the batch value, maxUserInBufferSize would silently stop being a batch size and become a single-record cap. Consider deriving both from applicationBufferSize here too.
| s"TLS packet buffer requires $requiredCapacity bytes but pekko.io.tcp.direct-buffer-size is $pooledBufferSize") | ||
| } | ||
| } | ||
| private def acquireTransportBuffer(requiredCapacity: Int): ByteBuffer = |
There was a problem hiding this comment.
The inner else looks unreachable with the shipped pool: DirectByteBufferPool.takeBufferFromPool only ever returns buffers of defaultBufferSize (DirectByteBufferPool.scala:51-66), and pooledBufferSize is tcp.Settings.DirectBufferSize (Tcp.scala:667), so the outer requiredCapacity <= pooledBufferSize guard already implies buffer.capacity() >= requiredCapacity.
Fine to keep as defence - just noting it cannot be covered by a test unless someone swaps in a different BufferPool.
The important part of this change is the companion guard in releaseTransportBuffer (line 722), and it deserves more credit than it currently gets: DirectByteBufferPool.release performs no validation (DirectByteBufferPool.scala:68-82) - it stores whatever it is handed and acquire() returns it after a clear(). Without the isDirect && capacity() == pooledBufferSize check, every heap fallback buffer allocated by this method would be released into the shared TCP pool and then handed out to unrelated TCP connections for the lifetime of the ActorSystem. Worth stating that explicitly in the PR description, because as written it reads like a cosmetic tidy-up.
| .parseString( | ||
| s"""pekko.scheduler.implementation = "${classOf[DeferredZeroTimerScheduler].getName}"""") | ||
| s"""pekko.scheduler.implementation = "${classOf[DeferredZeroTimerScheduler].getName}" | ||
| |pekko.io.tcp.direct-buffer-size = 18432 |
There was a problem hiding this comment.
This changes the environment of the three pre-existing tests in this spec, not just the new ones.
applicationBufferSize divides the transport buffer capacity by packetSize, so with the default direct-buffer-size = 128 KiB the pre-existing tests ran with 131072 / 16709 = 7 -> min(4, 7) = 4 -> 16704 * 4 = 66816 for maxUserInBufferSize/maxUserOutBufferSize. At 18432 that becomes 18432 / 16709 = 1 -> 16704, a 4x reduction in the batching limit for tests that have nothing to do with buffer sizing.
I can see why the value is needed: the two "grow inbound buffers after session sizes increase" cases require the initial buffer to be smaller than negotiatedPacketSize = 32768, otherwise ensureReadBufferCapacity is a no-op and underflowUnwraps/overflowUnwraps stay at 0. But TlsGraphStageSmallBufferSpec (8192) already exists for exactly that purpose. Suggest moving those two cases there - or into a third dedicated spec - and leaving this config with only direct-buffer-pool-limit = 8, which is the part the pool-uniqueness assertion genuinely needs (at the default limit of 1000 it would acquire 128 MiB of direct memory).
If you would rather keep it here, please add a comment explaining the coupling; right now a future reader tuning this value has no way to know it changes batching semantics for unrelated tests.
| reportedPacketSize = negotiatedPacketSize | ||
| if (negotiatedApplicationSize > 0) reportedApplicationSize = negotiatedApplicationSize | ||
| bufferRetries += 1 | ||
| if (bufferRetries > 8) throw new SSLException("TLS buffers were not resized after repeated retries") |
There was a problem hiding this comment.
bufferRetries is incremented on every post-handshake scatter unwrap and is never reset, so it really counts "unwraps since the handshake", not retries. A future case that legitimately needs more than 8 unwrap rounds will fail with the misleading "TLS buffers were not resized after repeated retries".
Suggest renaming to something like postHandshakeUnwraps, and/or resetting it once an unwrap returns neither BUFFER_OVERFLOW nor BUFFER_UNDERFLOW. That keeps the intent (fail fast if the stage never resizes) without coupling the guard to the total round count.
| import pekko.testkit.{ ExplicitlyTriggeredScheduler, WithLogCapturing } | ||
| import pekko.util.ByteString | ||
|
|
||
| import com.typesafe.config.{ Config, ConfigFactory } |
There was a problem hiding this comment.
Unrelated churn, although it does move the file onto the repo convention - TlsSpec.scala, TcpSpec.scala and TlsGraphStageEdgeCasesSpec.scala all put com.typesafe.config after the pekko group, and this file was the outlier. Fine either way; flagging it only against the AGENTS.md "do not mix formatting churn" rule in case a maintainer would rather it stayed out of this PR.
|
|
||
| The `SSLEngine` instance can then be used with the binding or outgoing connection factory methods. | ||
|
|
||
| When using `pekko.stream.materializer.tls.engine = graph-stage`, TLS packet buffers are sized to |
There was a problem hiding this comment.
#3727 inserts a different paragraph at this exact position, so whichever PR merges second conflicts here and both paragraphs need to be kept.
On the content: it is accurate, and this is the only place in either PR that names pekko.stream.materializer.tls.engine, which is helpful. One nit - "can reduce batching, which is bounded by the actual transport buffer capacity" understates the effect: once the session's packet size exceeds the transport buffer capacity, canContinueWrapping and the size-based flush in shouldFlushTransportNow are both permanently false, so batching is not merely reduced but switched off. See the inline note on ensureInboundBufferSizes.
Motivation: Session-size growth left the output buffer smaller than the packet threshold, and ordinary partial records unnecessarily queried session requirements. Modification: Guard size refreshes by buffer state, grow the output buffer while preserving ciphertext, and use the same batch-size formula for all growth paths. Isolate session-growth tests, reset the retry guard after successful records, restore the original import placement, and clarify buffer ownership in docs. Result: Session growth preserves output batching invariants and ordinary underflow keeps its fast path. Heap fallback buffers cannot pollute the shared TCP pool. Tests: - JDK 25: 10 focused buffer/close tests passed. - JDK 25: 292 TLS/TCP regression tests passed before the requested CI-only policy. - Native scalafmt and git diff --check passed. - Qoder stdout review: No must-fix findings. - Full validation, docs, style, and binary compatibility are delegated to remote CI. References: Refs apache#3728, Refs apache#2860.
Motivation: Sender-side boundary batching can send plaintext-output cancellation before plaintext that the TLS handler has already delivered to its output boundary. Modification: Opt TLS input boundaries out of sender-side batching using an internal attribute, retaining asynchronous isolation and existing input-buffer sizes. Add deterministic TLS 1.2 and TLS 1.3 regressions and document the scope. Result: Delivered plaintext reaches the TLS boundary before cancellation. Preserve the original TCP assertion, closing policy, and TLS engine close semantics. Tests: - Regression: 0/2 before, 2/2 after; related TLS 252/252 and fusing 37/37. - Original TCP TLS test: 20/20 per engine, plus one sbt run per engine. - Integration with apache#3727/apache#3728: deferred close 15/15, TLS 252/252, another 20/20 TCP tests per engine. - headerCreateAll, +headerCheckAll, docs/paradox, native scalafmt check, and git diff --check passed. - Full validatePullRequest and cross-project MiMa reserved for remote CI per user instruction; CI not followed. References: Fixes apache#3729
Motivation: Sender-side boundary batching could deliver plaintext-output cancellation before plaintext already delivered by the same handler. TLS could also close outbound before encrypting plaintext accepted into its deferred batch. Modification: - Opt TLS input boundaries out of sender-side batching while retaining asynchronous isolation and the existing input-buffer sizes. - Stop accepting input and drain the established session's accepted data with backpressure. Preserve transport-close ordering, failures during delayed cancellation, and prompt termination when a new handshake would be required. - Add deterministic ordering and deferred-close regressions, document drain limits in both DSLs and the guide, and correct the IgnoreCancel API reference. Result: Boundary messages sent by a handler precede its subsequent cancellation. Accepted plaintext drains when cancellation is observed before peer transport completion, subject to the documented limits. Preserve the peer-completes-first discard contract, original TCP hello assertion, and default closing policy. Tests: - Unchanged production/test tree from bae8200: deferred-close and cancellation suites 17/17; original GraphStage TCP TLS case 1/1. - Ordering regression on main: 0/2 before the fix, 2/2 afterwards; related TLS suites 252/252, fusing/interpreter suites 37/37, TCP TLS 20/20 per engine. - Temporary apache#3727/apache#3728 integration: all 15 deferred-close cases and related TLS suites passed; an additional 20/20 TCP TLS runs per engine passed. - sbt docs/paradox and git diff --check passed after the documentation fix. - Existing headerCreateAll, +headerCheckAll, and native scalafmt checks passed; no Scala code, test, imports, or headers changed during squash/review. - Auditable Qoder stdout review and independent review: no must-fix findings. - Full validatePullRequest and cross-project MiMa left to remote validation per user instruction; CI results were not followed or claimed to pass. References: Fixes apache#3729. Refs apache#2860.
Motivation: Sender-side boundary batching could deliver plaintext-output cancellation before plaintext already delivered by the same handler. TLS could also close outbound before encrypting plaintext accepted into its deferred batch. Modification: - Opt TLS input boundaries out of sender-side batching while retaining asynchronous isolation and the existing input-buffer sizes. - Stop accepting input and drain the established session's accepted data with backpressure. Preserve transport-close ordering, failures during delayed cancellation, and prompt termination when a new handshake would be required. - Add deterministic ordering, deferred-close, and partially sent large-message regressions. Document drain limits in both DSLs and the guide, including the correct IgnoreCancel API reference. Result: Boundary messages sent by a handler precede its subsequent cancellation. Accepted plaintext drains when cancellation is observed before peer transport completion, subject to the documented limits. Preserve the peer-completes-first discard contract, original TCP hello assertion, and default closing policy. Tests: - Large-message cancellation regression for IgnoreComplete and EagerClose: 0/2 passed with the original main TlsGraphStage implementation, 2/2 with the current implementation. Deferred-close and cancellation suites: 19/19. - Previously recorded ordering regression on main: 0/2 before, 2/2 after; related TLS suites 252/252, fusing/interpreter suites 37/37, TCP TLS 20/20 per engine. The combined PR's original GraphStage TCP TLS case passed 1/1. - Temporary apache#3727/apache#3728 integration: all 15 original deferred-close cases and related TLS suites passed; another 20/20 TCP TLS runs per engine passed. - Native scalafmt and git diff --check passed for the new tests. Production code is unchanged by this test-only follow-up. - Existing docs/paradox, headerCreateAll and +headerCheckAll checks passed; documentation, imports, source-file headers, and public API are unchanged. - Auditable Qoder stdout review and independent review: no remaining must-fix findings after checking the test against the implementation and API definitions. - Full validatePullRequest and cross-project MiMa left to remote validation per user instruction; CI results were not followed or claimed to pass. References: Fixes apache#3729. Refs apache#2860.
Motivation: Sender-side boundary batching could deliver plaintext-output cancellation before plaintext already delivered by the same handler. TLS could also close outbound before encrypting plaintext accepted into its deferred batch. Modification: - Opt TLS input boundaries out of sender-side batching while retaining asynchronous isolation and the existing input-buffer sizes. - Stop accepting input and drain the established session's accepted data with backpressure. Preserve transport-close ordering, failures during delayed cancellation, and prompt termination when a new handshake would be required. - Add deterministic ordering, deferred-close, and partially sent large-message regressions. Document drain limits in both DSLs and the guide, including the correct IgnoreCancel API reference. Result: Boundary messages sent by a handler precede its subsequent cancellation. Accepted plaintext drains when cancellation is observed before peer transport completion, subject to the documented limits. Preserve the peer-completes-first discard contract, original TCP hello assertion, and default closing policy. Tests: - Large-message cancellation regression for IgnoreComplete and EagerClose: 0/2 passed with the original main TlsGraphStage implementation, 2/2 with the current implementation. Deferred-close and cancellation suites: 19/19. - Previously recorded ordering regression on main: 0/2 before, 2/2 after; related TLS suites 252/252, fusing/interpreter suites 37/37, TCP TLS 20/20 per engine. The combined PR's original GraphStage TCP TLS case passed 1/1. - Temporary #3727/#3728 integration: all 15 original deferred-close cases and related TLS suites passed; another 20/20 TCP TLS runs per engine passed. - Native scalafmt and git diff --check passed for the new tests. Production code is unchanged by this test-only follow-up. - Existing docs/paradox, headerCreateAll and +headerCheckAll checks passed; documentation, imports, source-file headers, and public API are unchanged. - Auditable Qoder stdout review and independent review: no remaining must-fix findings after checking the test against the implementation and API definitions. - Full validatePullRequest and cross-project MiMa left to remote validation per user instruction; CI results were not followed or claimed to pass. References: Fixes #3729. Refs #2860.
Motivation: Small TCP pool buffers prevent TLS initialization and cannot satisfy output growth. Deferred ciphertext, session changes and heap allocation also need safe capacity and ownership handling. Modification: Use stage-owned heap buffers when the TCP pool is too small, sharing one initial allocation between three disjoint transport slices. Flush before growing, refresh session requirements and batching, and shrink drained output after a handshake lowers its capacity requirement. Validate pooled capacity and recycling. Rebase on main while preserving cancellation/draining coverage, add review and integration regressions, and document allocation tradeoffs. Result: TLS progresses with small TCP buffers without losing ciphertext or polluting the shared pool. Transient session capacity can be reclaimed without repeated growth, and initialization uses one backing heap allocation instead of three. Tests: - Two new review regressions failed before the fix and passed afterwards. - JDK 21: 310 related TLS/TCP tests passed, including 28 focused cases. - JDK 17 and 25: 28 focused cases passed on each runtime via ScalaTest. - Native scalafmt, headerCreateAll, +headerCheckAll and git diff --check passed. - Qoder stdout review and independent review: No must-fix findings. - Full local MiMa stopped at user request; full validation, docs, import/style and binary compatibility checks are delegated to remote CI. References: Refs apache#2860, Refs apache#3727, Refs apache#3728.
08c3695 to
90b20c6
Compare
Motivation: TLS GraphStage cannot initialize when the TCP pool is smaller than the SSLEngine packet requirement, and fixed-size pooled buffers cannot satisfy growth. The partial-record regression test also races handshake processing. Modification: Use stage-owned heap buffers when needed, sharing the initial allocation between three disjoint transport slices. Flush before growing, preserve pending data, refresh session requirements and batching, and shrink drained output after renegotiation. Keep foreign buffers out of the TCP pool. Add buffer and cancellation regressions and document allocation behavior. Synchronize the test harness with session verification and check query counts after cancellation acknowledges completion of unwrap processing. Result: Small TCP buffers no longer prevent TLS progress. Buffer growth and recycling preserve data and ownership, and the partial-record test checks session query behavior without racing the stage. Tests: - JDK 17: 312 TLS/TCP tests passed, including 28 focused buffer/close cases. - Negative control: unconditional underflow session queries fail with 3 != 2; the temporary mutation was restored. - Native scalafmt, headerCreateAll, checkCodeStyle and git diff --check passed. - +mimaReportBinaryIssues passed on Scala 2.13.18 and 3.3.9. - sortImports encountered a Scalafix/Scalameta NoSuchMethodError; its unrelated edits were reverted and the native formatter check passed. - docs/paradox stopped at user request; validatePullRequest and the remaining Scala test matrix are delegated to remote CI. References: Refs apache#2860, Refs apache#3727, Refs apache#3728.
90b20c6 to
3c4452d
Compare
Add apache#3727, apache#3728, apache#3731, apache#3734, apache#3738 (TLS), apache#3730 (fanout publisher performance) and apache#3733 (shard lease release logging). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add apache#3727, apache#3728, apache#3731, apache#3734, apache#3738 (TLS), apache#3730 (fanout publisher performance) and apache#3733 (shard lease release logging).
Motivation
TLS GraphStage fails when the TCP buffer pool is smaller than the SSLEngine packet requirement. The fixed-size pool also cannot satisfy output growth. CI additionally exposed a race in the partial-record regression: the test sampled session-query counts before the stage processed the completed handshake.
Modification
Result
TLS progresses with small TCP buffers while preserving ciphertext and pool ownership. The CI regression now establishes the required ordering and still detects unnecessary session queries.
Tests
3 was not equal to 2; the mutation was restored.headerCreateAll,checkCodeStyle(including Java formatting and cross-version headers), andgit diff --checkpassed.+mimaReportBinaryIssuespassed on Scala 2.13.18 and 3.3.9.sortImportsencountered a Scalafix/ScalametaNoSuchMethodError; unrelated import edits were reverted and the native formatter check passed.docs/paradoxwas stopped andvalidatePullRequestwas not run. The remaining documentation and Scala test matrix validation is delegated to remote CI.References
Refs #2860, Refs #3727, Refs #3728.