import asyncio
import time
from typing import List


async def run_tasks(tasks: List[asyncio.Future]):
    """
    Run tasks in parallel. Upon the first exiting, cancel and wait for the rest.
    If the first exists by an exception it is raised. If more tasks raise an exception they are ignored.
    """
    try:
        done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
        # propagate first work task error to the main thread
        for t in done:
            if e := t.exception():
                raise e
    finally:
        for t in tasks:
            t.cancel()
        await asyncio.gather(*tasks, return_exceptions=True)


async def force_wait_predicate(fn, sleep=0.15, timeout=None):
    """
    This is a simple construct to force waiting on a non-asyncio thread by waiting for a predicate function.
    This is necessary because cancellation of the current task cannot be chained with a non-asyncio thread.
    Since this construct eats all cancellation attempts, it is recommended to define a timeout and only use
    it during cleanup. Where the task is already exiting or cancelled and no other cancellation may take precedence.
    """
    cancelled = False
    t = time.perf_counter()
    while not fn():
        if timeout is not None and t + timeout < time.perf_counter():
            raise asyncio.TimeoutError()
        try:
            await asyncio.sleep(sleep)
        except asyncio.CancelledError:
            cancelled = True
    if cancelled:
        raise asyncio.CancelledError()
