Skip to content

Commit dff9662

Browse files
committed
fix: let an interrupted follow read stop instead of parking for the next frame
1 parent dbb42c5 commit dff9662

5 files changed

Lines changed: 136 additions & 5 deletions

File tree

‎src/nu/commands/cat_command.rs‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,13 +170,16 @@ impl Command for CatCommand {
170170
// shared runtime; the consumer dropping the ListStream cancels it
171171
// (the L1 fd-leak fix). Driven off the runtime in real use.
172172
let mut rx = self.store.read(options);
173+
let rt = self.store.runtime();
174+
let signals = engine_state.signals().clone();
175+
let waiting = signals.clone();
173176
let stream = ListStream::new(
174177
std::iter::from_fn(move || {
175-
let frame = rx.blocking_recv()?; // parks off-runtime; None when producer done/cancelled
178+
let frame = crate::nu::util::follow_recv(&mut rx, rt.as_ref(), &waiting)?;
176179
Some(to_value(&frame))
177180
}),
178181
span,
179-
Signals::empty(),
182+
signals,
180183
);
181184
return Ok(PipelineData::ListStream(stream, None));
182185
}

‎src/nu/commands/last_command.rs‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use nu_engine::CallExt;
22
use nu_protocol::engine::{Call, Command, EngineState, Stack};
33
use nu_protocol::{
4-
Category, ListStream, PipelineData, ShellError, Signals, Signature, SyntaxShape, Type, Value,
4+
Category, ListStream, PipelineData, ShellError, Signature, SyntaxShape, Type, Value,
55
};
66

77
use crate::nu::util;
@@ -90,13 +90,16 @@ impl Command for LastCommand {
9090
.build();
9191

9292
let mut rx = self.store.read(options);
93+
let rt = self.store.runtime();
94+
let signals = engine_state.signals().clone();
95+
let waiting = signals.clone();
9396
let stream = ListStream::new(
9497
std::iter::from_fn(move || {
95-
let frame = rx.blocking_recv()?; // parks off-runtime; None when producer done/cancelled
98+
let frame = util::follow_recv(&mut rx, rt.as_ref(), &waiting)?;
9699
Some(util::frame_to_value(&frame, span, with_timestamp))
97100
}),
98101
span,
99-
Signals::empty(),
102+
signals,
100103
);
101104

102105
return Ok(PipelineData::ListStream(stream, None));

‎src/nu/test_commands.rs‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -763,6 +763,88 @@ mod tests {
763763
// socket. That runtime is only dropped when the block_on future returns,
764764
// which requires the forwarding loop to exit.
765765
//
766+
// A following read has to notice interruption. Its iterator parks
767+
// waiting for the next frame on the topic, and on an idle topic that wait
768+
// has no end. http-nu gives each request its own `Signals` and triggers
769+
// them when the client disconnects, so without this the thread and the
770+
// store's follow task stay parked until some unrelated append happens to
771+
// land on that topic.
772+
#[test]
773+
fn test_cat_follow_stops_when_interrupted() {
774+
use crate::nu::commands;
775+
use nu_protocol::Signals;
776+
use std::sync::atomic::{AtomicBool, Ordering};
777+
use std::sync::Arc;
778+
use std::time::Duration;
779+
780+
let (store, mut engine) = setup_test_env();
781+
engine
782+
.add_commands(vec![Box::new(commands::cat_command::CatCommand::new(
783+
store.clone(),
784+
))])
785+
.unwrap();
786+
787+
let flag = Arc::new(AtomicBool::new(false));
788+
engine.state.set_signals(Signals::new(flag.clone()));
789+
790+
// Nothing is ever appended to this topic, so the only way out of the
791+
// follow is the signal.
792+
let (done_tx, done_rx) = std::sync::mpsc::channel();
793+
let engine_clone = engine.clone();
794+
std::thread::spawn(move || {
795+
let out = engine_clone.eval(PipelineData::empty(), ".cat -f -T idle".to_string());
796+
let drained = out.map(|d| d.into_iter().count());
797+
let _ = done_tx.send(drained.is_ok());
798+
});
799+
800+
// Let it reach the park before interrupting.
801+
std::thread::sleep(Duration::from_millis(200));
802+
flag.store(true, Ordering::SeqCst);
803+
804+
let finished = done_rx.recv_timeout(Duration::from_secs(5));
805+
assert!(
806+
finished.is_ok(),
807+
"a following read ignored its interrupt and stayed parked",
808+
);
809+
}
810+
811+
// `.last -f` has the same follow branch and the same wait.
812+
#[test]
813+
fn test_last_follow_stops_when_interrupted() {
814+
use crate::nu::commands;
815+
use nu_protocol::Signals;
816+
use std::sync::atomic::{AtomicBool, Ordering};
817+
use std::sync::Arc;
818+
use std::time::Duration;
819+
820+
let (store, mut engine) = setup_test_env();
821+
engine
822+
.add_commands(vec![Box::new(commands::last_command::LastCommand::new(
823+
store.clone(),
824+
))])
825+
.unwrap();
826+
827+
let flag = Arc::new(AtomicBool::new(false));
828+
engine.state.set_signals(Signals::new(flag.clone()));
829+
830+
let (done_tx, done_rx) = std::sync::mpsc::channel();
831+
let engine_clone = engine.clone();
832+
std::thread::spawn(move || {
833+
let out = engine_clone.eval(PipelineData::empty(), ".last idle -f".to_string());
834+
// Assert on the count, not just that the thread ended: a bad
835+
// invocation would error out at once and pass vacuously.
836+
let _ = done_tx.send(out.map(|d| d.into_iter().count()));
837+
});
838+
839+
std::thread::sleep(Duration::from_millis(200));
840+
flag.store(true, Ordering::SeqCst);
841+
842+
let finished = done_rx
843+
.recv_timeout(Duration::from_secs(5))
844+
.expect("a following read ignored its interrupt and stayed parked");
845+
assert!(finished.is_ok(), ".last -f failed to run: {finished:?}");
846+
}
847+
766848
// In `--follow` mode the store's read() future parks on the broadcast
767849
// receiver waiting for the next append. When the ListStream consumer is
768850
// dropped (client disconnects, pipeline ends early), the spawned thread is

‎src/nu/util.rs‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,3 +271,38 @@ pub fn write_pipeline_to_cas(
271271
PipelineData::Empty => Ok(None),
272272
}
273273
}
274+
275+
/// How long a following read waits before it checks whether it was
276+
/// interrupted. The wait ends the moment a frame arrives, so this bounds only
277+
/// how late an interrupt is noticed, not how late a frame is delivered.
278+
const FOLLOW_INTERRUPT_CHECK: std::time::Duration = std::time::Duration::from_millis(100);
279+
280+
/// The next frame of a following read, or `None` when the producer is done or
281+
/// the read was interrupted.
282+
///
283+
/// A follow parks until the next frame lands on its topic, and on a quiet
284+
/// topic that never happens. An unbounded wait therefore ignores interruption
285+
/// completely: the thread, and the store's follow task behind it, stay parked
286+
/// until some unrelated append wakes them. Waiting in slices fixes that and
287+
/// costs nothing, because the timeout returns as soon as a frame arrives.
288+
///
289+
/// `rt` is the store's runtime. Without one there is nothing to drive the
290+
/// timeout on, so the wait falls back to blocking and stays uninterruptible.
291+
pub fn follow_recv(
292+
rx: &mut tokio::sync::mpsc::Receiver<crate::store::Frame>,
293+
rt: Option<&tokio::runtime::Handle>,
294+
signals: &nu_protocol::Signals,
295+
) -> Option<crate::store::Frame> {
296+
let Some(rt) = rt else {
297+
return rx.blocking_recv();
298+
};
299+
loop {
300+
if signals.interrupted() {
301+
return None;
302+
}
303+
match rt.block_on(async { tokio::time::timeout(FOLLOW_INTERRUPT_CHECK, rx.recv()).await }) {
304+
Ok(frame) => return frame,
305+
Err(_elapsed) => continue,
306+
}
307+
}
308+
}

‎src/store/mod.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -994,6 +994,14 @@ impl Store {
994994
]
995995
}
996996

997+
/// The runtime this store's tasks run on, if it was opened inside one.
998+
///
999+
/// A following read needs it to wait on its receiver with a timeout, so
1000+
/// it can notice an interrupt instead of parking until the next frame.
1001+
pub fn runtime(&self) -> Option<tokio::runtime::Handle> {
1002+
self.rt.clone()
1003+
}
1004+
9971005
/// The [`Fsync`] policy this store was opened with.
9981006
pub fn fsync(&self) -> Fsync {
9991007
self.fsync.mode

0 commit comments

Comments
 (0)