Skip to content

🔨 Remove potential cross-threads contention by eliminating Mutex on Batch Processor#55

Open
roma-glushko wants to merge 6 commits into
mainfrom
remove-mutex-from-event-queue
Open

🔨 Remove potential cross-threads contention by eliminating Mutex on Batch Processor#55
roma-glushko wants to merge 6 commits into
mainfrom
remove-mutex-from-event-queue

Conversation

@roma-glushko

@roma-glushko roma-glushko commented Mar 9, 2026

Copy link
Copy Markdown
Owner

Remove a potential cross-thread contention during event queuing by replacing Arc<Mutex<BatchProcessor>> with bounded mpsc::channel.

The mutex was contended across threads because:

  • the notify callback could block waiting for the drain task,
  • and vice versa.

If the mutex got poisoned (e.g. a panic in either side), events would be dropped.

mpsc::channel should help to submit events in an non-blocking way. If we can't queue events we will be dropping them.

@roma-glushko roma-glushko self-assigned this Mar 9, 2026
@roma-glushko roma-glushko added the enhancement New feature or request label Mar 9, 2026
@roma-glushko roma-glushko requested a review from Copilot March 9, 2026 16:46

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR refactors the Rust Watcher event ingestion path to avoid cross-thread contention by removing the shared Arc<Mutex<BatchProcessor>> and routing raw notify events through a Tokio mpsc channel to the drain task.

Changes:

  • Replace direct, mutex-protected BatchProcessor access in the notify callback with mpsc::unbounded_channel() sending.
  • Move BatchProcessor ownership into the drain task and drain the channel on each tick using try_recv.
  • Add buffering_duration to Watcher state to construct the drain-side BatchProcessor.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/watcher.rs Outdated
Comment on lines 49 to 62
let (event_tx, event_rx) = mpsc::unbounded_channel();

let (tx, _rx) = broadcast::channel::<Vec<EventType>>(event_buffer_size);

let config = notify::Config::default().with_follow_symlinks(follow_symlinks);

let inner = RecommendedWatcher::new(
move |e: Result<Event, notify::Error>| {
let mut event_processor = match processor_c.lock() {
Ok(guard) => guard,
Err(e) => {
eprintln!("notifykit: event processor lock poisoned, dropping event: {e}");
return;
}
};

if debug {
println!("raw event: {:?}", e);
}

match e {
Ok(e) => event_processor.add_event(e),
Err(e) => event_processor.add_error(e),
if let Err(e) = event_tx.send(e) {
eprintln!("failed to send event: {e}");
}

Copilot AI Mar 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using an unbounded mpsc channel here can lead to unbounded memory growth if filesystem events arrive faster than the drain loop can process them (e.g., slow consumer, large tick_duration, or drain temporarily stopped). Consider switching to a bounded channel (mpsc::channel) sized for expected bursts and using try_send / drop-with-metrics when full, or otherwise introducing backpressure/limits so the process can’t OOM under sustained event load.

Copilot uses AI. Check for mistakes.
Comment thread src/watcher.rs Outdated
Comment on lines +165 to +176
let mut processor = BatchProcessor::new(buffering_duration);
let mut ticker = time::interval(tick_duration);

loop {
tokio::select! {
_ = &mut stop_rx => break,
_ = ticker.tick() => {
let (raw, errs) = {
let mut p = match proc.lock() {
Ok(guard) => guard,
Err(e) => {
eprintln!("notifykit: event processor lock poisoned, skipping drain tick: {e}");
continue;
if let Ok(mut rx) = event_rx.lock() {
while let Ok(result) = rx.try_recv() {
match result {
Ok(event) => processor.add_event(event),
Err(error) => processor.add_error(error),

Copilot AI Mar 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Events are now timestamped when they’re drained (processor.add_event() uses Instant::now()) rather than when notify delivers them. That changes the effective debounce/buffering window by up to tick_duration (and can merge/split batches differently compared to the previous design). If the original timing semantics matter, consider sending an Instant alongside each event (captured in the notify callback) or restructuring the drain task to recv events continuously and only flush on ticker ticks.

Copilot uses AI. Check for mistakes.
Comment thread src/watcher.rs Outdated
Comment on lines +172 to +179
if let Ok(mut rx) = event_rx.lock() {
while let Ok(result) = rx.try_recv() {
match result {
Ok(event) => processor.add_event(event),
Err(error) => processor.add_error(error),
}
};
(p.get_events(), p.get_errors())
};
}
}

Copilot AI Mar 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The async drain task uses a std::sync::Mutex around the UnboundedReceiver. Even if contention is expected to be low, this is a blocking mutex inside the Tokio runtime and it can be poisoned (reintroducing the ‘poisoned mutex’ failure mode the PR description is trying to avoid). Consider replacing this with tokio::sync::Mutex (no poisoning) or refactoring so the receiver is moved into the drain task (no Arc/Mutex needed) and polled via recv/try_recv within the task.

Copilot uses AI. Check for mistakes.
@roma-glushko roma-glushko marked this pull request as draft March 9, 2026 16:56
@roma-glushko roma-glushko marked this pull request as ready for review March 9, 2026 19:08

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/watcher.rs Outdated
Comment on lines +154 to +156
if let Some(mut rx_return) = self.rx_return.take() {
if let Ok(rx) = rx_return.try_recv() {
self.event_rx = Some(rx);

Copilot AI Mar 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

recover_event_rx() drops rx_return if the drain task hasn’t returned the receiver yet (e.g. try_recv() returns Empty). Because self.rx_return is take()n unconditionally, you lose the only handle that could ever recover event_rx, and subsequent start_drain() calls will permanently have event_rx = None (and/or the mpsc channel becomes closed after abort). Consider (a) not take()ing the receiver unless it is ready (put it back on Empty), and (b) avoiding handle.abort() here or ensuring the receiver is returned even on abort (e.g. via a Drop guard), otherwise restart/stop can leave the watcher unable to drain events.

Suggested change
if let Some(mut rx_return) = self.rx_return.take() {
if let Ok(rx) = rx_return.try_recv() {
self.event_rx = Some(rx);
if let Some(rx_return) = &mut self.rx_return {
match rx_return.try_recv() {
Ok(rx) => {
// Successfully recovered the event receiver; we no longer need rx_return.
self.event_rx = Some(rx);
self.rx_return = None;
}
Err(tokio::sync::oneshot::error::TryRecvError::Empty) => {
// The drain task has not yet returned the receiver; keep rx_return so
// that a future call to recover_event_rx can try again.
}
Err(tokio::sync::oneshot::error::TryRecvError::Closed) => {
// The oneshot will never yield a receiver; drop it to avoid retrying.
self.rx_return = None;
}

Copilot uses AI. Check for mistakes.
Comment thread src/watcher.rs Outdated
Comment on lines +168 to +171
let mut event_rx = match self.event_rx.take() {
Some(rx) => rx,
None => return,
};

Copilot AI Mar 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

start_drain() silently returns when self.event_rx is None. With the current “move receiver into task and return it later” design, this can happen after a stop/restart attempt or if recover_event_rx() couldn’t immediately reclaim the receiver. Instead of returning, consider blocking until the receiver is available (or recreating the channel/sender pair) so callers don’t end up with a watcher that never emits events and only logs dropped events.

Copilot uses AI. Check for mistakes.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/watcher.rs
Comment on lines 234 to +240
_ = ticker.tick() => {
let (raw, errs) = {
let mut p = match proc.lock() {
Ok(guard) => guard,
Err(e) => {
eprintln!("notifykit: event processor lock poisoned, skipping drain tick: {e}");
continue;
}
};
(p.get_events(), p.get_errors())
};
while let Ok((time, result)) = event_rx.try_recv() {
match result {
Ok(event) => processor.add_event(event, time),
Err(error) => processor.add_error(error),
}
}

Copilot AI Mar 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The drain task only consumes from event_rx on each ticker.tick() via try_recv(). If tick_duration is large or events arrive in bursts, the bounded channel can fill up and try_send will start dropping events even though the drain task is idle between ticks. Consider also awaiting event_rx.recv() (and buffering timestamps) so the receiver keeps up continuously while still emitting batches on tick boundaries.

Copilot uses AI. Check for mistakes.
Comment thread src/watcher.rs
Comment on lines +227 to +229
let handle = runtime.spawn(async move {
let mut processor = BatchProcessor::new(buffering_duration);
let mut ticker = time::interval(tick_duration);

Copilot AI Mar 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BatchProcessor is now created inside the drain task. When stop_and_recover stops a running drain, any events already moved from event_rx into processor but not yet emitted will be dropped when the task exits. Previously buffered events survived drain restarts because the processor lived outside the task. If drain restarts are expected (e.g., multiple events() calls), consider persisting the processor across drain lifecycles or flushing pending buffered events back into the receiver/channel before exit.

Copilot uses AI. Check for mistakes.
Comment thread src/watcher.rs
Comment on lines +128 to +129
if let Err(e) = event_tx.try_send((Instant::now(), e)) {
eprintln!("event channel full or closed, dropping event: {e}");

Copilot AI Mar 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The log message says "dropping event" but {e} here is a TrySendError whose Display typically only reports the reason (full/closed), not the event being dropped. Consider logging the dropped payload (or using {:?} and matching Full/Closed) so debug output is actionable.

Suggested change
if let Err(e) = event_tx.try_send((Instant::now(), e)) {
eprintln!("event channel full or closed, dropping event: {e}");
if let Err(err) = event_tx.try_send((Instant::now(), e)) {
match err {
tokio::sync::mpsc::error::TrySendError::Full((_ts, payload)) => {
eprintln!("event channel full, dropping event: {:?}", payload);
}
tokio::sync::mpsc::error::TrySendError::Closed((_ts, payload)) => {
eprintln!("event channel closed, dropping event: {:?}", payload);
}
}

Copilot uses AI. Check for mistakes.
Comment thread src/watcher.rs
Comment on lines +54 to +69
fn recover_event_rx(&mut self, runtime: &tokio::runtime::Runtime) {
if let Some(handle) = self.drain_handle.take() {
// Wait for the task to finish so it can return the receiver.
// The caller must send the stop signal before calling this.
let _ = runtime.block_on(handle);
}

if let Some(mut rx_return) = self.rx_return.take() {
match rx_return.try_recv() {
Ok(rx) => self.event_rx = Some(rx),
Err(oneshot::error::TryRecvError::Empty) => {
// Task hasn't returned the receiver yet; keep the handle for later
self.rx_return = Some(rx_return);
}
Err(oneshot::error::TryRecvError::Closed) => {
// Task dropped the sender (e.g. panic); receiver is lost

Copilot AI Mar 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

recover_event_rx blocks the current thread with runtime.block_on(handle). In libraries this can be risky because calling Runtime::block_on from within an active Tokio runtime context will panic / can deadlock depending on where stop()/start_drain() is invoked. Consider making recovery non-blocking (e.g., abort + detach, or perform the join from a dedicated blocking thread) so stop()/start_drain() are safe to call regardless of caller context.

Suggested change
fn recover_event_rx(&mut self, runtime: &tokio::runtime::Runtime) {
if let Some(handle) = self.drain_handle.take() {
// Wait for the task to finish so it can return the receiver.
// The caller must send the stop signal before calling this.
let _ = runtime.block_on(handle);
}
if let Some(mut rx_return) = self.rx_return.take() {
match rx_return.try_recv() {
Ok(rx) => self.event_rx = Some(rx),
Err(oneshot::error::TryRecvError::Empty) => {
// Task hasn't returned the receiver yet; keep the handle for later
self.rx_return = Some(rx_return);
}
Err(oneshot::error::TryRecvError::Closed) => {
// Task dropped the sender (e.g. panic); receiver is lost
fn recover_event_rx(&mut self, _runtime: &tokio::runtime::Runtime) {
if let Some(_handle) = self.drain_handle.take() {
// Do not block on the task here; let it finish in the background.
// The task is responsible for sending back the receiver via `rx_return`.
}
if let Some(mut rx_return) = self.rx_return.take() {
match rx_return.try_recv() {
Ok(rx) => self.event_rx = Some(rx),
Err(oneshot::error::TryRecvError::Empty) => {
// Task hasn't returned the receiver yet; keep the receiver channel for later.
self.rx_return = Some(rx_return);
}
Err(oneshot::error::TryRecvError::Closed) => {
// Task dropped the sender (e.g. panic); receiver is lost.

Copilot uses AI. Check for mistakes.

@c-h-russell-walker c-h-russell-walker left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nice - PR looks good to me 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants