refactor(perception): share bounded streaming scheduler
This commit is contained in:
@@ -1,74 +1,5 @@
|
||||
"""One producer, one graph, bounded pending queue and active payload accounting."""
|
||||
"""Compatibility import; the bounded mailbox belongs to the shared runtime."""
|
||||
|
||||
from collections import deque
|
||||
from threading import Condition
|
||||
from k1link.perception.streaming_queue import StreamMailbox as Mailbox
|
||||
|
||||
|
||||
class Mailbox:
|
||||
def __init__(self, capacity=2, byte_limit=16 * 1024 * 1024):
|
||||
self.capacity = capacity
|
||||
self.byte_limit = byte_limit
|
||||
self.condition = Condition()
|
||||
self.pending = deque()
|
||||
self.bytes = 0
|
||||
self.peak_bytes = 0
|
||||
self.peak_pending = 0
|
||||
self.external_pending = 0
|
||||
self.dropped = []
|
||||
self.done = False
|
||||
self.error = None
|
||||
|
||||
def put(self, bundle):
|
||||
size = bundle["payload_bytes"]
|
||||
with self.condition:
|
||||
while self.pending and (
|
||||
len(self.pending) + self.external_pending >= self.capacity
|
||||
or self.bytes + size > self.byte_limit
|
||||
):
|
||||
old = self.pending.popleft()
|
||||
self.bytes -= old["payload_bytes"]
|
||||
self.dropped.append({"sequence": old["sequence"], "reason": "pending-overflow"})
|
||||
if len(self.pending) + self.external_pending >= self.capacity:
|
||||
self.dropped.append({"sequence": bundle["sequence"], "reason": "pending-overflow"})
|
||||
return
|
||||
if self.bytes + size > self.byte_limit:
|
||||
self.dropped.append({"sequence": bundle["sequence"], "reason": "byte-budget"})
|
||||
return
|
||||
self.pending.append(bundle)
|
||||
self.bytes += size
|
||||
self.peak_bytes = max(self.peak_bytes, self.bytes)
|
||||
self.peak_pending = max(self.peak_pending, len(self.pending) + self.external_pending)
|
||||
self.condition.notify_all()
|
||||
|
||||
def reserve_completed(self):
|
||||
"""A finished GPU result shares the SAME pending budget as ingress."""
|
||||
with self.condition:
|
||||
if self.external_pending:
|
||||
raise ValueError("only one completed GPU slot is permitted")
|
||||
while len(self.pending) + 1 > self.capacity:
|
||||
old = self.pending.popleft()
|
||||
self.bytes -= old["payload_bytes"]
|
||||
self.dropped.append({"sequence": old["sequence"], "reason": "handoff-overflow"})
|
||||
self.external_pending = 1
|
||||
self.peak_pending = max(self.peak_pending, len(self.pending) + 1)
|
||||
|
||||
def take_completed(self):
|
||||
with self.condition:
|
||||
if self.external_pending != 1:
|
||||
raise ValueError("completed GPU slot accounting mismatch")
|
||||
self.external_pending = 0
|
||||
|
||||
def take(self):
|
||||
with self.condition:
|
||||
self.condition.wait_for(lambda: self.pending or self.done)
|
||||
return self.pending.popleft() if self.pending else None
|
||||
|
||||
def release(self, bundle):
|
||||
with self.condition:
|
||||
self.bytes -= bundle["payload_bytes"]
|
||||
|
||||
def finish(self, error=None):
|
||||
with self.condition:
|
||||
self.done = True
|
||||
self.error = error
|
||||
self.condition.notify_all()
|
||||
__all__ = ["Mailbox"]
|
||||
|
||||
@@ -1,91 +1,5 @@
|
||||
"""One serial GPU stage can overlap one chronological CPU fusion stage.
|
||||
"""Compatibility import; no separate per-LAB GPU scheduling implementation."""
|
||||
|
||||
No GPU model concurrency. Ingress and completed-GPU results share two pending
|
||||
slots. Active payloads remain in the shared mailbox byte budget.
|
||||
"""
|
||||
from k1link.perception.streaming_scheduler import SerialGpuStage as GpuStage
|
||||
|
||||
import threading
|
||||
import traceback
|
||||
|
||||
|
||||
class GpuStage:
|
||||
def __init__(self, mailbox, compute, stop):
|
||||
self.mailbox = mailbox
|
||||
self.compute = compute
|
||||
self.stop = stop
|
||||
self.output_condition = threading.Condition()
|
||||
self.completed = None
|
||||
self.completed_reserved = False
|
||||
self.consumer_waiting = False
|
||||
# Reserve the output slot BEFORE starting another GPU call. Otherwise a
|
||||
# blocked put would hide a third pending frame outside the two queues.
|
||||
self.output_slot = threading.Semaphore(1)
|
||||
self.finished = threading.Event()
|
||||
self.error = None
|
||||
self.peak_pending = 0
|
||||
self.direct_handoffs = 0
|
||||
self.buffered_handoffs = 0
|
||||
self.thread = threading.Thread(target=self._run, daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
def _run(self):
|
||||
try:
|
||||
while not self.stop.is_set():
|
||||
if not self.output_slot.acquire(timeout=0.05):
|
||||
continue
|
||||
bundle = self.mailbox.take()
|
||||
if bundle is None:
|
||||
break
|
||||
result = self.compute(bundle)
|
||||
with self.output_condition:
|
||||
if self.completed is not None:
|
||||
raise ValueError("completed GPU slot is already occupied")
|
||||
# A receiver already blocked in take() accepts the result as
|
||||
# a synchronous rendezvous. It never becomes pending and
|
||||
# therefore must not evict a newer ingress frame merely for
|
||||
# the few instructions between publish and receive.
|
||||
reserved = not self.consumer_waiting
|
||||
if reserved:
|
||||
self.mailbox.reserve_completed()
|
||||
self.buffered_handoffs += 1
|
||||
else:
|
||||
self.direct_handoffs += 1
|
||||
self.completed = (bundle, result)
|
||||
self.completed_reserved = reserved
|
||||
self.peak_pending = max(self.peak_pending, int(reserved))
|
||||
self.output_condition.notify_all()
|
||||
except Exception:
|
||||
self.error = traceback.format_exc()
|
||||
finally:
|
||||
self.finished.set()
|
||||
with self.output_condition:
|
||||
self.output_condition.notify_all()
|
||||
|
||||
def take(self):
|
||||
while True:
|
||||
with self.output_condition:
|
||||
if self.completed is not None:
|
||||
result = self.completed
|
||||
reserved = self.completed_reserved
|
||||
self.completed = None
|
||||
self.completed_reserved = False
|
||||
self.consumer_waiting = False
|
||||
elif self.finished.is_set():
|
||||
self.consumer_waiting = False
|
||||
if self.error:
|
||||
raise RuntimeError(self.error) from None
|
||||
return None
|
||||
else:
|
||||
self.consumer_waiting = True
|
||||
self.output_condition.wait(timeout=0.05)
|
||||
continue
|
||||
if reserved:
|
||||
self.mailbox.take_completed()
|
||||
self.output_slot.release()
|
||||
return result
|
||||
|
||||
def close(self):
|
||||
self.stop.set()
|
||||
self.mailbox.finish(self.mailbox.error)
|
||||
self.thread.join(timeout=2)
|
||||
return not self.thread.is_alive()
|
||||
__all__ = ["GpuStage"]
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Bounded stream ingress shared by serial GPU and chronological CPU stages.
|
||||
|
||||
No source duration, recording path, model, lease acquisition, or output history.
|
||||
Active inputs remain byte-accounted until their owner releases them. A completed
|
||||
GPU result shares the pending budget with ingress; it is not a third queue.
|
||||
These are scheduler primitives, not a replacement for graph terminal outcomes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter, deque
|
||||
from collections.abc import Mapping
|
||||
from threading import Condition
|
||||
from typing import Any
|
||||
|
||||
StreamBundle = Mapping[str, Any]
|
||||
DISCARD_REASONS = frozenset(("cancelled", "gpu-failed", "processing-failed"))
|
||||
|
||||
|
||||
class StreamMailbox:
|
||||
def __init__(self, capacity: int = 2, byte_limit: int = 16 * 1024 * 1024) -> None:
|
||||
if type(capacity) is not int or not 1 <= capacity <= 2:
|
||||
raise ValueError("pending capacity must be 1..2")
|
||||
if type(byte_limit) is not int or not 1 <= byte_limit <= 16 * 1024 * 1024:
|
||||
raise ValueError("inflight byte budget must be 1..16 MiB")
|
||||
self.capacity, self.byte_limit = capacity, byte_limit
|
||||
self.condition = Condition()
|
||||
self.pending: deque[StreamBundle] = deque()
|
||||
self.bytes = self.peak_bytes = self.peak_pending = self.external_pending = 0
|
||||
self.drop_counts: Counter[str] = Counter()
|
||||
self._recent_drops: deque[dict[str, Any]] = deque(maxlen=256)
|
||||
self._owned: dict[int, tuple[StreamBundle, int]] = {}
|
||||
self._active: set[int] = set()
|
||||
self._last_sequence = -1
|
||||
self.done = False
|
||||
self.error: str | None = None
|
||||
|
||||
@property
|
||||
def dropped(self) -> list[dict[str, Any]]:
|
||||
"""Bounded diagnostic tail, NOT an all-session terminal journal."""
|
||||
with self.condition:
|
||||
return list(self._recent_drops)
|
||||
|
||||
@property
|
||||
def dropped_count(self) -> int:
|
||||
with self.condition:
|
||||
return sum(self.drop_counts.values())
|
||||
|
||||
@property
|
||||
def quiescent(self) -> bool:
|
||||
with self.condition:
|
||||
return self.done and not self._owned and not self.external_pending
|
||||
|
||||
def _drop(self, bundle: StreamBundle, reason: str) -> None:
|
||||
self.drop_counts[reason] += 1
|
||||
self._recent_drops.append({"sequence": bundle["sequence"], "reason": reason})
|
||||
|
||||
def _discard_pending(self, reason: str) -> None:
|
||||
old = self.pending.popleft()
|
||||
_, size = self._owned.pop(id(old))
|
||||
self.bytes -= size
|
||||
self._drop(old, reason)
|
||||
|
||||
def put(self, bundle: StreamBundle) -> bool:
|
||||
size, sequence = bundle["payload_bytes"], bundle["sequence"]
|
||||
if type(size) is not int or size <= 0 or type(sequence) is not int or sequence < 0:
|
||||
raise ValueError("invalid source sequence or payload size")
|
||||
with self.condition:
|
||||
if id(bundle) in self._owned:
|
||||
raise ValueError("input is already owned")
|
||||
if sequence <= self._last_sequence:
|
||||
raise ValueError("stream sequence must strictly increase")
|
||||
self._last_sequence = sequence
|
||||
if self.done or size > self.byte_limit:
|
||||
self._drop(bundle, "ingress-closed" if self.done else "byte-budget")
|
||||
return False
|
||||
while self.pending and (
|
||||
len(self.pending) + self.external_pending >= self.capacity
|
||||
or self.bytes + size > self.byte_limit
|
||||
):
|
||||
self._discard_pending("pending-overflow")
|
||||
if len(self.pending) + self.external_pending >= self.capacity:
|
||||
self._drop(bundle, "pending-overflow")
|
||||
return False
|
||||
if self.bytes + size > self.byte_limit:
|
||||
self._drop(bundle, "byte-budget")
|
||||
return False
|
||||
self.pending.append(bundle)
|
||||
self._owned[id(bundle)] = (bundle, size)
|
||||
self.bytes += size
|
||||
self.peak_bytes = max(self.peak_bytes, self.bytes)
|
||||
self.peak_pending = max(self.peak_pending, len(self.pending) + self.external_pending)
|
||||
self.condition.notify_all()
|
||||
return True
|
||||
|
||||
def reserve_completed(self) -> None:
|
||||
with self.condition:
|
||||
if self.external_pending:
|
||||
raise ValueError("only one completed GPU slot is permitted")
|
||||
while len(self.pending) + 1 > self.capacity:
|
||||
self._discard_pending("handoff-overflow")
|
||||
self.external_pending = 1
|
||||
self.peak_pending = max(self.peak_pending, len(self.pending) + 1)
|
||||
|
||||
def take_completed(self) -> None:
|
||||
with self.condition:
|
||||
if self.external_pending != 1:
|
||||
raise ValueError("completed GPU slot accounting mismatch")
|
||||
self.external_pending = 0
|
||||
|
||||
def take(self) -> StreamBundle | None:
|
||||
with self.condition:
|
||||
self.condition.wait_for(lambda: self.pending or self.done)
|
||||
if not self.pending:
|
||||
return None
|
||||
bundle = self.pending.popleft()
|
||||
self._active.add(id(bundle))
|
||||
return bundle
|
||||
|
||||
def release(self, bundle: StreamBundle, *, discard_reason: str | None = None) -> None:
|
||||
if discard_reason is not None and discard_reason not in DISCARD_REASONS:
|
||||
raise ValueError("unknown discard reason")
|
||||
with self.condition:
|
||||
if id(bundle) not in self._active or self._owned[id(bundle)][0] is not bundle:
|
||||
raise ValueError("input release ownership mismatch")
|
||||
self._active.remove(id(bundle))
|
||||
_, size = self._owned.pop(id(bundle))
|
||||
self.bytes -= size
|
||||
if discard_reason is not None:
|
||||
self._drop(bundle, discard_reason)
|
||||
|
||||
def finish(self, error: str | None = None) -> None:
|
||||
"""Close ingress, allowing accepted pending work to drain."""
|
||||
with self.condition:
|
||||
self.done = True
|
||||
self.error = self.error or error
|
||||
self.condition.notify_all()
|
||||
|
||||
def cancel(self, reason: str = "cancelled") -> None:
|
||||
"""Discard pending work, but never free another stage's active input."""
|
||||
if reason not in DISCARD_REASONS:
|
||||
raise ValueError("unknown cancellation reason")
|
||||
with self.condition:
|
||||
self.done = True
|
||||
while self.pending:
|
||||
self._discard_pending(reason)
|
||||
self.condition.notify_all()
|
||||
@@ -0,0 +1,116 @@
|
||||
"""One serial GPU lane overlapped with one chronological CPU consumer.
|
||||
|
||||
The callback owns all GPU model calls for a frame (e.g. DDRNet then RF-DETR).
|
||||
This class does not acquire the Worker lease or prove external GPU exclusivity.
|
||||
Close is cooperative: False means an active callback still owns resources and
|
||||
the supervisor must not admit another profile. No thread is forcefully killed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import traceback
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from .streaming_queue import StreamBundle, StreamMailbox
|
||||
|
||||
|
||||
class SerialGpuStage:
|
||||
def __init__(
|
||||
self, mailbox: StreamMailbox, compute: Callable[[StreamBundle], Any], stop: threading.Event
|
||||
) -> None:
|
||||
self.mailbox, self.compute, self.stop = mailbox, compute, stop
|
||||
self.output_condition = threading.Condition()
|
||||
self.completed: tuple[StreamBundle, Any] | None = None
|
||||
self.completed_reserved = self.consumer_waiting = False
|
||||
self.output_slot = threading.Semaphore(1)
|
||||
self.finished = threading.Event()
|
||||
self.error: str | None = None
|
||||
self.peak_pending = self.direct_handoffs = self.buffered_handoffs = 0
|
||||
self.thread = threading.Thread(target=self._run, name="perception-serial-gpu", daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
def _run(self) -> None:
|
||||
active = None
|
||||
try:
|
||||
while not self.stop.is_set():
|
||||
# Reserve output BEFORE inference: no hidden third pending result.
|
||||
if not self.output_slot.acquire(timeout=0.05):
|
||||
continue
|
||||
active = self.mailbox.take()
|
||||
if active is None:
|
||||
break
|
||||
if self.stop.is_set():
|
||||
break
|
||||
result = self.compute(active)
|
||||
with self.output_condition:
|
||||
if self.stop.is_set():
|
||||
break
|
||||
if self.completed is not None:
|
||||
raise ValueError("completed GPU slot is already occupied")
|
||||
# A blocked consumer accepts a synchronous rendezvous, not
|
||||
# a pending slot that would spuriously evict newer ingress.
|
||||
reserved = not self.consumer_waiting
|
||||
if reserved:
|
||||
self.mailbox.reserve_completed()
|
||||
self.buffered_handoffs += 1
|
||||
else:
|
||||
self.direct_handoffs += 1
|
||||
self.completed = (active, result)
|
||||
active = None
|
||||
self.completed_reserved = reserved
|
||||
self.peak_pending = max(self.peak_pending, int(reserved))
|
||||
self.output_condition.notify_all()
|
||||
except Exception:
|
||||
self.error = traceback.format_exc()
|
||||
self.mailbox.finish(self.error)
|
||||
self.mailbox.cancel("gpu-failed")
|
||||
finally:
|
||||
if active is not None:
|
||||
self.mailbox.release(
|
||||
active, discard_reason="gpu-failed" if self.error else "cancelled"
|
||||
)
|
||||
self.finished.set()
|
||||
with self.output_condition:
|
||||
self.output_condition.notify_all()
|
||||
|
||||
def take(self) -> tuple[StreamBundle, Any] | None:
|
||||
while True:
|
||||
with self.output_condition:
|
||||
if self.stop.is_set():
|
||||
self.consumer_waiting = False
|
||||
return None
|
||||
if self.completed is not None:
|
||||
result = self.completed
|
||||
reserved = self.completed_reserved
|
||||
self.completed = None
|
||||
self.completed_reserved = self.consumer_waiting = False
|
||||
elif self.finished.is_set():
|
||||
self.consumer_waiting = False
|
||||
if self.error:
|
||||
raise RuntimeError(self.error) from None
|
||||
return None
|
||||
else:
|
||||
self.consumer_waiting = True
|
||||
self.output_condition.wait(timeout=0.05)
|
||||
continue
|
||||
if reserved:
|
||||
self.mailbox.take_completed()
|
||||
self.output_slot.release()
|
||||
return result
|
||||
|
||||
def close(self, *, timeout: float = 2.0) -> bool:
|
||||
if not 0 <= timeout <= 2:
|
||||
raise ValueError("stop timeout must be 0..2 seconds")
|
||||
self.stop.set()
|
||||
self.mailbox.cancel()
|
||||
self.thread.join(timeout=timeout)
|
||||
with self.output_condition:
|
||||
if self.completed is not None:
|
||||
if self.completed_reserved:
|
||||
self.mailbox.take_completed()
|
||||
self.mailbox.release(self.completed[0], discard_reason="cancelled")
|
||||
self.completed = None
|
||||
self.completed_reserved = False
|
||||
return not self.thread.is_alive()
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Small synthetic scheduler lifecycle tests; no models, GPU or source IO."""
|
||||
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.perception.streaming_queue import StreamMailbox
|
||||
from k1link.perception.streaming_scheduler import SerialGpuStage
|
||||
|
||||
|
||||
def bundle(sequence, size=10):
|
||||
return {"sequence": sequence, "payload_bytes": size}
|
||||
|
||||
|
||||
def test_finish_drains_but_cancel_drops_pending_without_freeing_active_owner():
|
||||
queue = StreamMailbox()
|
||||
first = bundle(0)
|
||||
queue.put(first)
|
||||
assert queue.take() is first
|
||||
queue.put(bundle(1))
|
||||
queue.finish()
|
||||
assert not queue.put(bundle(2))
|
||||
assert queue.bytes == 20 and not queue.quiescent
|
||||
queue.cancel()
|
||||
assert queue.take() is None and queue.bytes == 10
|
||||
assert queue.drop_counts == {"ingress-closed": 1, "cancelled": 1}
|
||||
queue.release(first)
|
||||
assert queue.quiescent
|
||||
with pytest.raises(ValueError, match="ownership"):
|
||||
queue.release(first)
|
||||
|
||||
|
||||
def test_oversized_message_does_not_evict_accepted_small_input():
|
||||
queue = StreamMailbox(byte_limit=100)
|
||||
first = bundle(0)
|
||||
queue.put(first)
|
||||
assert not queue.put(bundle(1, 101))
|
||||
assert queue.take() is first and queue.bytes == 10
|
||||
queue.release(first)
|
||||
|
||||
|
||||
def test_diagnostic_history_is_bounded_and_total_drops_remain_exact():
|
||||
queue = StreamMailbox()
|
||||
# Boundary-sized synthetic fixture, not a local sustained-load benchmark.
|
||||
for sequence in range(260):
|
||||
queue.put(bundle(sequence))
|
||||
assert queue.dropped_count == 258 and len(queue.dropped) == 256
|
||||
assert queue.peak_pending == 2 and queue.bytes == 20
|
||||
queue.cancel()
|
||||
assert queue.dropped_count == 260 and queue.quiescent
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sequence,size", [(True, 1), (-1, 1), (0, 0), (0, True)])
|
||||
def test_invalid_admission_is_rejected(sequence, size):
|
||||
queue = StreamMailbox()
|
||||
with pytest.raises(ValueError):
|
||||
queue.put(bundle(sequence, size))
|
||||
assert queue.bytes == 0
|
||||
|
||||
|
||||
def test_release_uses_admitted_size_and_does_not_allow_reusing_owned_input():
|
||||
queue = StreamMailbox()
|
||||
first = bundle(0)
|
||||
queue.put(first)
|
||||
assert queue.take() is first
|
||||
first.update(sequence=1, payload_bytes=1000)
|
||||
with pytest.raises(ValueError, match="owned"):
|
||||
queue.put(first)
|
||||
with pytest.raises(ValueError, match="ownership"):
|
||||
queue.release(dict(first))
|
||||
queue.release(first)
|
||||
assert queue.bytes == 0
|
||||
with pytest.raises(ValueError, match="increase"):
|
||||
queue.put(bundle(0))
|
||||
|
||||
|
||||
def test_failed_gpu_releases_its_input_and_discards_pending():
|
||||
started, proceed = threading.Event(), threading.Event()
|
||||
queue = StreamMailbox()
|
||||
|
||||
def compute(_bundle):
|
||||
started.set()
|
||||
assert proceed.wait(1)
|
||||
raise ValueError("synthetic failure")
|
||||
|
||||
stage = SerialGpuStage(queue, compute, threading.Event())
|
||||
try:
|
||||
queue.put(bundle(0))
|
||||
assert started.wait(1)
|
||||
queue.put(bundle(1))
|
||||
proceed.set()
|
||||
with pytest.raises(RuntimeError, match="synthetic failure"):
|
||||
stage.take()
|
||||
assert queue.quiescent and queue.bytes == 0
|
||||
assert queue.drop_counts == {"gpu-failed": 2}
|
||||
finally:
|
||||
proceed.set()
|
||||
assert stage.close()
|
||||
|
||||
|
||||
def test_timed_out_stop_retains_ownership_until_callback_actually_exits():
|
||||
started, proceed = threading.Event(), threading.Event()
|
||||
queue = StreamMailbox()
|
||||
|
||||
def compute(_bundle):
|
||||
started.set()
|
||||
assert proceed.wait(1)
|
||||
return "must not be published after cancellation"
|
||||
|
||||
stage = SerialGpuStage(queue, compute, threading.Event())
|
||||
try:
|
||||
queue.put(bundle(0))
|
||||
assert started.wait(1)
|
||||
assert not stage.close(timeout=0)
|
||||
assert queue.bytes == 10 and not queue.quiescent
|
||||
assert stage.take() is None
|
||||
proceed.set()
|
||||
assert stage.finished.wait(1)
|
||||
assert stage.close() and queue.quiescent
|
||||
assert queue.drop_counts == {"cancelled": 1}
|
||||
finally:
|
||||
proceed.set()
|
||||
assert stage.close()
|
||||
|
||||
|
||||
def test_stop_discards_completed_result_but_cannot_free_cpu_owned_input():
|
||||
queue = StreamMailbox()
|
||||
stage = SerialGpuStage(queue, lambda item: item["sequence"], threading.Event())
|
||||
try:
|
||||
queue.put(bundle(0))
|
||||
first, _ = stage.take()
|
||||
queue.put(bundle(1))
|
||||
with stage.output_condition:
|
||||
assert stage.output_condition.wait_for(lambda: stage.completed is not None, timeout=1)
|
||||
assert queue.bytes == 20
|
||||
assert stage.close()
|
||||
assert queue.bytes == 10 and not queue.quiescent
|
||||
queue.release(first)
|
||||
assert queue.quiescent
|
||||
finally:
|
||||
assert stage.close()
|
||||
Reference in New Issue
Block a user