diff --git a/docs/ACCELERATION.md b/docs/ACCELERATION.md index 31127c6..1e52318 100644 --- a/docs/ACCELERATION.md +++ b/docs/ACCELERATION.md @@ -78,6 +78,26 @@ backpressure cannot be hidden by small idle gaps. Subsequent active samples can still reduce the target when capacity, RTT or loss changes. Fixed-rate and bypass modes are unchanged. +The controller separately measures time actually spent waiting for missing +tokens. Only one admission owner can sleep at a time, so concurrent writers do +not multiply that duration; observations include an in-progress sleep and use +the controller's own clock for its cumulative wait baseline. When this waiting +occupies at least half a valid observation interval, a lower observed rate does +not replace the unpenalized capacity history. Higher observations are still +accepted. For valid delivery observations, current RTT and loss factors still +apply to that capacity, not to a rate already reduced by the previous penalty. With no +capacity history yet, a pacing-limited sample uses the configured initial rate +as a prior; an unconstrained sample can replace it with a lower capacity. + +This qualification never subtracts waiting time from the delivery-rate +denominator: QUIC can send already-queued bytes during an application wait. +Actual transport backpressure remains outside token sleep. When a slower path +fills the finite send buffers, predominantly transport-bound samples can age +out the old bandwidth maximum. Sleep share is still an application-level +heuristic, not proof that the path is uncongested. The controller does not add +a separate capacity-probing phase: sustained RTT penalties can limit discovery +of spare capacity until conditions or higher delivery observations change. + This is intentionally not a full BBR state machine. In particular AutoCAR has no transport-visible BDP congestion window, ACK aggregation model, ProbeRTT drain, ECN policy, inflight bounds or BBRv2/BBRv3 logic. Calling it “real BBR” diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 359372f..922c86b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -212,6 +212,13 @@ bandwidth estimate. Predominantly busy intervals remain eligible. Pending pacing waits and blocked transport writes are not idle; concurrent streams share the activity accounting and ordered counter observations. +The controller also records its single admission owner's actual token sleeps. +In pacing-dominated intervals, lower observations do not overwrite capacity +history, but current RTT/loss penalties still apply. Transport-bound intervals +can lower the estimate. This prevents an application-imposed rate reduction +from repeatedly becoming the next capacity estimate; it neither bypasses +QUIC congestion control nor infers wire capacity by subtracting sleep time. + Web H2/H3 streams do not use the native pacing negotiation and report client and relay pacing as `not-applicable`. Fixed-rate is rejected with `web-auto`, `h3`, and `h2`. diff --git a/internal/accel/adaptive.go b/internal/accel/adaptive.go index b38e080..3accc9a 100644 --- a/internal/accel/adaptive.go +++ b/internal/accel/adaptive.go @@ -22,13 +22,14 @@ type adaptiveSettings struct { // deliberately smaller than a transport congestion controller and has no // control over the transport's congestion window or retransmission behavior. type adaptiveEstimator struct { - settings adaptiveSettings - rates [deliveryRateWindow]float64 - next int - count int + settings adaptiveSettings + initialRate float64 + rates [deliveryRateWindow]float64 + next int + count int } -func newAdaptiveEstimator(profile Profile) adaptiveEstimator { +func newAdaptiveEstimator(profile Profile, initialRate int64) adaptiveEstimator { settings := adaptiveSettings{} switch profile { case ProfileConservative: @@ -59,7 +60,7 @@ func newAdaptiveEstimator(profile Profile) adaptiveEstimator { minimumLossFactor: 0.60, } } - return adaptiveEstimator{settings: settings} + return adaptiveEstimator{settings: settings, initialRate: float64(initialRate)} } func (e *adaptiveEstimator) reset() { @@ -73,17 +74,26 @@ func (e *adaptiveEstimator) observe( lossRatio float64, minimumRTT time.Duration, smoothedRTT time.Duration, + pacingLimited bool, ) float64 { - e.rates[e.next] = math.Max(0, deliveredRate) - e.next = (e.next + 1) % len(e.rates) - if e.count < len(e.rates) { - e.count++ + deliveredRate = math.Max(0, deliveredRate) + bottleneckRate := e.bandwidthEstimate() + if !pacingLimited || deliveredRate > bottleneckRate { + // An application pacing limit censors lower capacity observations. Do + // not let those samples replace the unpenalized bandwidth history with + // the result of our own previous RTT/loss reduction. Higher observations + // remain useful evidence, and unconstrained samples can age out an old + // maximum when the path really becomes slower. + e.rates[e.next] = deliveredRate + e.next = (e.next + 1) % len(e.rates) + if e.count < len(e.rates) { + e.count++ + } + bottleneckRate = e.bandwidthEstimate() } - bottleneckRate := float64(0) - for index := 0; index < e.count; index++ { - bottleneckRate = math.Max(bottleneckRate, e.rates[index]) - } + // Always apply current congestion signals to capacity evidence, even when + // a pacing-limited sample was not allowed to change that evidence. target := bottleneckRate * e.settings.pacingGain rttRatio := float64(smoothedRTT) / float64(minimumRTT) @@ -97,3 +107,16 @@ func (e *adaptiveEstimator) observe( } return target } + +func (e *adaptiveEstimator) bandwidthEstimate() float64 { + if e.count == 0 { + // This is only a prior, not a permanent minimum or a history entry. The + // first non-limited observation may establish a lower path capacity. + return e.initialRate + } + maximum := float64(0) + for index := 0; index < e.count; index++ { + maximum = math.Max(maximum, e.rates[index]) + } + return maximum +} diff --git a/internal/accel/adaptive_feedback_test.go b/internal/accel/adaptive_feedback_test.go new file mode 100644 index 0000000..3842cbb --- /dev/null +++ b/internal/accel/adaptive_feedback_test.go @@ -0,0 +1,136 @@ +package accel + +import ( + "math" + "testing" + "time" +) + +func TestAdaptiveEstimatorLimitedSamplesPreserveCapacityHistory(t *testing.T) { + for _, profile := range []Profile{ProfileConservative, ProfileBalanced, ProfileAggressive} { + t.Run(profile.String(), func(t *testing.T) { + estimator := newAdaptiveEstimator(profile, 2_000_000) + estimator.observe(8_000_000, 0, time.Millisecond, time.Millisecond, false) + learned := estimator + for range 4 * deliveryRateWindow { + estimator.observe(100_000, 0, time.Millisecond, time.Millisecond, true) + if estimator != learned { + t.Fatal("low pacing-limited sample aged or changed capacity history") + } + } + estimator.observe(8_000_000, 0, time.Millisecond, time.Millisecond, true) + if estimator != learned { + t.Fatal("equal pacing-limited sample aged capacity history") + } + got := estimator.observe(9_000_000, 0, time.Millisecond, time.Millisecond, true) + assertEstimatorRate(t, got, 9_000_000*estimator.settings.pacingGain) + if estimator.count != learned.count+1 || estimator.bandwidthEstimate() != 9_000_000 { + t.Fatal("higher pacing-limited sample was not learned") + } + }) + } +} + +func TestAdaptiveEstimatorUnconstrainedCapacityCanFallAndRecover(t *testing.T) { + for _, profile := range []Profile{ProfileConservative, ProfileBalanced, ProfileAggressive} { + t.Run(profile.String(), func(t *testing.T) { + estimator := newAdaptiveEstimator(profile, 8_000_000) + estimator.observe(8_000_000, 0, time.Millisecond, time.Millisecond, false) + for index := 0; index < deliveryRateWindow; index++ { + got := estimator.observe(1_000_000, 0, time.Millisecond, time.Millisecond, false) + wantCapacity := float64(8_000_000) + if index == deliveryRateWindow-1 { + wantCapacity = 1_000_000 + } + assertEstimatorRate(t, got, wantCapacity*estimator.settings.pacingGain) + } + got := estimator.observe(4_000_000, 0, time.Millisecond, time.Millisecond, false) + assertEstimatorRate(t, got, 4_000_000*estimator.settings.pacingGain) + }) + } +} + +func TestAdaptiveEstimatorLimitedCongestionDoesNotCompound(t *testing.T) { + for _, test := range []struct { + profile Profile + gain float64 + rttFactor float64 + loss float64 + }{ + {ProfileConservative, 1.00, 0.575, 0.62}, + {ProfileBalanced, 1.08, 0.65, 0.745}, + {ProfileAggressive, 1.18, 0.75, 0.86}, + } { + t.Run(test.profile.String(), func(t *testing.T) { + const capacity = 8_000_000 + estimator := newAdaptiveEstimator(test.profile, capacity) + current := estimator.observe(capacity, 0, time.Millisecond, 2*time.Millisecond, false) + assertEstimatorRate(t, current, capacity*test.gain*test.rttFactor) + learned := estimator + for range 4 * deliveryRateWindow { + current = estimator.observe(current, 0, time.Millisecond, 2*time.Millisecond, true) + assertEstimatorRate(t, current, capacity*test.gain*test.rttFactor) + } + for range 4 * deliveryRateWindow { + current = estimator.observe(current, 0.2, time.Millisecond, 2*time.Millisecond, true) + assertEstimatorRate(t, current, capacity*test.gain*test.rttFactor*test.loss) + } + worseLoss := estimator.observe(current, 0.4, time.Millisecond, 2*time.Millisecond, true) + if worseLoss >= current { + t.Fatalf("worsening loss did not lower target: %g >= %g", worseLoss, current) + } + worseRTT := estimator.observe(current, 0.2, time.Millisecond, 4*time.Millisecond, true) + if worseRTT >= current { + t.Fatalf("worsening RTT did not lower target: %g >= %g", worseRTT, current) + } + recovered := estimator.observe(worseRTT, 0, time.Millisecond, time.Millisecond, true) + assertEstimatorRate(t, recovered, capacity*test.gain) + if estimator != learned { + t.Fatal("RTT/loss changes changed retained bandwidth evidence") + } + }) + } +} + +func TestAdaptiveEstimatorEmptyHistoryUsesPriorOnlyWhenLimited(t *testing.T) { + for _, profile := range []Profile{ProfileConservative, ProfileBalanced, ProfileAggressive} { + t.Run(profile.String(), func(t *testing.T) { + const initial = 8_000_000 + estimator := newAdaptiveEstimator(profile, initial) + prior := estimator + // The comparison estimator has actual capacity evidence, so its + // penalties should match the empty-history prior under the same RTT + // and loss without inserting sparse ACK traffic into history. + reference := newAdaptiveEstimator(profile, initial) + want := reference.observe(initial, 0.2, time.Millisecond, 2*time.Millisecond, false) + for range 4 * deliveryRateWindow { + got := estimator.observe(100, 0.2, time.Millisecond, 2*time.Millisecond, true) + assertEstimatorRate(t, got, want) + if estimator != prior { + t.Fatal("sparse limited sample initialized bandwidth history") + } + } + got := estimator.observe(10_000_000, 0, time.Millisecond, time.Millisecond, true) + assertEstimatorRate(t, got, 10_000_000*estimator.settings.pacingGain) + if estimator.count != 1 || estimator.bandwidthEstimate() != 10_000_000 { + t.Fatal("higher-than-prior limited observation was not learned") + } + estimator.reset() + if estimator != prior { + t.Fatal("reset did not restore empty history while retaining the initial prior") + } + got = estimator.observe(100_000, 0, time.Millisecond, time.Millisecond, false) + assertEstimatorRate(t, got, 100_000*estimator.settings.pacingGain) + if estimator.count != 1 || estimator.bandwidthEstimate() != 100_000 { + t.Fatal("initial prior prevented learning a truly low capacity") + } + }) + } +} + +func assertEstimatorRate(t *testing.T, got, want float64) { + t.Helper() + if math.Abs(got-want) > 0.000001 { + t.Fatalf("estimator target = %g, want %g", got, want) + } +} diff --git a/internal/accel/pacer.go b/internal/accel/pacer.go index 323b480..c3cd13b 100644 --- a/internal/accel/pacer.go +++ b/internal/accel/pacer.go @@ -129,6 +129,15 @@ type Controller struct { haveSnapshot bool previous Snapshot estimator adaptiveEstimator + + // Only the admission owner sleeps for missing tokens. Count this interval + // once, not the overlapping Wait durations of queued writers. Observation + // baselines use our own clock, independently of a caller's Snapshot.At. + pacingSleepStarted time.Time + pacingSleeping bool + pacingSleepTime time.Duration + previousPacingSleep time.Duration + previousObservationTime time.Time } type normalizedConfig struct { @@ -187,7 +196,7 @@ func New(config Config) (*Controller, error) { lastRefill: normalized.clock.Now(), admission: makeAdmissionToken(), stateChange: make(chan struct{}), - estimator: newAdaptiveEstimator(normalized.profile), + estimator: newAdaptiveEstimator(normalized.profile, normalized.initialRate), }, nil } @@ -258,7 +267,7 @@ func (c *Controller) Observe(snapshot Snapshot) error { defer c.mu.Unlock() if !c.haveSnapshot { - c.previous = snapshot + c.recordObservationLocked(snapshot, c.clock.Now()) c.haveSnapshot = true return nil } @@ -269,7 +278,7 @@ func (c *Controller) Observe(snapshot Snapshot) error { if snapshot.SentBytes < c.previous.SentBytes || snapshot.ApplicationIdleTime < c.previous.ApplicationIdleTime { // A cumulative counter reset denotes a new transport epoch. Rebaseline // instead of interpreting wrapped counters as a huge delivery sample. - c.previous = snapshot + c.recordObservationLocked(snapshot, c.clock.Now()) c.estimator.reset() c.setTargetRateLocked(c.initialRate) return nil @@ -284,13 +293,17 @@ func (c *Controller) Observe(snapshot Snapshot) error { return nil } + now := c.clock.Now() + observationElapsed := now.Sub(c.previousObservationTime) + pacingElapsed := c.pacingTimeLocked(now) - c.previousPacingSleep + pacingLimited := observationElapsed > 0 && pacingElapsed > 0 && pacingElapsed >= observationElapsed/2 idleDelta := snapshot.ApplicationIdleTime - c.previous.ApplicationIdleTime sentDelta := snapshot.SentBytes - c.previous.SentBytes lostDelta := uint64(0) if snapshot.LostBytes >= c.previous.LostBytes { lostDelta = snapshot.LostBytes - c.previous.LostBytes } - c.previous = snapshot + c.recordObservationLocked(snapshot, now) if idleDelta >= window && idleDelta >= elapsed/2 { // ACK/control traffic during application silence does not measure path @@ -313,12 +326,29 @@ func (c *Controller) Observe(snapshot Snapshot) error { deliveredRate := float64(deliveredDelta) / elapsed.Seconds() lossRatio := float64(lostDelta) / float64(sentDelta) - target := c.estimator.observe(deliveredRate, lossRatio, snapshot.MinRTT, snapshot.SmoothedRTT) + // QUIC can transmit queued bytes while our writer waits for tokens. Do not + // subtract pacing time from elapsed to invent a larger delivery rate. It + // only qualifies lower capacity samples; RTT/loss still affect the target. + target := c.estimator.observe(deliveredRate, lossRatio, snapshot.MinRTT, snapshot.SmoothedRTT, pacingLimited) target = math.Max(float64(c.minimumRate), math.Min(float64(c.maximumRate), target)) c.setTargetRateLocked(int64(math.Round(target))) return nil } +func (c *Controller) recordObservationLocked(snapshot Snapshot, now time.Time) { + c.previous = snapshot + c.previousObservationTime = now + c.previousPacingSleep = c.pacingTimeLocked(now) +} + +func (c *Controller) pacingTimeLocked(now time.Time) time.Duration { + elapsed := c.pacingSleepTime + if c.pacingSleeping && now.After(c.pacingSleepStarted) { + elapsed += now.Sub(c.pacingSleepStarted) + } + return elapsed +} + func stableSampleWindow(minimumRTT time.Duration) time.Duration { window := minimumRTT / 4 if window < minimumSampleWindow { @@ -388,9 +418,21 @@ func (c *Controller) admit(ctx context.Context, amount float64) error { missing := amount - c.tokens delay := durationForBytes(missing, c.targetRate) stateChange := c.stateChange + if c.mode == ModeAdaptive { + c.pacingSleepStarted = c.clock.Now() + c.pacingSleeping = true + } c.mu.Unlock() - if err := c.sleepUntilStateChange(ctx, delay, stateChange); err != nil { + err := c.sleepUntilStateChange(ctx, delay, stateChange) + if c.mode == ModeAdaptive { + c.mu.Lock() + c.pacingSleepTime = c.pacingTimeLocked(c.clock.Now()) + c.pacingSleepStarted = time.Time{} + c.pacingSleeping = false + c.mu.Unlock() + } + if err != nil { return err } } diff --git a/internal/accel/pacing_sleep_test.go b/internal/accel/pacing_sleep_test.go new file mode 100644 index 0000000..f1865ae --- /dev/null +++ b/internal/accel/pacing_sleep_test.go @@ -0,0 +1,508 @@ +package accel + +import ( + "context" + "errors" + "sync" + "testing" + "time" +) + +func TestPacingSleepOngoingAndSubWindowObservation(t *testing.T) { + clock := newPacingSleepClock() + controller := newPacingSleepController(t, clock) + // The transport timestamp deliberately has a different epoch. Pacing time + // must be compared with the controller's clock, not with Snapshot.At. + base := Snapshot{At: time.Unix(100, 0), MinRTT: time.Millisecond, SmoothedRTT: time.Millisecond} + mustObserveIdleSnapshot(t, controller, base) + started := clock.Now() + if err := controller.Wait(context.Background(), 1000); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + done := make(chan error, 1) + go func() { done <- controller.Wait(ctx, 1000) }() + clock.nextSleep(t) + for _, elapsed := range []time.Duration{4 * time.Millisecond, 8 * time.Millisecond} { + clock.advance(4 * time.Millisecond) + sample := base + sample.At = base.At.Add(elapsed) + sample.SentBytes = uint64(elapsed / time.Millisecond) + mustObserveIdleSnapshot(t, controller, sample) + controller.mu.Lock() + previous, observation, previousSleep := controller.previous, controller.previousObservationTime, controller.previousPacingSleep + ongoing := controller.pacingTimeLocked(clock.Now()) + controller.mu.Unlock() + if previous != base || observation != started || previousSleep != 0 || ongoing != elapsed { + t.Fatalf("sub-window consumed or lost sleep: previous=%+v own=%v baseline=%s ongoing=%s", previous, observation, previousSleep, ongoing) + } + } + clock.advance(2 * time.Millisecond) + sample := base + sample.At = base.At.Add(10 * time.Millisecond) + sample.SentBytes = 10 + mustObserveIdleSnapshot(t, controller, sample) + controller.mu.Lock() + previous, observation, previousSleep := controller.previous, controller.previousObservationTime, controller.previousPacingSleep + controller.mu.Unlock() + if previous != sample || observation != clock.Now() || previousSleep != 10*time.Millisecond { + t.Fatalf("accepted observation did not retain partial sleep: previous=%+v own=%v sleep=%s", previous, observation, previousSleep) + } + cancel() + if err := awaitPacingSleepWait(t, done); !errors.Is(err, context.Canceled) { + t.Fatalf("Wait error = %v, want cancellation", err) + } + assertPacingSleep(t, controller, clock.Now(), 10*time.Millisecond, false) +} + +func TestPacingSleepConcurrentWaitersAreNotDoubleCounted(t *testing.T) { + clock := newPacingSleepClock() + controller := newPacingSleepController(t, clock) + if err := controller.Wait(context.Background(), 1000); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + const writers = 16 + done := make(chan error, writers) + entered := make(chan struct{}, writers) + for range writers { + go func() { + entered <- struct{}{} + done <- controller.Wait(ctx, 1000) + }() + } + for range writers { + <-entered + } + clock.nextSleep(t) + clock.advance(40 * time.Millisecond) + assertPacingSleep(t, controller, clock.Now(), 40*time.Millisecond, true) + select { + case <-clock.requests: + t.Fatal("more than one admission holder entered token sleep") + default: + } + cancel() + for range writers { + if err := awaitPacingSleepWait(t, done); !errors.Is(err, context.Canceled) { + t.Fatalf("Wait error = %v, want cancellation", err) + } + } + assertPacingSleep(t, controller, clock.Now(), 40*time.Millisecond, false) + clock.advance(time.Second) + assertPacingSleep(t, controller, clock.Now(), 40*time.Millisecond, false) +} + +func TestPacingSleepRecordsActualElapsedAndStopsAtReturn(t *testing.T) { + clock := newPacingSleepClock() + controller := newPacingSleepController(t, clock) + if err := controller.Wait(context.Background(), 1000); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + done := make(chan error, 1) + go func() { done <- controller.Wait(ctx, 1000) }() + sleep := clock.nextSleep(t) + if sleep.duration != time.Second { + t.Fatalf("requested sleep = %s, want 1s", sleep.duration) + } + clock.advance(1250 * time.Millisecond) + close(sleep.release) + if err := awaitPacingSleepWait(t, done); err != nil { + t.Fatal(err) + } + assertPacingSleep(t, controller, clock.Now(), 1250*time.Millisecond, false) + clock.advance(time.Second) + assertPacingSleep(t, controller, clock.Now(), 1250*time.Millisecond, false) +} + +func TestPacingSleepZeroEpochTracksFirstSleep(t *testing.T) { + clock := newPacingSleepClock() + clock.now = time.Time{} + controller := newPacingSleepController(t, clock) + if err := controller.Wait(context.Background(), 1000); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + done := make(chan error, 1) + go func() { done <- controller.Wait(ctx, 1000) }() + sleep := clock.nextSleep(t) + if sleep.duration != time.Second { + t.Fatalf("requested sleep = %s, want 1s", sleep.duration) + } + // The active sleep timestamp is legitimately zero. Its activity must not + // be inferred from IsZero(), either during observation or at completion. + assertPacingSleep(t, controller, clock.Now(), 0, true) + clock.advance(250 * time.Millisecond) + assertPacingSleep(t, controller, clock.Now(), 250*time.Millisecond, true) + clock.advance(750 * time.Millisecond) + close(sleep.release) + if err := awaitPacingSleepWait(t, done); err != nil { + t.Fatal(err) + } + assertPacingSleep(t, controller, clock.Now(), time.Second, false) + clock.advance(time.Second) + assertPacingSleep(t, controller, clock.Now(), time.Second, false) +} + +func TestPacingSleepRefundAndRateChangeFinishAccounting(t *testing.T) { + for _, action := range []string{"refund", "rate_change"} { + t.Run(action, func(t *testing.T) { + clock := newPacingSleepClock() + controller := newPacingSleepController(t, clock) + if err := controller.Wait(context.Background(), 1000); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + done := make(chan error, 1) + go func() { done <- controller.Wait(ctx, 1000) }() + clock.nextSleep(t) + clock.advance(200 * time.Millisecond) + wantSleep := 200 * time.Millisecond + if action == "refund" { + controller.refund(1000) + } else { + controller.mu.Lock() + controller.setTargetRateLocked(2000) + controller.mu.Unlock() + recalculated := clock.nextSleep(t) + if recalculated.duration != 400*time.Millisecond { + t.Fatalf("recalculated sleep = %s, want 400ms", recalculated.duration) + } + assertPacingSleep(t, controller, clock.Now(), 200*time.Millisecond, true) + clock.advance(400 * time.Millisecond) + close(recalculated.release) + wantSleep += 400 * time.Millisecond + } + if err := awaitPacingSleepWait(t, done); err != nil { + t.Fatal(err) + } + assertPacingSleep(t, controller, clock.Now(), wantSleep, false) + }) + } +} + +func TestPacingSleepCanceledMultiChunkWaitRefundsWithoutLeakingTime(t *testing.T) { + clock := newPacingSleepClock() + controller := newPacingSleepController(t, clock) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + done := make(chan error, 1) + // One chunk consumes the initial burst; the second sleeps and is canceled. + go func() { done <- controller.Wait(ctx, 2000) }() + clock.nextSleep(t) + clock.advance(37 * time.Millisecond) + cancel() + if err := awaitPacingSleepWait(t, done); !errors.Is(err, context.Canceled) { + t.Fatalf("Wait error = %v, want cancellation", err) + } + assertPacingSleep(t, controller, clock.Now(), 37*time.Millisecond, false) + refundedCtx, cancelRefunded := context.WithTimeout(context.Background(), time.Second) + defer cancelRefunded() + if err := controller.Wait(refundedCtx, 1000); err != nil { + t.Fatal(err) + } + assertPacingSleep(t, controller, clock.Now(), 37*time.Millisecond, false) +} + +func TestPacingSleepClassificationUsesOwnElapsedTime(t *testing.T) { + for _, condition := range []string{"all_pacing", "mostly_transport", "exactly_half", "half_minus_1ns"} { + t.Run(condition, func(t *testing.T) { + clock := newFakeClock() + controller := newPacingSleepController(t, clock) + snapshot := Snapshot{At: time.Unix(100, 0), MinRTT: time.Millisecond, SmoothedRTT: time.Millisecond} + mustObserveIdleSnapshot(t, controller, snapshot) + snapshot.At = snapshot.At.Add(time.Second) + snapshot.SentBytes = 10_000 + mustObserveIdleSnapshot(t, controller, snapshot) + beforeNext := controller.estimator.next + beforeWait := clock.Now() + if err := controller.Wait(context.Background(), 2000); err != nil { + t.Fatal(err) + } + pacingTime := clock.Now().Sub(beforeWait) + var externalWork time.Duration + switch condition { + case "mostly_transport": + externalWork = time.Second + case "exactly_half": + externalWork = pacingTime + case "half_minus_1ns": + externalWork = pacingTime + 2*time.Nanosecond + } + if externalWork > 0 { + // This is transport/application work, not the controller's sleep. + if err := clock.Sleep(context.Background(), externalWork); err != nil { + t.Fatal(err) + } + } + // Transport observation time spans 10s while the own clock spans + // about 93ms of pacing plus the independently specified work above. + snapshot.At = snapshot.At.Add(10 * time.Second) + snapshot.SentBytes += 1000 + mustObserveIdleSnapshot(t, controller, snapshot) + wantNext := beforeNext + if condition == "mostly_transport" || condition == "half_minus_1ns" { + wantNext = (beforeNext + 1) % deliveryRateWindow + } + if controller.estimator.next != wantNext { + t.Fatalf("external work=%s: history index=%d want %d; pacing must use own-clock elapsed", externalWork, controller.estimator.next, wantNext) + } + }) + } +} + +func TestPacingSleepEpochResetRebaselinesOwnClock(t *testing.T) { + for _, counter := range []string{"sent", "application_idle"} { + t.Run(counter, func(t *testing.T) { + clock := newFakeClock() + controller := newPacingSleepController(t, clock) + base := Snapshot{At: time.Unix(100, 0), SentBytes: 10, ApplicationIdleTime: time.Second, MinRTT: time.Millisecond, SmoothedRTT: time.Millisecond} + mustObserveIdleSnapshot(t, controller, base) + if err := controller.Wait(context.Background(), 2000); err != nil { + t.Fatal(err) + } + reset := base + reset.At = base.At.Add(time.Millisecond) + if counter == "sent" { + reset.SentBytes = 0 + } else { + reset.ApplicationIdleTime = 0 + } + mustObserveIdleSnapshot(t, controller, reset) + controller.mu.Lock() + observation, previousSleep := controller.previousObservationTime, controller.previousPacingSleep + controller.mu.Unlock() + if observation != clock.Now() || previousSleep != time.Second { + t.Fatalf("epoch reset retained old pacing baseline: own=%v sleep=%s", observation, previousSleep) + } + }) + } +} + +func TestPacingSleepNonAdaptiveModesKeepAccountingInactive(t *testing.T) { + for _, mode := range []Mode{ModeFixedRate, ModeReno} { + t.Run(mode.String(), func(t *testing.T) { + clock := newFakeClock() + controller, err := New(Config{Mode: mode, FixedRateBytesPerSecond: 1000, BurstBytes: 1000, Clock: clock}) + if err != nil { + t.Fatal(err) + } + if err := controller.Wait(context.Background(), 2000); err != nil { + t.Fatal(err) + } + assertPacingSleep(t, controller, clock.Now(), 0, false) + wantSleep := time.Duration(0) + if mode == ModeFixedRate { + wantSleep = time.Second + } + if clock.totalSleep() != wantSleep { + t.Fatalf("mode %s sleep=%s want %s", mode, clock.totalSleep(), wantSleep) + } + }) + } +} + +func TestPacingSleepClosedLoopPersistentRTTDoesNotCompound(t *testing.T) { + for _, profile := range []Profile{ProfileConservative, ProfileBalanced, ProfileAggressive} { + t.Run(profile.String(), func(t *testing.T) { + clock := newFakeClock() + controller, err := New(Config{Profile: profile, Clock: clock}) + if err != nil { + t.Fatal(err) + } + const chunk, total = 32 << 10, 8 << 20 + snapshot := Snapshot{MinRTT: time.Millisecond, SmoothedRTT: 2 * time.Millisecond} + var halfwayTarget int64 + for sent := 0; sent < total; sent += chunk { + snapshot.At = clock.Now() + mustObserveIdleSnapshot(t, controller, snapshot) + if err := controller.Wait(context.Background(), chunk); err != nil { + t.Fatal(err) + } + // This separate fixed-capacity wire delay is not token waiting. + if err := clock.Sleep(context.Background(), time.Millisecond); err != nil { + t.Fatal(err) + } + snapshot.SentBytes += chunk + if sent+chunk == total/2 { + halfwayTarget = controller.TargetBytesPerSecond() + } + } + finalTarget := controller.TargetBytesPerSecond() + if halfwayTarget <= defaultMinimumRate || finalTarget < halfwayTarget { + t.Fatalf("unchanged path kept compounding its penalty: halfway=%d final=%d", halfwayTarget, finalTarget) + } + wantSleep := clock.totalSleep() - time.Duration(total/chunk)*time.Millisecond + if wantSleep <= 0 { + t.Fatal("fixture did not exercise pacing sleep") + } + assertPacingSleep(t, controller, clock.Now(), wantSleep, false) + }) + } +} + +func TestPacingSleepClosedLoopPathDropAndRecovery(t *testing.T) { + for _, profile := range []Profile{ProfileConservative, ProfileBalanced, ProfileAggressive} { + t.Run(profile.String(), func(t *testing.T) { + clock := newFakeClock() + controller, err := New(Config{Profile: profile, Clock: clock}) + if err != nil { + t.Fatal(err) + } + const chunk = 32 << 10 + snapshot := Snapshot{At: clock.Now(), MinRTT: time.Millisecond, SmoothedRTT: 2 * time.Millisecond} + mustObserveIdleSnapshot(t, controller, snapshot) + type phaseResult struct { + target int64 + lowerSamples int + censored int + } + phase := func(name string, chunks int, wireDelay, smoothedRTT time.Duration) phaseResult { + t.Helper() + result := phaseResult{} + started := clock.Now() + snapshot.SmoothedRTT = smoothedRTT + for range chunks { + // Every chunk uses production Wait. Independent wire work is + // never declared pacing-limited by this fixture. Observing each + // completed chunk supplies the next Observe-before-Wait cycle. + if err := controller.Wait(context.Background(), chunk); err != nil { + t.Fatal(err) + } + if err := clock.Sleep(context.Background(), wireDelay); err != nil { + t.Fatal(err) + } + snapshot.At = clock.Now() + snapshot.SentBytes += chunk + beforeNext := controller.estimator.next + beforeCapacity := controller.estimator.bandwidthEstimate() + beforeObservation := controller.previous.At + mustObserveIdleSnapshot(t, controller, snapshot) + if controller.estimator.next != beforeNext { + // A newly inserted lower rate proves the real Controller + // classifier allowed transport-bound capacity learning. + if controller.estimator.rates[beforeNext] < beforeCapacity { + result.lowerSamples++ + } + } else if controller.previous.At != beforeObservation { + result.censored++ + } + } + result.target = controller.TargetBytesPerSecond() + t.Logf("phase=%s virtual_elapsed=%s target=%d lower_samples=%d censored=%d", name, clock.Now().Sub(started), result.target, result.lowerSamples, result.censored) + return result + } + stable := phase("fast path with 2x RTT", 32*deliveryRateWindow, time.Millisecond, 2*time.Millisecond) + if stable.target <= defaultMinimumRate || stable.censored < deliveryRateWindow { + t.Fatalf("initial phase did not establish stable pacing-limited samples: %+v", stable) + } + // 32KiB/32ms is 1,024,000 B/s. Keep the RTT penalty unchanged, + // so capacity learning (not removal of the penalty) must lower rate. + slow := phase("path step-down", 16*deliveryRateWindow, 32*time.Millisecond, 2*time.Millisecond) + if slow.target >= stable.target/2 || slow.target <= defaultMinimumRate || slow.lowerSamples < deliveryRateWindow { + t.Fatalf("transport-bound step-down was not learned: initial=%+v slow=%+v", stable, slow) + } + recoveredRTT := phase("RTT recovery on slow path", 16*deliveryRateWindow, 32*time.Millisecond, time.Millisecond) + if recoveredRTT.target <= slow.target { + t.Fatalf("RTT recovery failed: slow=%d recovered=%d", slow.target, recoveredRTT.target) + } + recoveredPath := phase("fourfold path recovery", 32*deliveryRateWindow, 8*time.Millisecond, time.Millisecond) + if recoveredPath.target < recoveredRTT.target { + t.Fatalf("improved path reduced target: before=%d after=%d", recoveredRTT.target, recoveredPath.target) + } + // Gain 1.0 cannot actively probe an unobserved fourfold increase. + // Conservative must preserve its recovered rate; the other profiles + // have explicit positive probe gains and can rediscover this model's + // fixed wire ceiling. These are synthetic, not real-network gates. + if profile != ProfileConservative && recoveredPath.target < 4_096_000 { + t.Fatalf("profile %s failed to probe recovered capacity: %d", profile, recoveredPath.target) + } + }) + } +} + +func newPacingSleepController(t *testing.T, clock Clock) *Controller { + t.Helper() + controller, err := New(Config{InitialRateBytesPerSecond: 1000, MinRateBytesPerSecond: 1, MaxRateBytesPerSecond: 1_000_000, BurstBytes: 1000, Clock: clock}) + if err != nil { + t.Fatal(err) + } + return controller +} + +func assertPacingSleep(t *testing.T, controller *Controller, now time.Time, want time.Duration, active bool) { + t.Helper() + controller.mu.Lock() + total, started, sleeping := controller.pacingTimeLocked(now), controller.pacingSleepStarted, controller.pacingSleeping + controller.mu.Unlock() + if total != want || sleeping != active || (!active && !started.IsZero()) { + t.Fatalf("pacing sleep total/active/started = %s/%t/%v, want %s/%t with cleared inactive timestamp", total, sleeping, started, want, active) + } +} + +func awaitPacingSleepWait(t *testing.T, done <-chan error) error { + t.Helper() + select { + case err := <-done: + return err + case <-time.After(3 * time.Second): + t.Fatal("pacing Wait did not finish") + return nil + } +} + +// Unlike blockingClock, this clock advances independently of releasing a +// sleep. It can model partial waits, interruptions and scheduler oversleep. +type pacingSleepClock struct { + mu sync.Mutex + now time.Time + requests chan blockingSleep +} + +func newPacingSleepClock() *pacingSleepClock { + return &pacingSleepClock{now: time.Unix(1, 0), requests: make(chan blockingSleep, 32)} +} + +func (c *pacingSleepClock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.now +} + +func (c *pacingSleepClock) advance(duration time.Duration) { + c.mu.Lock() + c.now = c.now.Add(duration) + c.mu.Unlock() +} + +func (c *pacingSleepClock) Sleep(ctx context.Context, duration time.Duration) error { + request := blockingSleep{duration: duration, release: make(chan struct{})} + select { + case c.requests <- request: + case <-ctx.Done(): + return ctx.Err() + } + select { + case <-request.release: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (c *pacingSleepClock) nextSleep(t *testing.T) blockingSleep { + t.Helper() + select { + case request := <-c.requests: + return request + case <-time.After(time.Second): + t.Fatal("timed out waiting for pacing sleep") + return blockingSleep{} + } +} diff --git a/internal/tunnel/pacing_feedback_integration_test.go b/internal/tunnel/pacing_feedback_integration_test.go new file mode 100644 index 0000000..31dde5f --- /dev/null +++ b/internal/tunnel/pacing_feedback_integration_test.go @@ -0,0 +1,349 @@ +package tunnel + +import ( + "bytes" + "context" + "fmt" + "io" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/cppla/autocar/internal/accel" + quic "github.com/quic-go/quic-go" +) + +const feedbackChunkSize = 32 << 10 + +var feedbackPayload = bytes.Repeat([]byte("QUIC feedback payload:0123456789"), feedbackChunkSize/32) + +// These tests cover the real QUIC stream adapter/controller, not authentication +// or serverCore. The deadline bounds failures; no comparison with another mode +// or a minimum transfer speed determines success. +func TestQUICPacingSustainedDownloadDoesNotSelfLimitToFloor(t *testing.T) { + f := newFeedbackQUICFixture(t, 1, 0) + const size = 8 << 20 + writer := f.run(func() error { return writeFeedbackPayload(f.serverStreams[0], size) }) + if err := readFeedbackPayload(f.clientStreams[0], size); err != nil { + t.Fatalf("8 MiB download: %v (target %d)", err, f.pacer.controller.TargetBytesPerSecond()) + } + f.await(t, writer) + if got := f.pacer.controller.TargetBytesPerSecond(); got <= 64<<10 { + t.Fatalf("sustained download reached default minimum target: %d", got) + } + if f.clock.sleeps.Load() == 0 { + t.Fatal("fixture never exercised actual token waiting") + } + f.checkReverse(t) + t.Logf("8 MiB intact; target=%d; real token sleeps=%d", f.pacer.controller.TargetBytesPerSecond(), f.clock.sleeps.Load()) +} + +func TestQUICPacingSharedStreamsAcceptTransportBackpressure(t *testing.T) { + // 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. + f := newFeedbackQUICFixture(t, 2, 16<<10) + warmup := f.run(func() error { return writeFeedbackPayload(f.serverStreams[0], 1<<20) }) + if err := readFeedbackPayload(f.clientStreams[0], 1<<20); err != nil { + t.Fatal(err) + } + f.await(t, warmup) + if f.clock.sleeps.Load() == 0 { + t.Fatal("warmup never exercised actual token waiting") + } + + const rounds = 16 + const slowBytes = rounds * feedbackChunkSize + primaryEntered := make(chan struct{}, 1) + f.rawStreams[0].entered = primaryEntered + primaryTransportBefore := f.rawStreams[0].writeTime.Load() + primaryPacingBefore := f.streamPacers[0].waitTime.Load() + primary := f.run(func() error { return writeFeedbackPayload(f.serverStreams[0], slowBytes) }) + select { + case <-primaryEntered: + case <-f.ctx.Done(): + t.Fatal(f.ctx.Err()) + } + // 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 { + t.Fatal(err) + } + f.await(t, siblingBurst) + if f.clock.sleepsWithPrimaryWrite.Load() == 0 { + t.Fatal("did not observe sibling token sleep overlapping a primary QUIC write") + } + + before := f.pacer.controller.TargetBytesPerSecond() + // Both streams now have slow readers. Unlike one blocked stream with an + // unrestricted sibling, this reduces delivery for the shared connection. + siblingSlow := f.run(func() error { return writeFeedbackPayload(f.serverStreams[1], slowBytes) }) + 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) + } + } + } + f.await(t, primary) + f.await(t, siblingSlow) + after := f.pacer.controller.TargetBytesPerSecond() + transport := time.Duration(f.rawStreams[0].writeTime.Load() - primaryTransportBefore) + pacing := time.Duration(f.streamPacers[0].waitTime.Load() - primaryPacingBefore) + if transport <= pacing { + t.Fatalf("slow-reader phase was not transport-bound: QUIC Write=%s, pacing Wait=%s", transport, pacing) + } + // In the default balanced profile both RTT and loss penalties have a 0.60 + // floor. With unchanged capacity, even applying both worst-case penalties + // cannot reduce the target below 36% of its previous value. Crossing that + // bound proves capacity evidence changed, not merely a congestion penalty. + if after >= before*36/100 { + t.Fatalf("shared capacity did not adapt to transport backpressure: target %d -> %d, want below %d", before, after, before*36/100) + } + // Removing the imposed reader delays must leave the same streams usable. + for index := range f.serverStreams { + writer := f.run(func() error { return writeFeedbackPayload(f.serverStreams[index], 128<<10) }) + if err := readFeedbackPayload(f.clientStreams[index], 128<<10); err != nil { + t.Fatal(err) + } + f.await(t, writer) + } + f.checkReverse(t) + t.Logf("shared target %d -> %d; primary QUIC Write=%s, pacing Wait=%s; overlapping token sleeps=%d", + before, after, transport, pacing, f.clock.sleepsWithPrimaryWrite.Load()) +} + +type feedbackQUICFixture struct { + ctx context.Context + pacer *connectionPacer + clock *feedbackRealClock + clientStreams []*quic.Stream + serverStreams []*quicStreamConn + rawStreams []*feedbackRawStream + streamPacers []*feedbackObservedPacer + workers sync.WaitGroup +} + +func newFeedbackQUICFixture(t *testing.T, streamCount int, receiveWindow uint64) *feedbackQUICFixture { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + f := &feedbackQUICFixture{ctx: ctx, clock: &feedbackRealClock{}} + var listener *quic.Listener + var client, server *quic.Conn + t.Cleanup(func() { + cancel() + if client != nil { + _ = client.CloseWithError(applicationShutdown, "test finished") + } + if server != nil { + _ = server.CloseWithError(applicationShutdown, "test finished") + } + if listener != nil { + _ = listener.Close() + } + done := make(chan struct{}) + go func() { f.workers.Wait(); close(done) }() + select { + case <-done: + case <-time.After(time.Second): + t.Error("feedback fixture writers did not stop") + } + }) + serverTLS, clientTLS := testTLSConfigs(t) + var err error + listener, err = quic.ListenAddr("127.0.0.1:0", mustServerTLSConfig(t, serverTLS), hardenedQUICServerConfig(nil, streamCount)) + if err != nil { + t.Fatal(err) + } + rawTLS, err := clientTLSConfig(clientTLS, listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + quicConfig := hardenedQUICClientConfig(nil) + if receiveWindow != 0 { + quicConfig.InitialStreamReceiveWindow = receiveWindow + quicConfig.MaxStreamReceiveWindow = receiveWindow + quicConfig.InitialConnectionReceiveWindow = receiveWindow * uint64(streamCount) * 4 + quicConfig.MaxConnectionReceiveWindow = quicConfig.InitialConnectionReceiveWindow + } + client, err = quic.DialAddr(ctx, listener.Addr().String(), rawTLS, quicConfig) + if err != nil { + t.Fatal(err) + } + server, err = listener.Accept(ctx) + if err != nil { + t.Fatal(err) + } + f.pacer, err = newConnectionPacer(PacingConfig{}, 0) + if err != nil { + t.Fatal(err) + } + config, err := f.pacer.config.accelConfig(0) + if err != nil { + t.Fatal(err) + } + // Identical wall-clock semantics to accel's systemClock; this observes + // actual timer sleeps without changing default rates, gains, or time. + config.Clock = f.clock + f.pacer.controller, err = accel.New(config) + if err != nil { + t.Fatal(err) + } + deadline, _ := ctx.Deadline() + for index := 0; index < streamCount; index++ { + clientStream, err := client.OpenStreamSync(ctx) + if err != nil { + t.Fatal(err) + } + if err := clientStream.SetDeadline(deadline); err != nil { + t.Fatal(err) + } + if _, err := clientStream.Write([]byte{byte(index)}); err != nil { + t.Fatal(err) + } + serverStream, err := server.AcceptStream(ctx) + if err != nil { + t.Fatal(err) + } + if err := serverStream.SetDeadline(deadline); err != nil { + t.Fatal(err) + } + var marker [1]byte + if _, err := io.ReadFull(serverStream, marker[:]); err != nil || marker[0] != byte(index) { + t.Fatalf("stream marker: %v, %v", marker, err) + } + raw := &feedbackRawStream{quicStream: serverStream} + observed := &feedbackObservedPacer{connectionPacer: f.pacer} + f.clientStreams = append(f.clientStreams, clientStream) + f.serverStreams = append(f.serverStreams, newQUICStreamConnWithPacer(raw, server, observed)) + f.rawStreams = append(f.rawStreams, raw) + f.streamPacers = append(f.streamPacers, observed) + } + f.clock.primary = &f.rawStreams[0].pending + return f +} + +func (f *feedbackQUICFixture) run(work func() error) <-chan error { + done := make(chan error, 1) + f.workers.Add(1) + go func() { defer f.workers.Done(); done <- work() }() + return done +} + +func (f *feedbackQUICFixture) await(t *testing.T, done <-chan error) { + t.Helper() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-f.ctx.Done(): + t.Fatal(f.ctx.Err()) + } +} + +func (f *feedbackQUICFixture) checkReverse(t *testing.T) { + t.Helper() + want := []byte("same connection reverse direction remains usable") + if _, err := f.clientStreams[0].Write(want); err != nil { + t.Fatal(err) + } + got := make([]byte, len(want)) + if _, err := io.ReadFull(f.serverStreams[0], got); err != nil || !bytes.Equal(got, want) { + t.Fatalf("reverse payload mismatch: %q, %v", got, err) + } +} + +func writeFeedbackPayload(writer io.Writer, size int) error { + for remaining := size; remaining > 0; { + chunk := feedbackPayload[:min(remaining, len(feedbackPayload))] + n, err := writer.Write(chunk) + if err != nil { + return err + } + if n != len(chunk) { + return io.ErrShortWrite + } + remaining -= n + } + return nil +} + +func readFeedbackPayload(reader io.Reader, size int) error { + buffer := make([]byte, len(feedbackPayload)) + for remaining := size; remaining > 0; { + chunk := buffer[:min(remaining, len(buffer))] + if _, err := io.ReadFull(reader, chunk); err != nil { + return err + } + if !bytes.Equal(chunk, feedbackPayload[:len(chunk)]) { + return fmt.Errorf("feedback payload mismatch at byte %d", size-remaining) + } + remaining -= len(chunk) + } + return nil +} + +type feedbackRawStream struct { + quicStream + entered chan struct{} + pending atomic.Bool + writeTime atomic.Int64 +} + +func (s *feedbackRawStream) Write(p []byte) (int, error) { + started := time.Now() + s.pending.Store(true) + select { + case s.entered <- struct{}{}: + default: + } + n, err := s.quicStream.Write(p) + s.pending.Store(false) + s.writeTime.Add(int64(time.Since(started))) + return n, err +} + +type feedbackObservedPacer struct { + *connectionPacer + waitTime atomic.Int64 +} + +func (p *feedbackObservedPacer) wait(ctx context.Context, size int, conn *quic.Conn) error { + started := time.Now() + err := p.connectionPacer.wait(ctx, size, conn) + p.waitTime.Add(int64(time.Since(started))) + return err +} + +type feedbackRealClock struct { + primary *atomic.Bool + sleeps atomic.Int64 + sleepsWithPrimaryWrite atomic.Int64 +} + +func (*feedbackRealClock) Now() time.Time { return time.Now() } + +func (c *feedbackRealClock) Sleep(ctx context.Context, duration time.Duration) error { + c.sleeps.Add(1) + if c.primary != nil && c.primary.Load() { + c.sleepsWithPrimaryWrite.Add(1) + } + timer := time.NewTimer(duration) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +}