diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 7bfdaf5..2ee1ff5 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -397,7 +397,7 @@ The above copyright notice and this permission notice shall be included in all c THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -## `github.com/quic-go/quic-go` `v0.64.1` +## `github.com/quic-go/quic-go` `v0.65.0` ### `LICENSE` @@ -529,7 +529,7 @@ rights granted to you under this License for this implementation of Go shall terminate as of the date such litigation is filed. ``` -## `golang.org/x/net` `v0.61.0` +## `golang.org/x/net` `v0.62.0` ### `LICENSE` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ea83f2b..4a45ab9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -357,8 +357,11 @@ Capacity history is a recent maximum of eight eligible delivery samples, not an average or a promise of a monotonically decreasing target. Higher delivery evidence can raise it even in a pacing-dominated interval. The shared-stream regression test therefore witnesses actual peer-received flow-control blocking -and checks learning at a fixed, still-backpressured feedback checkpoint. It -also drains the remaining payload and verifies same-connection recovery; +and checks learning at a fixed, still-backpressured feedback checkpoint. Its +slow readers release credit in small, spaced reads rather than two large +bursts, to reduce short high-rate samples caused by scheduler skew between +large drains. Both aligned and deliberately skewed readers retain the same +checkpoint and capacity-reduction assertion. It also drains the remaining payload and verifies same-connection recovery; an arbitrary final target alone is not evidence that capacity never adapted. Web H2/H3 streams do not use the native pacing negotiation and report client diff --git a/go.mod b/go.mod index c3d3fb7..072da91 100644 --- a/go.mod +++ b/go.mod @@ -11,9 +11,9 @@ replace github.com/apernet/quic-go => github.com/cppla/quic-go v0.63.1-0.2026100 require ( github.com/apernet/quic-go v0.63.1-0.20261004180939-a10df75c260c github.com/quic-go/qpack v0.6.0 - github.com/quic-go/quic-go v0.64.1 + github.com/quic-go/quic-go v0.65.0 github.com/refraction-networking/utls v1.8.3-0.20261006222701-ff1b50fbbe9a - golang.org/x/net v0.61.0 + golang.org/x/net v0.62.0 ) require ( diff --git a/go.sum b/go.sum index 304d718..d3d3fcd 100644 --- a/go.sum +++ b/go.sum @@ -10,8 +10,8 @@ github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4 github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= -github.com/quic-go/quic-go v0.64.1 h1:BCNTP+6x6++OPKrGST2FR3f1tycx8O3QOA3QQv09g70= -github.com/quic-go/quic-go v0.64.1/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w= +github.com/quic-go/quic-go v0.65.0 h1:BJQSjdTvLPLmWtaCupUphRbgxgpZhdO/GMpr9Eoe3B4= +github.com/quic-go/quic-go v0.65.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= @@ -20,8 +20,8 @@ go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= -golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= -golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= +golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY= +golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= diff --git a/internal/tunnel/pacing_feedback_integration_test.go b/internal/tunnel/pacing_feedback_integration_test.go index ea5638b..c76a9c5 100644 --- a/internal/tunnel/pacing_feedback_integration_test.go +++ b/internal/tunnel/pacing_feedback_integration_test.go @@ -41,6 +41,15 @@ func TestQUICPacingSustainedDownloadDoesNotSelfLimitToFloor(t *testing.T) { } func TestQUICPacingSharedStreamsAcceptTransportBackpressure(t *testing.T) { + for _, skew := range []time.Duration{0, 15 * time.Millisecond} { + t.Run(fmt.Sprintf("reader-skew-%s", skew), func(t *testing.T) { + testQUICPacingSharedStreamsAcceptTransportBackpressure(t, skew) + }) + } +} + +func testQUICPacingSharedStreamsAcceptTransportBackpressure(t *testing.T, readerSkew time.Duration) { + t.Helper() // Each stream's fixed 16 KiB receive window is smaller than one 32 KiB // write, and cannot auto-grow to absorb the entire slow-reader payload. trace := newFeedbackSharedTrace() @@ -80,8 +89,12 @@ func TestQUICPacingSharedStreamsAcceptTransportBackpressure(t *testing.T) { // Start a fast sibling while the primary cannot drain its finite receive // buffer. The real clock observes actual token sleeps, not merely calls to // Wait, while the primary is inside the underlying QUIC stream.Write. - siblingBurst := f.run(func() error { return writeFeedbackPayload(f.serverStreams[1], 128<<10) }) - if err := readFeedbackPayload(f.clientStreams[1], 128<<10); err != nil { + // A short burst can fit the shared bucket plus tokens refilled during + // transport work. The same fixed 1 MiB warmup as the primary gives this + // phase more real writes in which to witness a token sleep under race. + const siblingWarmupBytes = 1 << 20 + siblingBurst := f.run(func() error { return writeFeedbackPayload(f.serverStreams[1], siblingWarmupBytes) }) + if err := readFeedbackPayload(f.clientStreams[1], siblingWarmupBytes); err != nil { t.Fatal(err) } f.await(t, siblingBurst) @@ -93,7 +106,7 @@ func TestQUICPacingSharedStreamsAcceptTransportBackpressure(t *testing.T) { // Both streams now have slow readers. Unlike one blocked stream with an // unrestricted sibling, this reduces delivery for the shared connection. // Arm this immutable phase only after the preceding fast writer joined. - siblingWitness := newFeedbackSharedWitness(f.rawStreams[1], trace.streams[1], 128<<10, checkpoint, receiptQueue) + siblingWitness := newFeedbackSharedWitness(f.rawStreams[1], trace.streams[1], siblingWarmupBytes, checkpoint, receiptQueue) f.rawStreams[1].sharedWitness = siblingWitness f.streamPacers[1].sharedWitness = siblingWitness siblingFinished := make(chan struct{}) @@ -101,7 +114,7 @@ func TestQUICPacingSharedStreamsAcceptTransportBackpressure(t *testing.T) { defer close(siblingFinished) return writeFeedbackPayload(f.serverStreams[1], slowBytes) }) - if _, err := siblingWitness.awaitRaw(f.ctx, siblingFinished, 1, 128<<10); err != nil { + if _, err := siblingWitness.awaitRaw(f.ctx, siblingFinished, 1, siblingWarmupBytes); err != nil { t.Fatalf("sibling fresh backpressure before shared reads: %v", err) } if _, err := primaryWitness.awaitRaw(f.ctx, primaryFinished, 1, 1<<20); err != nil { @@ -109,16 +122,25 @@ func TestQUICPacingSharedStreamsAcceptTransportBackpressure(t *testing.T) { } var after int64 for round := 0; round < rounds; round++ { - timer := time.NewTimer(120 * time.Millisecond) - select { - case <-timer.C: - case <-f.ctx.Done(): - timer.Stop() - t.Fatal(f.ctx.Err()) - } - for _, reader := range f.clientStreams { - if err := readFeedbackPayload(reader, feedbackChunkSize); err != nil { - t.Fatal(err) + // Release credit gradually instead of draining two 32 KiB bursts. A + // scheduler gap between those bursts can create a high-rate sample + // just over the controller's 10 ms minimum window, even though the + // round's sustained rate is low. Its recent-maximum estimator is + // right to retain that evidence. Small reads and the fixed window bound + // that burstiness without changing the production estimator or the + // prescribed checkpoint. Relative delays never catch up after a stall. + // Each read spans whole repetitions of the 32-byte payload pattern. + const readSize = 1 << 10 + const steps = feedbackChunkSize / readSize + for step := 0; step < steps; step++ { + feedbackReadDelay(t, f.ctx, 120*time.Millisecond/steps) + for index, reader := range f.clientStreams { + if step == 0 && index == 1 && readerSkew != 0 { + feedbackReadDelay(t, f.ctx, readerSkew) + } + if err := readFeedbackPayload(reader, readSize); err != nil { + t.Fatal(err) + } } } if round+1 == checkpoint { @@ -171,6 +193,17 @@ func TestQUICPacingSharedStreamsAcceptTransportBackpressure(t *testing.T) { before, after, transport, pacing, f.clock.sleepsWithPrimaryWrite.Load()) } +func feedbackReadDelay(t *testing.T, ctx context.Context, duration time.Duration) { + t.Helper() + timer := time.NewTimer(duration) + defer timer.Stop() + select { + case <-timer.C: + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } +} + type feedbackQUICFixture struct { ctx context.Context pacer *connectionPacer @@ -336,7 +369,7 @@ func writeFeedbackPayload(writer io.Writer, size int) error { } func readFeedbackPayload(reader io.Reader, size int) error { - buffer := make([]byte, len(feedbackPayload)) + buffer := make([]byte, min(size, len(feedbackPayload))) for remaining := size; remaining > 0; { chunk := buffer[:min(remaining, len(buffer))] if _, err := io.ReadFull(reader, chunk); err != nil {