blob: 5258fe99a013e964e73be9ad4c67c39f0005e155 [file]
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
import asyncio
import logging
import time
import unittest
from queue import Queue
from threading import Event, Thread
from skywalking.agent import (
_abandon_async_queue,
_abandon_sync_queue,
_await_shutdown_or_background_failure,
_cancel_pending_tasks,
_join_sync_queue,
_shutdown_async_queue,
_shutdown_sync_queue,
)
class TestShutdownQueueHelpers(unittest.TestCase):
def test_abandon_sync_queue_unblocks_join(self):
q = Queue()
q.put('a')
q.put('b')
self.assertEqual(q.unfinished_tasks, 2)
hung = Event()
def _join():
q.join()
hung.set()
Thread(target=_join, daemon=True).start()
time.sleep(0.05)
self.assertFalse(hung.is_set())
abandoned = _abandon_sync_queue(q)
self.assertEqual(abandoned, 2)
self.assertTrue(hung.wait(1.0))
self.assertTrue(_join_sync_queue(q, 0.5))
def test_shutdown_sync_skips_flush_when_not_ready(self):
q = Queue()
q.put('x')
called = []
def report():
called.append(1)
raise AssertionError('must not flush when may_send is False')
_shutdown_sync_queue(report, q, 'test', may_send=False)
self.assertEqual(called, [])
self.assertTrue(q.empty())
self.assertEqual(q.unfinished_tasks, 0)
def test_shutdown_sync_flush_timeout_then_abandon(self):
q = Queue()
q.put('x')
started = Event()
def report():
started.set()
time.sleep(10) # longer than flush budget
# Temporarily shrink budget via monkeypatch on module constant
import skywalking.agent as agent_mod
previous = agent_mod._SHUTDOWN_FLUSH_TIMEOUT_SEC
try:
agent_mod._SHUTDOWN_FLUSH_TIMEOUT_SEC = 0.2
t0 = time.monotonic()
_shutdown_sync_queue(report, q, 'test', may_send=True)
elapsed = time.monotonic() - t0
finally:
agent_mod._SHUTDOWN_FLUSH_TIMEOUT_SEC = previous
self.assertTrue(started.wait(1.0))
self.assertLess(elapsed, 2.0)
self.assertTrue(q.empty())
self.assertEqual(q.unfinished_tasks, 0)
def test_abandon_async_queue_unblocks_join(self):
async def _run():
q = asyncio.Queue()
await q.put('a')
await q.put('b')
join_task = asyncio.create_task(q.join())
await asyncio.sleep(0.05)
self.assertFalse(join_task.done())
abandoned = await _abandon_async_queue(q)
self.assertEqual(abandoned, 2)
await asyncio.wait_for(join_task, timeout=1.0)
asyncio.run(_run())
def test_cancel_pending_tasks_excludes_caller(self):
"""Regression: gathering the caller's own task made shutdown hang until the outer budget."""
loop = asyncio.new_event_loop()
thread = Thread(target=loop.run_forever, daemon=True)
thread.start()
async def _forever():
while True:
await asyncio.sleep(0.05)
try:
async def _spawn():
return [asyncio.create_task(_forever()) for _ in range(2)]
reporter_tasks = asyncio.run_coroutine_threadsafe(_spawn(), loop).result(timeout=2.0)
time.sleep(0.1)
t0 = time.monotonic()
future = asyncio.run_coroutine_threadsafe(_cancel_pending_tasks(reporter_tasks), loop)
future.result(timeout=2.0)
self.assertLess(time.monotonic() - t0, 2.0)
for task in reporter_tasks:
self.assertTrue(task.cancelled() or task.done())
finally:
loop.call_soon_threadsafe(loop.stop)
thread.join(timeout=2.0)
loop.close()
def test_async_shutdown_cleanup_runs_inside_asyncio_run_root(self):
"""
Production topology: root waits on _finished, then cancels reporters and
awaits a yielding protocol aclose() before asyncio.run returns.
"""
holder = {}
aclose_entered = Event()
aclose_done = Event()
loop_ready = Event()
async def yielding_aclose():
aclose_entered.set()
await asyncio.sleep(0.05)
aclose_done.set()
async def root():
finished = asyncio.Event()
holder['finished'] = finished
holder['loop'] = asyncio.get_running_loop()
async def reporter():
while not finished.is_set():
await asyncio.sleep(0.02)
tasks = {asyncio.create_task(reporter()) for _ in range(2)}
loop_ready.set()
await finished.wait()
await _cancel_pending_tasks(tasks)
await yielding_aclose()
holder['root_finished'] = True
thread = Thread(target=lambda: asyncio.run(root()), daemon=True)
thread.start()
self.assertTrue(loop_ready.wait(3.0))
holder['loop'].call_soon_threadsafe(holder['finished'].set)
thread.join(timeout=5.0)
self.assertTrue(aclose_entered.wait(2.0))
self.assertTrue(aclose_done.wait(2.0))
self.assertTrue(holder.get('root_finished', False))
def test_background_task_failure_is_logged_not_silenced(self):
"""Regression: failing background tasks must be observed and logged exactly once."""
holder = {'errors': []}
error_logged = Event()
aclose_done = Event()
class _Handler(logging.Handler):
def emit(self, record):
msg = record.getMessage()
if 'Error in Python agent asyncio event loop' in msg:
holder['errors'].append(msg)
error_logged.set()
agent_logger = logging.getLogger('skywalking')
handler = _Handler()
agent_logger.addHandler(handler)
previous_level = agent_logger.level
agent_logger.setLevel(logging.ERROR)
async def failing_background():
await asyncio.sleep(0.02)
raise ValueError('command dispatch failed')
async def yielding_aclose():
await asyncio.sleep(0.02)
aclose_done.set()
async def root():
finished = asyncio.Event()
holder['finished'] = finished
failing_task = asyncio.create_task(failing_background())
async def reporter():
while not finished.is_set():
await asyncio.sleep(0.05)
reporter_task = asyncio.create_task(reporter())
tasks = {failing_task, reporter_task}
await _await_shutdown_or_background_failure(finished, tasks)
await _cancel_pending_tasks(tasks)
await yielding_aclose()
holder['after_wait'] = True
holder['failing_task'] = failing_task
try:
asyncio.run(root())
self.assertTrue(holder.get('after_wait'))
self.assertTrue(error_logged.wait(2.0))
self.assertTrue(aclose_done.wait(2.0))
self.assertEqual(len(holder['errors']), 1)
self.assertIn('command dispatch failed', holder['errors'][0])
self.assertTrue(holder['finished'].is_set())
self.assertTrue(holder['failing_task'].done())
self.assertIsInstance(holder['failing_task'].exception(), ValueError)
finally:
agent_logger.removeHandler(handler)
agent_logger.setLevel(previous_level)
def test_clean_shutdown_does_not_log_normal_background_completion(self):
"""Clean shutdown: reporters finish after _finished; cleanup must stay silent."""
holder = {'errors': []}
class _Handler(logging.Handler):
def emit(self, record):
if 'Error in Python agent asyncio event loop' in record.getMessage():
holder['errors'].append(record.getMessage())
agent_logger = logging.getLogger('skywalking')
handler = _Handler()
agent_logger.addHandler(handler)
previous_level = agent_logger.level
agent_logger.setLevel(logging.ERROR)
async def root():
finished = asyncio.Event()
async def reporter():
while not finished.is_set():
await asyncio.sleep(0.01)
tasks = {asyncio.create_task(reporter()) for _ in range(2)}
finished.set()
await _await_shutdown_or_background_failure(finished, tasks)
# Give reporters a turn to observe _finished and return normally.
await asyncio.sleep(0.05)
await _cancel_pending_tasks(tasks)
holder['all_done'] = all(task.done() for task in tasks)
try:
asyncio.run(root())
self.assertTrue(holder.get('all_done'))
self.assertEqual(holder['errors'], [])
finally:
agent_logger.removeHandler(handler)
agent_logger.setLevel(previous_level)
def test_unexpected_pre_shutdown_cancellation_is_logged(self):
"""Cancellation before shutdown must be logged and trigger orderly cleanup."""
holder = {'errors': []}
error_logged = Event()
aclose_done = Event()
class _Handler(logging.Handler):
def emit(self, record):
msg = record.getMessage()
if 'Error in Python agent asyncio event loop' in msg:
holder['errors'].append(msg)
error_logged.set()
agent_logger = logging.getLogger('skywalking')
handler = _Handler()
agent_logger.addHandler(handler)
previous_level = agent_logger.level
agent_logger.setLevel(logging.ERROR)
async def cancelled_background():
asyncio.current_task().cancel()
await asyncio.sleep(0)
async def yielding_aclose():
await asyncio.sleep(0.02)
aclose_done.set()
async def root():
finished = asyncio.Event()
holder['finished'] = finished
async def reporter():
while not finished.is_set():
await asyncio.sleep(0.05)
cancelled_task = asyncio.create_task(cancelled_background())
reporter_task = asyncio.create_task(reporter())
tasks = {cancelled_task, reporter_task}
await _await_shutdown_or_background_failure(finished, tasks)
await _cancel_pending_tasks(tasks)
await yielding_aclose()
holder['after_wait'] = True
try:
asyncio.run(root())
self.assertTrue(holder.get('after_wait'))
self.assertTrue(error_logged.wait(2.0))
self.assertTrue(aclose_done.wait(2.0))
self.assertEqual(len(holder['errors']), 1)
self.assertIn('cancelled unexpectedly', holder['errors'][0])
self.assertTrue(holder['finished'].is_set())
finally:
agent_logger.removeHandler(handler)
agent_logger.setLevel(previous_level)
def test_intentional_cleanup_cancellation_is_silent(self):
"""Tasks cancelled by _cancel_pending_tasks during cleanup must not ERROR."""
holder = {'errors': []}
class _Handler(logging.Handler):
def emit(self, record):
if 'Error in Python agent asyncio event loop' in record.getMessage():
holder['errors'].append(record.getMessage())
agent_logger = logging.getLogger('skywalking')
handler = _Handler()
agent_logger.addHandler(handler)
previous_level = agent_logger.level
agent_logger.setLevel(logging.ERROR)
async def forever():
while True:
await asyncio.sleep(0.05)
async def root():
tasks = {asyncio.create_task(forever()) for _ in range(2)}
await _cancel_pending_tasks(tasks)
holder['all_done'] = all(task.done() for task in tasks)
try:
asyncio.run(root())
self.assertTrue(holder.get('all_done'))
self.assertEqual(holder['errors'], [])
finally:
agent_logger.removeHandler(handler)
agent_logger.setLevel(previous_level)
def test_shutdown_async_queue_bounded(self):
async def _run():
q = asyncio.Queue()
await q.put('a')
t0 = time.monotonic()
await _shutdown_async_queue(q, 'test')
self.assertLess(time.monotonic() - t0, 2.0)
self.assertTrue(q.empty())
asyncio.run(_run())
if __name__ == '__main__':
unittest.main()