11use std:: io:: Write ;
22use std:: sync:: Arc ;
33use std:: thread;
4- use std:: time:: Duration ;
54
6- use anyhow:: Result ;
5+ use anyhow:: { bail , Result } ;
76use parking_lot:: Condvar ;
87use parking_lot:: Mutex ;
98
@@ -19,9 +18,6 @@ const DEFAULT_BUFFER_SIZE: usize = 1024 * 1024;
1918/// Set the maximum overflow buffer size to 128MB
2019const MAXIMUM_BUFFER_SIZE : usize = 128 * 1024 * 1024 ;
2120
22- /// Sets the default sleep time (in milliseconds)
23- const DEFAULT_SLEEP_MS : u64 = 100 ;
24-
2521/// A shorthand for the type of output handles we expect to write
2622pub type BoxedWriter = Box < dyn Write + Send > ;
2723
@@ -104,6 +100,10 @@ struct WriterState {
104100 /// Set to `true` once the owning [`ThreadWriter`] is dropped, signalling that
105101 /// no further data will be ingested.
106102 closed : bool ,
103+ /// Set if the worker thread terminates due to an I/O error. Producers parked
104+ /// in [`ThreadWriter::ingest`] observe this and return an error instead of
105+ /// blocking forever on a worker that will never notify again.
106+ error : Option < String > ,
107107}
108108
109109/// A thead-local writer that owns a subprocess handling the actual writing
@@ -120,6 +120,7 @@ impl ThreadWriter {
120120 Mutex :: new ( WriterState {
121121 buffer : Vec :: new ( ) ,
122122 closed : false ,
123+ error : None ,
123124 } ) ,
124125 Condvar :: new ( ) ,
125126 ) ) ;
@@ -145,11 +146,21 @@ impl ThreadWriter {
145146
146147 // We have data to process
147148 let data = std:: mem:: take ( & mut guard. buffer ) ;
149+ // The buffer is now empty; wake any producer parked in `ingest`
150+ // waiting for backpressure to ease before releasing the lock.
151+ cvar. notify_all ( ) ;
148152 drop ( guard) ; // Release lock before I/O
149153
150- // Perform actual write (potentially blocking I/O)
151- handle. write_all ( & data) ?;
152- handle. flush ( ) ?;
154+ // Perform actual write (potentially blocking I/O). On failure,
155+ // record the error and wake any parked producer so it observes
156+ // the failure instead of waiting forever for a notification that
157+ // will never come.
158+ if let Err ( e) = handle. write_all ( & data) . and_then ( |( ) | handle. flush ( ) ) {
159+ let mut guard = state. lock ( ) ;
160+ guard. error = Some ( e. to_string ( ) ) ;
161+ cvar. notify_all ( ) ;
162+ return Err ( e. into ( ) ) ;
163+ }
153164 }
154165 } ) ;
155166
@@ -159,22 +170,36 @@ impl ThreadWriter {
159170 }
160171 }
161172
162- fn ingest ( & self , data : & [ u8 ] ) {
173+ fn ingest ( & self , data : & [ u8 ] ) -> Result < ( ) > {
163174 let ( state, cvar) = & * self . state_pair ;
164- loop {
165- let mut guard = state. lock ( ) ;
166- if guard. buffer . len ( ) <= MAXIMUM_BUFFER_SIZE {
167- guard. buffer . extend_from_slice ( data) ;
168- cvar. notify_one ( ) ;
169- break ;
170- } else {
171- // Release the lock before sleeping so the worker thread can
172- // acquire it and drain the buffer while we wait for backpressure
173- // to ease.
174- drop ( guard) ;
175- thread:: sleep ( Duration :: from_millis ( DEFAULT_SLEEP_MS ) ) ;
175+ let mut guard = state. lock ( ) ;
176+
177+ // Apply backpressure based on the *projected* buffer size, not the
178+ // current size: appending `data` must not push the buffer past the cap.
179+ // We wait (releasing the lock) until the worker has drained enough for
180+ // the new data to fit. An empty buffer always accepts the data so that a
181+ // single write larger than the cap still makes forward progress instead
182+ // of deadlocking. The condition variable replaces the previous polling
183+ // sleep, so the producer resumes the instant the worker drains.
184+ while !guard. buffer . is_empty ( ) && guard. buffer . len ( ) + data. len ( ) > MAXIMUM_BUFFER_SIZE {
185+ // If the worker died while we were waiting for it to drain, stop
186+ // waiting: no further notifications will arrive.
187+ if let Some ( err) = & guard. error {
188+ bail ! ( "writer thread failed: {err}" ) ;
176189 }
190+ cvar. wait ( & mut guard) ;
191+ }
192+
193+ // The worker may have failed before we ever parked above (e.g. the
194+ // buffer had room). Surface that rather than silently buffering data
195+ // that will never be written.
196+ if let Some ( err) = & guard. error {
197+ bail ! ( "writer thread failed: {err}" ) ;
177198 }
199+
200+ guard. buffer . extend_from_slice ( data) ;
201+ cvar. notify_all ( ) ;
202+ Ok ( ( ) )
178203 }
179204}
180205
@@ -239,7 +264,7 @@ impl BufferedWriter {
239264 . zip ( self . segment_buffers . iter_mut ( ) )
240265 {
241266 if !buf. is_empty ( ) {
242- writer. ingest ( buf. drain ( ..) . as_slice ( ) ) ;
267+ writer. ingest ( buf. drain ( ..) . as_slice ( ) ) ? ;
243268 }
244269 }
245270 Ok ( ( ) )
@@ -325,6 +350,7 @@ mod tests {
325350 use super :: * ;
326351 use std:: io:: { self , Write } ;
327352 use std:: sync:: { Arc , Mutex } ;
353+ use std:: time:: Duration ;
328354
329355 // Simple in-memory writer that lets us inspect written data
330356 struct TestWriter {
@@ -387,14 +413,169 @@ mod tests {
387413 let payload = b"ACGTACGTACGT" ;
388414 {
389415 let writer = ThreadWriter :: new ( handle) ;
390- writer. ingest ( payload) ;
416+ writer. ingest ( payload) . unwrap ( ) ;
391417 // `writer` is dropped here; all ingested bytes must be flushed first.
392418 }
393419
394420 let written = data. lock ( ) . unwrap ( ) . clone ( ) ;
395421 assert_eq ! ( written, payload) ;
396422 }
397423
424+ // Backpressure tests (finding #3: ingest must enforce a *projected* cap and
425+ // must not poll-sleep while holding the buffer mutex).
426+
427+ /// A writer that signals when it has entered `write` and then blocks there
428+ /// until the test releases it. This lets us park the worker thread so the
429+ /// shared buffer accumulates and backpressure is forced to engage.
430+ struct GateState {
431+ entered : bool ,
432+ released : bool ,
433+ }
434+ struct GatedWriter {
435+ gate : Arc < ( Mutex < GateState > , std:: sync:: Condvar ) > ,
436+ }
437+ impl Write for GatedWriter {
438+ fn write ( & mut self , buf : & [ u8 ] ) -> io:: Result < usize > {
439+ let ( lock, cv) = & * self . gate ;
440+ let mut g = lock. lock ( ) . unwrap ( ) ;
441+ g. entered = true ;
442+ cv. notify_all ( ) ;
443+ while !g. released {
444+ g = cv. wait ( g) . unwrap ( ) ;
445+ }
446+ Ok ( buf. len ( ) )
447+ }
448+ fn flush ( & mut self ) -> io:: Result < ( ) > {
449+ Ok ( ( ) )
450+ }
451+ }
452+
453+ /// Reproduces finding #3: with the worker parked mid-write, a small chunk
454+ /// makes the buffer non-empty and a subsequent large chunk must NOT be
455+ /// appended, because the *projected* size would exceed the cap. The buggy
456+ /// `len() <= MAX` check appends it anyway, overshooting the cap by a full
457+ /// large chunk.
458+ #[ test]
459+ fn ingest_enforces_projected_buffer_cap ( ) {
460+ let gate = Arc :: new ( (
461+ Mutex :: new ( GateState {
462+ entered : false ,
463+ released : false ,
464+ } ) ,
465+ std:: sync:: Condvar :: new ( ) ,
466+ ) ) ;
467+ let writer: BoxedWriter = Box :: new ( GatedWriter { gate : gate. clone ( ) } ) ;
468+ let tw = ThreadWriter :: new ( writer) ;
469+
470+ // Make the worker pick up data and park inside the writer; the shared
471+ // buffer is empty again once it does.
472+ tw. ingest ( b"kick" ) . unwrap ( ) ;
473+ {
474+ let ( lock, cv) = & * gate;
475+ let mut g = lock. lock ( ) . unwrap ( ) ;
476+ while !g. entered {
477+ g = cv. wait ( g) . unwrap ( ) ;
478+ }
479+ }
480+
481+ thread:: scope ( |s| {
482+ // Producer: a small chunk (buffer now non-empty), then a large chunk.
483+ // A correct implementation blocks on the large chunk instead of
484+ // overshooting the cap.
485+ s. spawn ( || {
486+ tw. ingest ( & [ 1u8 ; 4096 ] ) . unwrap ( ) ;
487+ tw. ingest ( & vec ! [ 2u8 ; MAXIMUM_BUFFER_SIZE ] ) . unwrap ( ) ;
488+ } ) ;
489+
490+ // Let the producer attempt both ingests.
491+ thread:: sleep ( Duration :: from_millis ( 250 ) ) ;
492+
493+ let buffered = {
494+ let ( state, _) = & * tw. state_pair ;
495+ state. lock ( ) . buffer . len ( )
496+ } ;
497+
498+ // Release the worker so the producer can finish and the scope joins
499+ // cleanly regardless of the assertion outcome.
500+ {
501+ let ( lock, cv) = & * gate;
502+ lock. lock ( ) . unwrap ( ) . released = true ;
503+ cv. notify_all ( ) ;
504+ }
505+
506+ assert ! (
507+ buffered <= MAXIMUM_BUFFER_SIZE ,
508+ "ingest overshot the cap: buffered {buffered} bytes, cap {MAXIMUM_BUFFER_SIZE}"
509+ ) ;
510+ } ) ;
511+
512+ drop ( tw) ;
513+ }
514+
515+ /// Reproduces the latency half of finding #3: once the buffer drains, a
516+ /// blocked `ingest` must resume promptly via notification rather than after
517+ /// a fixed polling interval. We assert a blocked producer wakes well within
518+ /// the old 100ms poll period after space frees up.
519+ #[ test]
520+ fn ingest_wakes_promptly_when_space_frees ( ) {
521+ let gate = Arc :: new ( (
522+ Mutex :: new ( GateState {
523+ entered : false ,
524+ released : false ,
525+ } ) ,
526+ std:: sync:: Condvar :: new ( ) ,
527+ ) ) ;
528+ let writer: BoxedWriter = Box :: new ( GatedWriter { gate : gate. clone ( ) } ) ;
529+ let tw = ThreadWriter :: new ( writer) ;
530+
531+ // Park the worker mid-write so the buffer accumulates.
532+ tw. ingest ( b"kick" ) . unwrap ( ) ;
533+ {
534+ let ( lock, cv) = & * gate;
535+ let mut g = lock. lock ( ) . unwrap ( ) ;
536+ while !g. entered {
537+ g = cv. wait ( g) . unwrap ( ) ;
538+ }
539+ }
540+
541+ thread:: scope ( |s| {
542+ // Fill to the cap, then block on one more byte.
543+ s. spawn ( || {
544+ tw. ingest ( & vec ! [ 3u8 ; MAXIMUM_BUFFER_SIZE ] ) . unwrap ( ) ;
545+ tw. ingest ( b"z" ) . unwrap ( ) ;
546+ } ) ;
547+
548+ // Ensure the producer is blocked on the second ingest.
549+ thread:: sleep ( Duration :: from_millis ( 100 ) ) ;
550+
551+ // Release the worker; it drains the cap-sized chunk, which should
552+ // immediately notify the blocked producer.
553+ {
554+ let ( lock, cv) = & * gate;
555+ lock. lock ( ) . unwrap ( ) . released = true ;
556+ cv. notify_all ( ) ;
557+ }
558+
559+ // The producer's final byte should be buffered/drained quickly. Poll
560+ // until the worker has been handed the trailing byte (buffer empties
561+ // again) or a generous deadline elapses.
562+ let mut woke = false ;
563+ for _ in 0 ..50 {
564+ thread:: sleep ( Duration :: from_millis ( 2 ) ) ;
565+ let ( state, _) = & * tw. state_pair ;
566+ // Once the producer appended "z" and the worker drained it, the
567+ // buffer is empty again.
568+ if state. lock ( ) . buffer . is_empty ( ) {
569+ woke = true ;
570+ break ;
571+ }
572+ }
573+ assert ! ( woke, "blocked ingest did not resume promptly after drain" ) ;
574+ } ) ;
575+
576+ drop ( tw) ;
577+ }
578+
398579 #[ test]
399580 fn thread_writer_drains_pending_data_under_shutdown_race ( ) {
400581 // The data-loss bug was race-sensitive: ingest immediately before drop,
@@ -406,11 +587,46 @@ mod tests {
406587
407588 {
408589 let writer = ThreadWriter :: new ( handle) ;
409- writer. ingest ( payload) ;
590+ writer. ingest ( payload) . unwrap ( ) ;
410591 }
411592
412593 let written = data. lock ( ) . unwrap ( ) . clone ( ) ;
413594 assert_eq ! ( written, payload) ;
414595 }
415596 }
597+
598+ /// Reproduces the Gemini review concern: if the worker thread dies on an I/O
599+ /// error, a later `ingest` must surface that error instead of blocking
600+ /// forever waiting for a notification that will never arrive.
601+ #[ test]
602+ fn ingest_reports_worker_io_error_instead_of_hanging ( ) {
603+ struct FailingWriter ;
604+ impl Write for FailingWriter {
605+ fn write ( & mut self , _: & [ u8 ] ) -> io:: Result < usize > {
606+ Err ( io:: Error :: new ( io:: ErrorKind :: BrokenPipe , "downstream gone" ) )
607+ }
608+ fn flush ( & mut self ) -> io:: Result < ( ) > {
609+ Ok ( ( ) )
610+ }
611+ }
612+
613+ let tw = ThreadWriter :: new ( Box :: new ( FailingWriter ) ) ;
614+
615+ // The first ingest may win the race and buffer before the worker fails;
616+ // a bounded retry guarantees the worker has died, after which ingest must
617+ // report the error rather than hang.
618+ let mut reported = false ;
619+ for _ in 0 ..1000 {
620+ if tw. ingest ( b"data" ) . is_err ( ) {
621+ reported = true ;
622+ break ;
623+ }
624+ thread:: sleep ( Duration :: from_millis ( 1 ) ) ;
625+ }
626+ assert ! ( reported, "ingest never surfaced the worker I/O error" ) ;
627+
628+ // The worker already exited with an error; `Drop` would join and panic on
629+ // that result (tracked separately as report finding #16), so skip it here.
630+ std:: mem:: forget ( tw) ;
631+ }
416632}
0 commit comments