Skip to content

Commit 77bbfb9

Browse files
committed
fix: async shutdown root lifecycle and queue_timeout=0 dequeue
Run async cleanup on the asyncio.run root after _finished is set so protocol aclose() completes before Runner teardown. Preserve the first Queue.get attempt when SW_AGENT_QUEUE_TIMEOUT=0. Add regression tests.
1 parent d6c3761 commit 77bbfb9

4 files changed

Lines changed: 80 additions & 48 deletions

File tree

skywalking/agent/__init__.py

Lines changed: 15 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -151,9 +151,8 @@ async def _cancel_pending_tasks(tasks) -> None:
151151
"""
152152
Cancel agent-owned reporter / connectivity-watch tasks only.
153153
154-
Must not cancel the asyncio.run root (__start_event_loop_async): that tears down
155-
the Runner and cancels this fini coroutine before protocol aclose() runs.
156-
The current task (fini) is also excluded so we never await ourselves.
154+
Cancel only the given reporter / watch tasks; never the asyncio.run root.
155+
The current task is excluded so we never await ourselves.
157156
"""
158157
current = asyncio.current_task()
159158
pending = [
@@ -820,11 +819,12 @@ async def __start_event_loop_async(self) -> None:
820819

821820
await self.__bootstrap() # gather all coroutines
822821

823-
# Track Tasks explicitly so shutdown cancels only reporters/watchers, not the
824-
# asyncio.run root task that is awaiting this gather.
825822
self.background_tasks = {asyncio.create_task(coro) for coro in self.background_coroutines}
826823
logger.debug('All background coroutines started')
827-
await asyncio.gather(*self.background_tasks)
824+
# Wait for shutdown inside the asyncio.run root, then clean up here so the
825+
# Runner stays alive through protocol aclose() before asyncio.run returns.
826+
await self._finished.wait()
827+
await self.__async_shutdown_cleanup()
828828

829829
def __start_event_loop(self) -> None:
830830
try:
@@ -872,17 +872,13 @@ def start(self) -> None:
872872
self.event_loop_thread = Thread(name='event_loop_thread', target=self.__start_event_loop, daemon=True)
873873
self.event_loop_thread.start()
874874

875-
async def __fini_async(self):
875+
async def __async_shutdown_cleanup(self) -> None:
876876
"""
877-
This method is called when the agent is shutting down.
878-
Clean up all the queues and stop all the asyncio tasks.
877+
Async shutdown body that must run on the asyncio.run root task.
879878
880879
Do not await report_* here: aio generators use unbounded queue.get() and can
881880
hang forever. Abandon + timed join, then cancel tasks.
882881
"""
883-
if self._finished is not None:
884-
self._finished.set()
885-
886882
await _shutdown_async_queue(self.__segment_queue, 'segment')
887883

888884
if config.agent_log_reporter_active:
@@ -908,14 +904,13 @@ async def __fini_async(self):
908904
close()
909905

910906
def __fini(self):
911-
if not self.loop.is_closed():
912-
future = asyncio.run_coroutine_threadsafe(self.__fini_async(), self.loop)
913-
try:
914-
future.result(timeout=_SHUTDOWN_LOOP_JOIN_TIMEOUT_SEC + _SHUTDOWN_JOIN_TIMEOUT_SEC * 4)
915-
except Exception: # noqa: BLE001 - never block process exit on shutdown
916-
logger.exception('async agent shutdown exceeded budget or failed')
917-
future.cancel()
918-
self.event_loop_thread.join(timeout=_SHUTDOWN_LOOP_JOIN_TIMEOUT_SEC)
907+
loop = getattr(self, 'loop', None)
908+
if loop is not None and not loop.is_closed() and self._finished is not None:
909+
loop.call_soon_threadsafe(self._finished.set)
910+
if self.event_loop_thread is not None:
911+
self.event_loop_thread.join(
912+
timeout=_SHUTDOWN_LOOP_JOIN_TIMEOUT_SEC + _SHUTDOWN_JOIN_TIMEOUT_SEC * 4,
913+
)
919914
if self.event_loop_thread.is_alive():
920915
logger.warning(
921916
'Python agent event_loop thread still alive after %.1fs shutdown budget',

skywalking/agent/protocol/grpc.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,20 +45,24 @@
4545
from skywalking.trace.segment import Segment
4646

4747

48-
def _queue_get_within_batch(queue: Queue, block: bool, batch_deadline: float):
48+
def _queue_get_within_batch(queue: Queue, block: bool, batch_deadline: float, *, allow_immediate: bool = False):
4949
"""
5050
Get one item within an absolute batch window (monotonic deadline).
5151
5252
Avoids int(elapsed) truncation that could let queue waits approach
5353
agent_queue_timeout + 1s and collide with a tight RPC deadline.
54+
When allow_immediate is True (first generator iteration), still attempt
55+
Queue.get once so SW_AGENT_QUEUE_TIMEOUT=0 can drain an immediately
56+
available item via get(timeout=0).
5457
Returns None when the window is exhausted or the queue is empty.
5558
"""
5659
remaining = batch_deadline - monotonic()
57-
if remaining <= 0:
60+
if remaining <= 0 and not allow_immediate:
5861
return None
5962
try:
6063
if block:
61-
return queue.get(block=True, timeout=remaining)
64+
timeout = remaining if remaining > 0 else 0
65+
return queue.get(block=True, timeout=timeout)
6266
return queue.get(block=False)
6367
except Empty:
6468
return None
@@ -172,8 +176,10 @@ def generator():
172176
nonlocal sent
173177

174178
batch_deadline = monotonic() + float(config.agent_queue_timeout)
179+
first_get = True
175180
while True:
176-
segment = _queue_get_within_batch(queue, block, batch_deadline) # type: Segment
181+
segment = _queue_get_within_batch(queue, block, batch_deadline, allow_immediate=first_get) # type: Segment
182+
first_get = False
177183
if segment is None:
178184
return
179185

@@ -240,8 +246,10 @@ def generator():
240246
nonlocal sent
241247

242248
batch_deadline = monotonic() + float(config.agent_queue_timeout)
249+
first_get = True
243250
while True:
244-
log_data = _queue_get_within_batch(queue, block, batch_deadline) # type: LogData
251+
log_data = _queue_get_within_batch(queue, block, batch_deadline, allow_immediate=first_get) # type: LogData
252+
first_get = False
245253
if log_data is None:
246254
return
247255

@@ -270,8 +278,10 @@ def generator():
270278
nonlocal sent
271279

272280
batch_deadline = monotonic() + float(config.agent_queue_timeout)
281+
first_get = True
273282
while True:
274-
meter_data = _queue_get_within_batch(queue, block, batch_deadline) # type: MeterData
283+
meter_data = _queue_get_within_batch(queue, block, batch_deadline, allow_immediate=first_get) # type: MeterData
284+
first_get = False
275285
if meter_data is None:
276286
return
277287

@@ -299,8 +309,10 @@ def generator():
299309
nonlocal sent
300310

301311
batch_deadline = monotonic() + float(config.agent_queue_timeout)
312+
first_get = True
302313
while True:
303-
snapshot = _queue_get_within_batch(queue, block, batch_deadline) # type: TracingThreadSnapshot
314+
snapshot = _queue_get_within_batch(queue, block, batch_deadline, allow_immediate=first_get) # type: TracingThreadSnapshot
315+
first_get = False
304316
if snapshot is None:
305317
return
306318

tests/unit/test_grpc_channel.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
import json
2020
import unittest
2121
import asyncio
22+
from queue import Queue
23+
from time import monotonic
2224
from unittest.mock import MagicMock, patch
2325

2426
import grpc
@@ -646,6 +648,28 @@ def test_sync_report_uses_timeout_with_margin(self):
646648
config.agent_queue_timeout = prev
647649

648650

651+
class TestQueueGetWithinBatch(unittest.TestCase):
652+
653+
def test_queue_timeout_zero_drains_immediately_available_item(self):
654+
from skywalking.agent.protocol.grpc import _queue_get_within_batch
655+
656+
q = Queue()
657+
q.put('segment')
658+
batch_deadline = monotonic()
659+
item = _queue_get_within_batch(q, True, batch_deadline, allow_immediate=True)
660+
self.assertEqual(item, 'segment')
661+
self.assertTrue(q.empty())
662+
663+
def test_queue_timeout_zero_skips_when_empty(self):
664+
from skywalking.agent.protocol.grpc import _queue_get_within_batch
665+
666+
q = Queue()
667+
batch_deadline = monotonic()
668+
self.assertIsNone(
669+
_queue_get_within_batch(q, True, batch_deadline, allow_immediate=True),
670+
)
671+
672+
649673
class TestCollectorChannelNotInstrumented(unittest.TestCase):
650674

651675
def test_multi_address_collector_channel_skips_sw_interceptor(self):

tests/unit/test_shutdown_queue.py

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -136,43 +136,44 @@ async def _spawn():
136136
thread.join(timeout=2.0)
137137
loop.close()
138138

139-
def test_cancel_preserves_asyncio_run_root_so_fini_completes(self):
139+
def test_async_shutdown_cleanup_runs_inside_asyncio_run_root(self):
140140
"""
141-
Production path uses asyncio.run(root). Cancelling the root tears down the Runner
142-
and cancels fini before aclose(). Only background tasks may be cancelled.
141+
Production topology: root waits on _finished, then cancels reporters and
142+
awaits a yielding protocol aclose() before asyncio.run returns.
143143
"""
144+
holder = {}
145+
aclose_entered = Event()
144146
aclose_done = Event()
145147
loop_ready = Event()
146-
holder = {}
148+
149+
async def yielding_aclose():
150+
aclose_entered.set()
151+
await asyncio.sleep(0.05)
152+
aclose_done.set()
147153

148154
async def root():
155+
finished = asyncio.Event()
156+
holder['finished'] = finished
149157
holder['loop'] = asyncio.get_running_loop()
150-
holder['root'] = asyncio.current_task()
151158

152159
async def reporter():
153-
while True:
154-
await asyncio.sleep(0.05)
160+
while not finished.is_set():
161+
await asyncio.sleep(0.02)
155162

156-
holder['tasks'] = {asyncio.create_task(reporter()) for _ in range(2)}
163+
tasks = {asyncio.create_task(reporter()) for _ in range(2)}
157164
loop_ready.set()
158-
# return_exceptions: cancelled reporters must not surface as root CancelledError
159-
await asyncio.gather(*holder['tasks'], return_exceptions=True)
165+
await finished.wait()
166+
await _cancel_pending_tasks(tasks)
167+
await yielding_aclose()
160168
holder['root_finished'] = True
161169

162170
thread = Thread(target=lambda: asyncio.run(root()), daemon=True)
163171
thread.start()
164172
self.assertTrue(loop_ready.wait(3.0))
165-
166-
async def fini():
167-
# Must not cancel holder["root"]; doing so would cancel this fini via Runner teardown.
168-
await _cancel_pending_tasks(holder['tasks'])
169-
aclose_done.set()
170-
return 'ok'
171-
172-
result = asyncio.run_coroutine_threadsafe(fini(), holder['loop']).result(timeout=3.0)
173-
self.assertEqual(result, 'ok')
174-
self.assertTrue(aclose_done.wait(1.0))
175-
thread.join(timeout=3.0)
173+
holder['loop'].call_soon_threadsafe(holder['finished'].set)
174+
thread.join(timeout=5.0)
175+
self.assertTrue(aclose_entered.wait(2.0))
176+
self.assertTrue(aclose_done.wait(2.0))
176177
self.assertTrue(holder.get('root_finished', False))
177178

178179

0 commit comments

Comments
 (0)