@@ -97,7 +97,7 @@ pub(crate) fn rate_limit_retry_delay(retry_number: u32) -> Duration {
9797
9898#[ derive( Debug ) ]
9999struct GateWaiter {
100- sender : oneshot:: Sender < ( ) > ,
100+ sender : oneshot:: Sender < DynamicGatePermit > ,
101101}
102102
103103#[ derive( Debug ) ]
@@ -113,6 +113,13 @@ struct GateInner {
113113/// and wakes one waiter. Reducing capacity below `active` is allowed: the
114114/// surplus holders finish naturally and no new permit is granted until the
115115/// active count drops under the new capacity.
116+ ///
117+ /// Waiters receive an *already granted* permit through a oneshot channel, so
118+ /// a waiter future that is cancelled after the grant is dispatched simply
119+ /// drops the permit, whose `Drop` hands the slot to the next waiter. (A
120+ /// wake-and-recheck design would lose that wakeup — the cancelled waiter
121+ /// never re-checks, and with no remaining holders there is no later release
122+ /// to re-dispatch it.)
116123#[ derive( Debug ) ]
117124pub ( crate ) struct DynamicGate {
118125 inner : Mutex < GateInner > ,
@@ -141,83 +148,96 @@ impl DynamicGate {
141148 inner. capacity . saturating_sub ( inner. active )
142149 }
143150
144- /// Adjust the gate capacity. Raising it wakes as many queued waiters as
145- /// the new headroom allows ; lowering it simply stops new admissions until
151+ /// Adjust the gate capacity. Raising it grants queued waiters the new
152+ /// headroom immediately ; lowering it simply stops new admissions until
146153 /// the active count drains below the new capacity.
147- pub ( crate ) fn set_capacity ( & self , capacity : usize ) {
154+ pub ( crate ) fn set_capacity ( self : & std :: sync :: Arc < Self > , capacity : usize ) {
148155 let mut inner = self . inner . lock ( ) . expect ( "launch gate poisoned" ) ;
149156 inner. capacity = capacity;
150- let headroom = capacity. saturating_sub ( inner. active ) ;
151- for _ in 0 ..headroom {
152- match inner. waiters . pop_front ( ) {
153- Some ( waiter) => {
154- // A dropped receiver means the waiter future was cancelled;
155- // skip it and keep waking until headroom or queue ends.
156- if waiter. sender . send ( ( ) ) . is_err ( ) {
157- continue ;
158- }
159- }
160- None => break ,
161- }
162- }
157+ Self :: wake_locked ( self , & mut inner) ;
163158 }
164159
165- fn grant_locked ( inner : & mut GateInner ) -> bool {
166- if inner. active < inner. capacity {
167- inner. active += 1 ;
168- true
169- } else {
170- false
160+ /// Grant queued waiters while there is headroom. Called with the lock
161+ /// held; each waiter receives an already-counted permit, so a cancelled
162+ /// receiver's permit is disarmed (never `Drop`ped under the lock) and the
163+ /// slot flows to the next waiter.
164+ fn wake_locked ( gate : & std:: sync:: Arc < Self > , inner : & mut GateInner ) {
165+ while inner. active < inner. capacity {
166+ let Some ( waiter) = inner. waiters . pop_front ( ) else {
167+ break ;
168+ } ;
169+ let permit = DynamicGatePermit {
170+ gate : Some ( std:: sync:: Arc :: clone ( gate) ) ,
171+ } ;
172+ match waiter. sender . send ( permit) {
173+ Ok ( ( ) ) => inner. active += 1 ,
174+ Err ( mut returned) => {
175+ // The waiter future was cancelled before receiving the
176+ // grant. Disarm instead of dropping: `Drop` would call
177+ // `release()` and re-enter the lock we are holding.
178+ let _ = returned. disarm ( ) ;
179+ }
180+ }
171181 }
172182 }
173183
174- fn release ( & self ) {
184+ fn release ( self : & std :: sync :: Arc < Self > ) {
175185 let mut inner = self . inner . lock ( ) . expect ( "launch gate poisoned" ) ;
176186 inner. active = inner. active . saturating_sub ( 1 ) ;
177- while let Some ( waiter) = inner. waiters . pop_front ( ) {
178- if waiter. sender . send ( ( ) ) . is_ok ( ) {
179- // The woken waiter re-checks capacity under the lock; if the
180- // capacity was lowered in the meantime it will re-queue.
181- break ;
182- }
183- }
187+ Self :: wake_locked ( self , & mut inner) ;
184188 }
185189
186190 /// Try to acquire a permit without waiting.
187191 pub ( crate ) fn try_acquire ( self : & std:: sync:: Arc < Self > ) -> Option < DynamicGatePermit > {
188192 let mut inner = self . inner . lock ( ) . expect ( "launch gate poisoned" ) ;
189- Self :: grant_locked ( & mut inner) . then ( || DynamicGatePermit {
190- gate : std:: sync:: Arc :: clone ( self ) ,
193+ ( inner. active < inner. capacity ) . then ( || {
194+ inner. active += 1 ;
195+ DynamicGatePermit {
196+ gate : Some ( std:: sync:: Arc :: clone ( self ) ) ,
197+ }
191198 } )
192199 }
193200
194201 /// Acquire a permit, waiting until capacity is available. Cancellation
195- /// safe: dropping the future leaves a stale queue entry that releasers
196- /// skip.
202+ /// safe: a dropped future either leaves a stale queue entry (skipped and
203+ /// disarmed by the granter) or drops an already-dispatched permit (whose
204+ /// `Drop` re-releases the slot).
197205 pub ( crate ) async fn acquire ( self : & std:: sync:: Arc < Self > ) -> DynamicGatePermit {
198206 loop {
199207 let rx = {
200208 let mut inner = self . inner . lock ( ) . expect ( "launch gate poisoned" ) ;
201- if Self :: grant_locked ( & mut inner) {
209+ if inner. active < inner. capacity {
210+ inner. active += 1 ;
202211 return DynamicGatePermit {
203- gate : std:: sync:: Arc :: clone ( self ) ,
212+ gate : Some ( std:: sync:: Arc :: clone ( self ) ) ,
204213 } ;
205214 }
206215 let ( tx, rx) = oneshot:: channel ( ) ;
207216 inner. waiters . push_back ( GateWaiter { sender : tx } ) ;
208217 rx
209218 } ;
210- // Ignore send failures: a cancelled waiter's entry is drained by
211- // the releaser, and a capacity change wakes us spuriously — the
212- // loop simply re-checks under the lock.
213- let _ = rx. await ;
219+ // A failed receive means the gate itself was dropped while we
220+ // were queued; the loop re-queues under the lock.
221+ if let Ok ( permit) = rx. await {
222+ return permit;
223+ }
214224 }
215225 }
216226}
217227
218228/// One held launch slot. Released on drop.
229+ ///
230+ /// The gate is an `Option` so the wake path can disarm a permit whose
231+ /// receiver vanished without running `Drop` (which would re-enter the locked
232+ /// `release()`).
219233pub ( crate ) struct DynamicGatePermit {
220- gate : std:: sync:: Arc < DynamicGate > ,
234+ gate : Option < std:: sync:: Arc < DynamicGate > > ,
235+ }
236+
237+ impl DynamicGatePermit {
238+ fn disarm ( & mut self ) -> Option < std:: sync:: Arc < DynamicGate > > {
239+ self . gate . take ( )
240+ }
221241}
222242
223243impl std:: fmt:: Debug for DynamicGatePermit {
@@ -228,7 +248,9 @@ impl std::fmt::Debug for DynamicGatePermit {
228248
229249impl Drop for DynamicGatePermit {
230250 fn drop ( & mut self ) {
231- self . gate . release ( ) ;
251+ if let Some ( gate) = self . gate . take ( ) {
252+ gate. release ( ) ;
253+ }
232254 }
233255}
234256
@@ -279,13 +301,15 @@ impl RateLimitGovernor {
279301 std:: sync:: Arc :: clone ( & self . gate )
280302 }
281303
282- /// Update the ceiling additive increase may climb to (the configured
283- /// launch concurrency). Never lowers the live capacity directly; the
284- /// AIMD loop converges on the new ceiling.
304+ /// Apply a new configured launch capacity: the AIMD ceiling and the gate
305+ /// capacity while not throttled. Applies to the live gate immediately
306+ /// (raising and lowering alike) unless the governor is paused — a pause
307+ /// keeps capacity 0 until recovery, so an external limit change cannot
308+ /// silently lift a rate-limit pause.
285309 pub ( crate ) fn set_max_capacity ( & self , max_capacity : usize ) {
286310 let mut state = self . state . lock ( ) . expect ( "rate limit governor poisoned" ) ;
287311 state. max_capacity = max_capacity. max ( 1 ) ;
288- if !state. paused && self . gate . capacity ( ) > state . max_capacity {
312+ if !state. paused {
289313 self . gate . set_capacity ( state. max_capacity ) ;
290314 }
291315 }
@@ -315,27 +339,45 @@ impl RateLimitGovernor {
315339 state. attempts . push_back ( now) ;
316340 }
317341
342+ /// Lift a pause whose rate-limit events have all aged out of the window,
343+ /// resuming at a conservative quarter of the configured capacity so
344+ /// additive increase climbs the rest of the way. Callers must hold the
345+ /// state lock; `prune` first.
346+ fn unpause_if_window_drained ( & self , state : & mut GovernorState ) {
347+ if !state. paused || !state. limited . is_empty ( ) {
348+ return ;
349+ }
350+ state. paused = false ;
351+ let capacity = ( state. max_capacity / 4 ) . max ( 1 ) ;
352+ self . gate . set_capacity ( capacity) ;
353+ tracing:: info!(
354+ target: "subagent" ,
355+ launch_capacity = capacity,
356+ max_capacity = state. max_capacity,
357+ "rate-limit governor resumed launches after window drained"
358+ ) ;
359+ }
360+
361+ /// Time-driven recovery probe for queued launches. A pause is normally
362+ /// lifted by a successful LLM attempt from an in-flight child, but if the
363+ /// entire in-flight fleet finishes while 429 events are still inside the
364+ /// window, no success ever arrives — without this probe the queue would
365+ /// freeze until each queued child hits its wall-time deadline. Once every
366+ /// limit event has aged out, the next probe resumes launches.
367+ pub ( crate ) fn recover_if_window_drained ( & self , now : Instant ) {
368+ let mut state = self . state . lock ( ) . expect ( "rate limit governor poisoned" ) ;
369+ Self :: prune ( & mut state, now) ;
370+ self . unpause_if_window_drained ( & mut state) ;
371+ }
372+
318373 /// Report a successful sub-agent LLM attempt. Drives AIMD additive
319374 /// increase and clears the pause once the window has drained.
320375 pub ( crate ) fn record_success ( & self , now : Instant ) {
321376 let mut state = self . state . lock ( ) . expect ( "rate limit governor poisoned" ) ;
322377 Self :: prune ( & mut state, now) ;
323378 state. consecutive_successes = state. consecutive_successes . saturating_add ( 1 ) ;
324379
325- if state. paused && state. limited . is_empty ( ) {
326- // All observed limits aged out of the window: recover at a
327- // conservative quarter of the configured capacity and let
328- // additive increase climb the rest of the way.
329- state. paused = false ;
330- let capacity = ( state. max_capacity / 4 ) . max ( 1 ) ;
331- self . gate . set_capacity ( capacity) ;
332- tracing:: info!(
333- target: "subagent" ,
334- launch_capacity = capacity,
335- max_capacity = state. max_capacity,
336- "rate-limit governor resumed launches after window drained"
337- ) ;
338- }
380+ self . unpause_if_window_drained ( & mut state) ;
339381
340382 if !state. paused
341383 && state. consecutive_successes >= SUCCESS_PER_INCREASE_STEP
@@ -655,4 +697,86 @@ mod tests {
655697 // The cap holds for absurd retry numbers.
656698 assert_eq ! ( rate_limit_backoff_base( 40 ) , RATE_LIMIT_MAX_BACKOFF ) ;
657699 }
700+
701+ /// A pause must lift via the time-driven probe even when no in-flight
702+ /// child ever reports another success (the in-flight fleet drained before
703+ /// the window did): otherwise queued children freeze until their
704+ /// wall-time deadline.
705+ #[ test]
706+ fn forkguard_rate_limit_governor_pauses_and_time_recovers_after_window_drains ( ) {
707+ let ( governor, _gate) = RateLimitGovernor :: new ( 8 ) ;
708+ let t0 = Instant :: now ( ) ;
709+ for i in 0 ..4 {
710+ governor. record_attempt ( t0 + ms ( i) ) ;
711+ governor. record_rate_limited ( t0 + ms ( i) ) ;
712+ }
713+ assert ! ( governor. is_paused( t0 + ms( 10 ) ) ) ;
714+
715+ // Probe while 429 events are still inside the window: stays paused.
716+ governor. recover_if_window_drained ( t0 + ms ( 20 ) ) ;
717+ assert ! ( governor. is_paused( t0 + ms( 30 ) ) ) ;
718+
719+ // Once every limit event has aged out, the probe resumes launches at
720+ // a quarter of the configured capacity — no success event required.
721+ let late = t0 + RATE_LIMIT_WINDOW + ms ( 10 ) ;
722+ governor. recover_if_window_drained ( late) ;
723+ assert ! ( !governor. is_paused( late) ) ;
724+ assert_eq ! ( governor. snapshot( late) . launch_capacity, 2 ) ;
725+ }
726+
727+ /// A runtime launch-concurrency change must not silently lift a pause:
728+ /// the gate stays at capacity 0 until the window drains, then resumes at
729+ /// a quarter of the *new* configured capacity.
730+ #[ test]
731+ fn forkguard_rate_limit_governor_limit_change_keeps_pause_capacity_zero ( ) {
732+ let ( governor, gate) = RateLimitGovernor :: new ( 8 ) ;
733+ let t0 = Instant :: now ( ) ;
734+ for i in 0 ..4 {
735+ governor. record_attempt ( t0 + ms ( i) ) ;
736+ governor. record_rate_limited ( t0 + ms ( i) ) ;
737+ }
738+ assert ! ( governor. is_paused( t0 + ms( 1 ) ) ) ;
739+
740+ governor. set_max_capacity ( 4 ) ;
741+ assert_eq ! ( gate. capacity( ) , 0 , "pause must keep capacity 0" ) ;
742+
743+ let late = t0 + RATE_LIMIT_WINDOW + ms ( 10 ) ;
744+ governor. recover_if_window_drained ( late) ;
745+ assert_eq ! (
746+ gate. capacity( ) ,
747+ 1 ,
748+ "resume at a quarter of the new capacity"
749+ ) ;
750+ }
751+
752+ /// A waiter cancelled *after* its grant was dispatched must not swallow
753+ /// the slot: the permit is dropped with the cancelled future and its
754+ /// `Drop` re-releases it for the next waiter.
755+ #[ test]
756+ fn forkguard_dynamic_gate_redispatches_grant_of_cancelled_waiter ( ) {
757+ let ( _governor, gate) = RateLimitGovernor :: new ( 1 ) ;
758+ let rt = tokio:: runtime:: Builder :: new_current_thread ( )
759+ . enable_time ( )
760+ . build ( )
761+ . expect ( "test runtime" ) ;
762+ rt. block_on ( async move {
763+ let holder = gate. try_acquire ( ) . expect ( "holder" ) ;
764+ let g2 = std:: sync:: Arc :: clone ( & gate) ;
765+ let waiter = tokio:: spawn ( async move { g2. acquire ( ) . await } ) ;
766+ tokio:: time:: sleep ( ms ( 20 ) ) . await ;
767+ assert ! ( !waiter. is_finished( ) , "waiter must be queued" ) ;
768+
769+ // Releasing the holder dispatches the grant into the waiter's
770+ // channel; on a current-thread runtime the waiter has not polled
771+ // yet when we abort it, so the permit is dropped mid-flight.
772+ drop ( holder) ;
773+ waiter. abort ( ) ;
774+ tokio:: time:: sleep ( ms ( 20 ) ) . await ;
775+
776+ assert ! (
777+ gate. try_acquire( ) . is_some( ) ,
778+ "grant of cancelled waiter must be re-released, not leaked"
779+ ) ;
780+ } ) ;
781+ }
658782}
0 commit comments