Skip to content

Commit 3409f2f

Browse files
committed
_queue: check for pending signals during blocking get()
Semaphore::acquire() waited on the condvar in a single uninterrupted call, so a Python-level signal handler never ran until the wait itself returned. check_signals() is only invoked from the bytecode dispatch loop, and this native wait never passed through it -- CPython chunks its equivalent blocking wait for the same reason. Cap each wait at a short interval and call check_signals() between chunks, only giving up once the caller's real deadline has actually elapsed. The mutex guard must be dropped before check_signals() runs: a signal handler that calls back into the same queue on the same thread would otherwise try to relock a mutex it's still holding and deadlock against itself. Add a regression snippet that arms a SIGALRM well before a helper thread unblocks the queue, and asserts the handler ran near the alarm instead of only once get() returned. Refs #8250 Assisted-by: Claude
1 parent 9c064c1 commit 3409f2f

2 files changed

Lines changed: 97 additions & 21 deletions

File tree

crates/stdlib/src/_queue.rs

Lines changed: 45 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ mod _queue {
3131

3232
const INITIAL_RING_BUF_CAPACITY: usize = 8;
3333

34+
/// Wait chunk size so blocking waits stay responsive to signals, mirroring
35+
/// CPython's `PyThread_acquire_lock_timed`.
36+
#[cfg(feature = "threading")]
37+
const SIGNAL_CHECK_INTERVAL: Duration = Duration::from_millis(50);
38+
3439
#[pyattr]
3540
#[pyclass(module = "_queue", name = "Empty", base = PyException)]
3641
#[repr(transparent)]
@@ -72,31 +77,50 @@ mod _queue {
7277
self.cond.notify_one();
7378
}
7479

75-
/// Returns `true` if the semaphore was acquired, `false` on timeout.
76-
#[must_use]
77-
fn acquire(&self, block: bool, deadline: Option<Instant>, vm: &VirtualMachine) -> bool {
78-
let mut count = self.mutex.lock();
80+
/// `Ok(true)` if acquired, `Ok(false)` on timeout, `Err` if a signal
81+
/// handler raised (e.g. `KeyboardInterrupt`) while we were waiting.
82+
fn acquire(
83+
&self,
84+
block: bool,
85+
deadline: Option<Instant>,
86+
vm: &VirtualMachine,
87+
) -> PyResult<bool> {
7988
loop {
80-
if *count > 0 {
81-
*count -= 1;
82-
return true;
83-
}
89+
// Guard must be dropped before check_signals() below, since a
90+
// signal handler may call back into this same queue.
91+
{
92+
let mut count = self.mutex.lock();
93+
94+
if *count > 0 {
95+
*count -= 1;
96+
return Ok(true);
97+
}
8498

85-
if !block {
86-
return false;
87-
}
99+
if !block {
100+
return Ok(false);
101+
}
102+
103+
let now = Instant::now();
104+
let chunk_deadline = deadline.map_or_else(
105+
|| now + SIGNAL_CHECK_INTERVAL,
106+
|dl| dl.min(now + SIGNAL_CHECK_INTERVAL),
107+
);
88108

89-
match deadline {
90-
Some(dl) => {
91-
let result = vm.allow_threads(|| self.cond.wait_until(&mut count, dl));
92-
if result.timed_out() && *count == 0 {
93-
return false;
94-
}
109+
vm.allow_threads(|| self.cond.wait_until(&mut count, chunk_deadline));
110+
111+
if *count > 0 {
112+
*count -= 1;
113+
return Ok(true);
95114
}
96-
None => {
97-
vm.allow_threads(|| self.cond.wait(&mut count));
115+
116+
if let Some(dl) = deadline
117+
&& Instant::now() >= dl
118+
{
119+
return Ok(false);
98120
}
99121
}
122+
123+
vm.check_signals()?;
100124
}
101125
}
102126
}
@@ -227,7 +251,7 @@ mod _queue {
227251

228252
#[cfg(feature = "threading")]
229253
{
230-
if !self.sem.acquire(block, deadline, vm) {
254+
if !self.sem.acquire(block, deadline, vm)? {
231255
return Err(empty_error(vm));
232256
}
233257
}
@@ -239,7 +263,7 @@ mod _queue {
239263
fn get_nowait(&self, vm: &VirtualMachine) -> PyResult<PyObjectRef> {
240264
#[cfg(feature = "threading")]
241265
{
242-
if !self.sem.acquire(false, None, vm) {
266+
if !self.sem.acquire(false, None, vm)? {
243267
return Err(empty_error(vm));
244268
}
245269
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""A blocking queue.SimpleQueue.get() must stay responsive to signals.
2+
3+
SimpleQueue.get() waits on a Condvar. A single uninterrupted wait blocks
4+
Python-level signal handlers (including the default KeyboardInterrupt) until
5+
the wait itself returns, since signals are only delivered at bytecode
6+
safepoints. A regression shows up as the handler firing only once get()
7+
unblocks, instead of promptly when the signal actually arrives.
8+
"""
9+
10+
import queue
11+
import signal
12+
import sys
13+
import threading
14+
import time
15+
16+
if sys.platform.startswith("win"):
17+
print("skipped (no SIGALRM)")
18+
raise SystemExit(0)
19+
20+
q = queue.SimpleQueue()
21+
start = time.time()
22+
handled_at = []
23+
24+
25+
def handler(signum, frame):
26+
handled_at.append(time.time() - start)
27+
28+
29+
signal.signal(signal.SIGALRM, handler)
30+
signal.setitimer(signal.ITIMER_REAL, 0.3)
31+
32+
33+
def unblock_later():
34+
time.sleep(1.5)
35+
q.put("unblock")
36+
37+
38+
threading.Thread(target=unblock_later, daemon=True).start()
39+
40+
item = q.get() # blocks until unblock_later() wakes us up
41+
elapsed = time.time() - start
42+
43+
assert item == "unblock", item
44+
assert handled_at, "signal handler never ran"
45+
# The handler should fire around t=0.3s, when the timer was started, not t=1.5s
46+
# (when get() finally unblocked).
47+
assert handled_at[0] < elapsed - 0.5, (
48+
f"signal handled at {handled_at[0]:.2f}s but get() only returned at "
49+
f"{elapsed:.2f}s -- signal was not processed while blocked"
50+
)
51+
52+
print("ok")

0 commit comments

Comments
 (0)