657 lines
21 KiB
Python
657 lines
21 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import ctypes
|
|
import io
|
|
import logging
|
|
import multiprocessing as mp
|
|
import socket
|
|
import time
|
|
import uuid
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from multiprocessing.context import BaseContext
|
|
from typing import ClassVar
|
|
|
|
import psutil
|
|
import pytest
|
|
|
|
from livekit.agents import JobContext, JobProcess, ipc, job, utils
|
|
from livekit.agents.ipc.log_queue import LogQueueHandler, LogQueueListener
|
|
from livekit.agents.utils.aio import duplex_unix
|
|
from livekit.protocol import agent
|
|
|
|
pytestmark = [pytest.mark.unit, pytest.mark.concurrent]
|
|
|
|
|
|
@dataclass
|
|
class EmptyMessage:
|
|
MSG_ID: ClassVar[int] = 0
|
|
|
|
|
|
@dataclass
|
|
class SomeDataMessage:
|
|
MSG_ID: ClassVar[int] = 1
|
|
string: str = ""
|
|
number: int = 0
|
|
double: float = 0.0
|
|
data: bytes = b""
|
|
|
|
def write(self, b: io.BytesIO) -> None:
|
|
ipc.channel.write_string(b, self.string)
|
|
ipc.channel.write_int(b, self.number)
|
|
ipc.channel.write_double(b, self.double)
|
|
ipc.channel.write_bytes(b, self.data)
|
|
|
|
def read(self, b: io.BytesIO) -> None:
|
|
self.string = ipc.channel.read_string(b)
|
|
self.number = ipc.channel.read_int(b)
|
|
self.double = ipc.channel.read_double(b)
|
|
self.data = ipc.channel.read_bytes(b)
|
|
|
|
|
|
IPC_MESSAGES = {
|
|
EmptyMessage.MSG_ID: EmptyMessage,
|
|
SomeDataMessage.MSG_ID: SomeDataMessage,
|
|
}
|
|
|
|
|
|
def _echo_main(mp_cch):
|
|
async def _pong():
|
|
cch = await utils.aio.duplex_unix._AsyncDuplex.open(mp_cch)
|
|
while True:
|
|
try:
|
|
msg = await ipc.channel.arecv_message(cch, IPC_MESSAGES)
|
|
await ipc.channel.asend_message(cch, msg)
|
|
except utils.aio.duplex_unix.DuplexClosed:
|
|
print("_echo_main, duplex closed..")
|
|
break
|
|
|
|
asyncio.run(_pong())
|
|
|
|
|
|
async def test_async_channel():
|
|
mp_pch, mp_cch = socket.socketpair()
|
|
pch = await utils.aio.duplex_unix._AsyncDuplex.open(mp_pch)
|
|
proc = mp.get_context("spawn").Process(target=_echo_main, args=(mp_cch,))
|
|
proc.start()
|
|
mp_cch.close()
|
|
|
|
await ipc.channel.asend_message(pch, EmptyMessage())
|
|
assert await ipc.channel.arecv_message(pch, IPC_MESSAGES) == EmptyMessage()
|
|
|
|
await ipc.channel.asend_message(
|
|
pch, SomeDataMessage(string="hello", number=42, double=3.14, data=b"world")
|
|
)
|
|
assert await ipc.channel.arecv_message(pch, IPC_MESSAGES) == SomeDataMessage(
|
|
string="hello", number=42, double=3.14, data=b"world"
|
|
)
|
|
|
|
await pch.aclose()
|
|
await asyncio.sleep(0.1)
|
|
proc.terminate()
|
|
await asyncio.get_running_loop().run_in_executor(None, proc.join)
|
|
|
|
|
|
def test_sync_channel():
|
|
mp_pch, mp_cch = socket.socketpair()
|
|
pch = utils.aio.duplex_unix._Duplex.open(mp_pch)
|
|
|
|
proc = mp.get_context("spawn").Process(target=_echo_main, args=(mp_cch,))
|
|
proc.start()
|
|
mp_cch.close()
|
|
|
|
ipc.channel.send_message(pch, EmptyMessage())
|
|
assert ipc.channel.recv_message(pch, IPC_MESSAGES) == EmptyMessage()
|
|
|
|
ipc.channel.send_message(
|
|
pch, SomeDataMessage(string="hello", number=42, double=3.14, data=b"world")
|
|
)
|
|
assert ipc.channel.recv_message(pch, IPC_MESSAGES) == SomeDataMessage(
|
|
string="hello", number=42, double=3.14, data=b"world"
|
|
)
|
|
|
|
pch.close()
|
|
|
|
|
|
def _generate_fake_job() -> job.RunningJobInfo:
|
|
return job.RunningJobInfo(
|
|
job=agent.Job(id="fake_job_" + str(uuid.uuid4().hex), type=agent.JobType.JT_ROOM),
|
|
url="fake_url",
|
|
token="fake_token",
|
|
accept_arguments=job.JobAcceptArguments(name="", identity="", metadata=""),
|
|
worker_id="fake_id",
|
|
fake_job=True,
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class _StartArgs:
|
|
initialize_counter: mp.Value
|
|
entrypoint_counter: mp.Value
|
|
shutdown_counter: mp.Value
|
|
initialize_simulate_work_time: float
|
|
entrypoint_simulate_work_time: float
|
|
shutdown_simulate_work_time: float
|
|
update_ev: mp.Condition
|
|
|
|
|
|
def _new_start_args(mp_ctx: BaseContext) -> _StartArgs:
|
|
return _StartArgs(
|
|
initialize_counter=mp_ctx.Value(ctypes.c_uint),
|
|
entrypoint_counter=mp_ctx.Value(ctypes.c_uint),
|
|
shutdown_counter=mp_ctx.Value(ctypes.c_uint),
|
|
initialize_simulate_work_time=0.0,
|
|
entrypoint_simulate_work_time=0.0,
|
|
shutdown_simulate_work_time=0.0,
|
|
update_ev=mp_ctx.Condition(),
|
|
)
|
|
|
|
|
|
def _initialize_proc(proc: JobProcess) -> None:
|
|
start_args: _StartArgs = proc.user_arguments
|
|
|
|
# incrementing isn't atomic (the lock should be reentrant by default)
|
|
with start_args.initialize_counter.get_lock():
|
|
start_args.initialize_counter.value += 1
|
|
|
|
time.sleep(start_args.initialize_simulate_work_time)
|
|
|
|
with start_args.update_ev:
|
|
start_args.update_ev.notify()
|
|
|
|
|
|
def _failing_initialize_proc(proc: JobProcess) -> None:
|
|
# Runs in the spawned child; raising here makes every spawn's initialize() fail. Used instead
|
|
# of monkeypatching ProcJobExecutor process-wide, so the test is safe to run concurrently.
|
|
raise RuntimeError("simulated init failure")
|
|
|
|
|
|
async def _job_entrypoint(job_ctx: JobContext) -> None:
|
|
start_args: _StartArgs = job_ctx.proc.user_arguments
|
|
|
|
async def _job_shutdown() -> None:
|
|
with start_args.shutdown_counter.get_lock():
|
|
start_args.shutdown_counter.value += 1
|
|
|
|
await asyncio.sleep(start_args.shutdown_simulate_work_time)
|
|
|
|
with start_args.update_ev:
|
|
start_args.update_ev.notify()
|
|
|
|
job_ctx.add_shutdown_callback(_job_shutdown)
|
|
|
|
with start_args.entrypoint_counter.get_lock():
|
|
start_args.entrypoint_counter.value += 1
|
|
|
|
await asyncio.sleep(start_args.entrypoint_simulate_work_time)
|
|
|
|
job_ctx.shutdown(
|
|
"calling shutdown inside the test to avoid a warning when neither shutdown nor connect is called." # noqa: E501
|
|
)
|
|
|
|
with start_args.update_ev:
|
|
start_args.update_ev.notify()
|
|
|
|
|
|
async def _job_entrypoint_session_aclose_hangs(job_ctx: JobContext) -> None:
|
|
"""Plant a fake AgentSession whose aclose() hangs forever; the
|
|
_SESSION_ACLOSE_TIMEOUT guardrail must fire so shutdown callbacks still run."""
|
|
from livekit.agents.ipc import job_proc_lazy_main
|
|
|
|
# Shrink the hardcoded guardrail so the test doesn't wait the full 60s.
|
|
job_proc_lazy_main._SESSION_ACLOSE_TIMEOUT = 0.5
|
|
|
|
start_args: _StartArgs = job_ctx.proc.user_arguments
|
|
|
|
async def _job_shutdown() -> None:
|
|
with start_args.shutdown_counter.get_lock():
|
|
start_args.shutdown_counter.value += 1
|
|
|
|
job_ctx.add_shutdown_callback(_job_shutdown)
|
|
|
|
class _HangingSession:
|
|
async def aclose(self) -> None:
|
|
await asyncio.Event().wait() # never resolves
|
|
|
|
job_ctx._primary_agent_session = _HangingSession() # type: ignore[assignment]
|
|
|
|
# _on_session_end touches session.history and make_session_report, which
|
|
# would crash on our minimal fake. Bypass it so the assertion is purely
|
|
# about the aclose-timeout → shutdown-callback path.
|
|
async def _noop_session_end() -> None:
|
|
return None
|
|
|
|
job_ctx._on_session_end = _noop_session_end # type: ignore[method-assign]
|
|
|
|
with start_args.entrypoint_counter.get_lock():
|
|
start_args.entrypoint_counter.value += 1
|
|
|
|
job_ctx.shutdown("trigger hang in session.aclose()")
|
|
|
|
|
|
async def _job_entrypoint_raises_after_shutdown(job_ctx: JobContext) -> None:
|
|
"""Reproduces the room-disconnect race: _shutdown_fut is set before the
|
|
entrypoint task unwinds, then the entrypoint raises while _run_job_task
|
|
is awaiting it. The shutdown callback must still run."""
|
|
start_args: _StartArgs = job_ctx.proc.user_arguments
|
|
|
|
async def _job_shutdown() -> None:
|
|
with start_args.shutdown_counter.get_lock():
|
|
start_args.shutdown_counter.value += 1
|
|
|
|
job_ctx.add_shutdown_callback(_job_shutdown)
|
|
|
|
with start_args.entrypoint_counter.get_lock():
|
|
start_args.entrypoint_counter.value += 1
|
|
|
|
# set _shutdown_fut first (mimics the room "disconnected" handler), then
|
|
# raise from a pending await (mimics wait_for_participant's RuntimeError).
|
|
fut: asyncio.Future[None] = asyncio.Future()
|
|
|
|
async def _trigger() -> None:
|
|
job_ctx.shutdown("simulated room disconnect")
|
|
await asyncio.sleep(0)
|
|
fut.set_exception(RuntimeError("room disconnected while waiting for participant"))
|
|
|
|
asyncio.create_task(_trigger())
|
|
await fut
|
|
|
|
|
|
async def _poll_until(
|
|
condition_fn: Callable[[], bool], *, timeout: float = 10.0, poll_interval: float = 0.05
|
|
) -> None:
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
if condition_fn():
|
|
return
|
|
await asyncio.sleep(poll_interval)
|
|
raise TimeoutError(f"Timed out after {timeout}s waiting for condition")
|
|
|
|
|
|
async def _wait_for_elements(q: asyncio.Queue, num_elements: int) -> None:
|
|
for _ in range(num_elements):
|
|
await q.get()
|
|
|
|
|
|
async def test_proc_pool():
|
|
mp_ctx = mp.get_context("spawn")
|
|
loop = asyncio.get_running_loop()
|
|
num_idle_processes = 3
|
|
pool = ipc.proc_pool.ProcPool(
|
|
initialize_process_fnc=_initialize_proc,
|
|
job_entrypoint_fnc=_job_entrypoint,
|
|
session_end_fnc=None,
|
|
simulation_end_fnc=None,
|
|
num_idle_processes=num_idle_processes,
|
|
job_executor_type=job.JobExecutorType.PROCESS,
|
|
initialize_timeout=20.0,
|
|
close_timeout=20.0,
|
|
session_end_timeout=300.0,
|
|
inference_executor=None,
|
|
memory_warn_mb=0,
|
|
memory_limit_mb=0,
|
|
http_proxy=None,
|
|
mp_ctx=mp_ctx,
|
|
loop=loop,
|
|
)
|
|
|
|
start_args = _new_start_args(mp_ctx)
|
|
created_q = asyncio.Queue()
|
|
start_q = asyncio.Queue()
|
|
ready_q = asyncio.Queue()
|
|
close_q = asyncio.Queue()
|
|
|
|
pids = []
|
|
exitcodes = []
|
|
|
|
@pool.on("process_created")
|
|
def _process_created(proc: ipc.job_proc_executor.ProcJobExecutor):
|
|
created_q.put_nowait(None)
|
|
proc.user_arguments = start_args
|
|
|
|
@pool.on("process_started")
|
|
def _process_started(proc: ipc.job_proc_executor.ProcJobExecutor):
|
|
start_q.put_nowait(None)
|
|
pids.append(proc.pid)
|
|
|
|
@pool.on("process_ready")
|
|
def _process_ready(proc: ipc.job_proc_executor.ProcJobExecutor):
|
|
ready_q.put_nowait(None)
|
|
|
|
@pool.on("process_closed")
|
|
def _process_closed(proc: ipc.job_proc_executor.ProcJobExecutor):
|
|
close_q.put_nowait(None)
|
|
exitcodes.append(proc.exitcode)
|
|
|
|
await pool.start()
|
|
|
|
await _wait_for_elements(created_q, num_idle_processes)
|
|
await _wait_for_elements(start_q, num_idle_processes)
|
|
await _wait_for_elements(ready_q, num_idle_processes)
|
|
|
|
assert start_args.initialize_counter.value == num_idle_processes
|
|
|
|
jobs_to_start = 2
|
|
|
|
for _ in range(jobs_to_start):
|
|
await pool.launch_job(_generate_fake_job())
|
|
|
|
await _wait_for_elements(created_q, jobs_to_start)
|
|
await _wait_for_elements(start_q, jobs_to_start)
|
|
await _wait_for_elements(ready_q, jobs_to_start)
|
|
|
|
await pool.aclose()
|
|
|
|
assert start_args.entrypoint_counter.value == jobs_to_start
|
|
assert start_args.shutdown_counter.value == jobs_to_start
|
|
|
|
await _wait_for_elements(close_q, num_idle_processes + jobs_to_start)
|
|
|
|
# the way we check that a process doesn't exist anymore isn't technically reliable (pid recycle could happen) # noqa: E501
|
|
for pid in pids:
|
|
assert not psutil.pid_exists(pid)
|
|
|
|
for exitcode in exitcodes:
|
|
# this test expects graceful shutdown, kill is tested on another test
|
|
assert exitcode == 0, f"process did not exit cleanly: {exitcode}"
|
|
|
|
|
|
async def test_slow_initialization():
|
|
mp_ctx = mp.get_context("spawn")
|
|
loop = asyncio.get_running_loop()
|
|
num_idle_processes = 2
|
|
pool = ipc.proc_pool.ProcPool(
|
|
job_executor_type=job.JobExecutorType.PROCESS,
|
|
initialize_process_fnc=_initialize_proc,
|
|
job_entrypoint_fnc=_job_entrypoint,
|
|
session_end_fnc=None,
|
|
simulation_end_fnc=None,
|
|
num_idle_processes=num_idle_processes,
|
|
initialize_timeout=1.0,
|
|
close_timeout=20.0,
|
|
session_end_timeout=300.0,
|
|
inference_executor=None,
|
|
memory_warn_mb=0,
|
|
memory_limit_mb=0,
|
|
http_proxy=None,
|
|
mp_ctx=mp_ctx,
|
|
loop=loop,
|
|
)
|
|
|
|
start_args = _new_start_args(mp_ctx)
|
|
start_args.initialize_simulate_work_time = 2.0
|
|
start_q = asyncio.Queue()
|
|
close_q = asyncio.Queue()
|
|
|
|
pids = []
|
|
exitcodes = []
|
|
|
|
@pool.on("process_created")
|
|
def _process_created(proc: ipc.job_proc_executor.ProcJobExecutor):
|
|
proc.user_arguments = start_args
|
|
start_q.put_nowait(None)
|
|
|
|
@pool.on("process_closed")
|
|
def _process_closed(proc: ipc.job_proc_executor.ProcJobExecutor):
|
|
close_q.put_nowait(None)
|
|
if proc.pid is not None:
|
|
pids.append(proc.pid)
|
|
exitcodes.append(proc.exitcode)
|
|
|
|
await pool.start()
|
|
|
|
await _wait_for_elements(start_q, num_idle_processes)
|
|
await _wait_for_elements(close_q, num_idle_processes)
|
|
|
|
# retry batch should also timeout and be killed
|
|
await _wait_for_elements(start_q, num_idle_processes)
|
|
await _wait_for_elements(close_q, num_idle_processes)
|
|
|
|
await pool.aclose()
|
|
|
|
for pid in pids:
|
|
assert not psutil.pid_exists(pid)
|
|
|
|
for exitcode in exitcodes:
|
|
assert exitcode != 0, "process should have been killed"
|
|
|
|
|
|
async def test_proc_pool_launch_job_raises_when_all_spawns_fail():
|
|
"""When every spawn task fails to initialize, launch_job should raise
|
|
instead of hanging on an empty warmed-process queue. Reproduces #5868.
|
|
|
|
The failure is injected via a real executor whose init fn raises (not by
|
|
monkeypatching ProcJobExecutor process-wide), so this is safe to run
|
|
concurrently with its peers.
|
|
"""
|
|
mp_ctx = mp.get_context("spawn")
|
|
loop = asyncio.get_running_loop()
|
|
pool = ipc.proc_pool.ProcPool(
|
|
job_executor_type=job.JobExecutorType.PROCESS,
|
|
initialize_process_fnc=_failing_initialize_proc,
|
|
job_entrypoint_fnc=_job_entrypoint,
|
|
session_end_fnc=None,
|
|
simulation_end_fnc=None,
|
|
num_idle_processes=0,
|
|
# generous so initialize() fails via the init fn raising, not a spawn-racing timeout
|
|
initialize_timeout=10.0,
|
|
close_timeout=20.0,
|
|
session_end_timeout=300.0,
|
|
inference_executor=None,
|
|
memory_warn_mb=0,
|
|
memory_limit_mb=0,
|
|
http_proxy=None,
|
|
mp_ctx=mp_ctx,
|
|
loop=loop,
|
|
)
|
|
await pool.start()
|
|
|
|
try:
|
|
with pytest.raises(RuntimeError, match="no process became available"):
|
|
await pool.launch_job(_generate_fake_job())
|
|
|
|
assert pool._jobs_waiting_for_process == 0
|
|
assert len(pool.processes) == 0
|
|
finally:
|
|
await pool.aclose()
|
|
|
|
|
|
def _create_proc(
|
|
*,
|
|
close_timeout: float,
|
|
mp_ctx: BaseContext,
|
|
initialize_timeout: float = 20.0,
|
|
job_entrypoint_fnc: Callable[[JobContext], object] = _job_entrypoint,
|
|
) -> tuple[ipc.job_proc_executor.ProcJobExecutor, _StartArgs]:
|
|
start_args = _new_start_args(mp_ctx)
|
|
loop = asyncio.get_running_loop()
|
|
proc = ipc.job_proc_executor.ProcJobExecutor(
|
|
initialize_process_fnc=_initialize_proc,
|
|
job_entrypoint_fnc=job_entrypoint_fnc,
|
|
session_end_fnc=None,
|
|
simulation_end_fnc=None,
|
|
initialize_timeout=initialize_timeout,
|
|
close_timeout=close_timeout,
|
|
session_end_timeout=300.0,
|
|
memory_warn_mb=0,
|
|
memory_limit_mb=0,
|
|
ping_interval=2.5,
|
|
ping_timeout=10.0,
|
|
high_ping_threshold=1.0,
|
|
inference_executor=None,
|
|
http_proxy=None,
|
|
mp_ctx=mp_ctx,
|
|
loop=loop,
|
|
)
|
|
proc.user_arguments = start_args
|
|
return proc, start_args
|
|
|
|
|
|
async def test_aclose_after_cancelled_start():
|
|
"""When start() is cancelled mid-flight, the shielded _start() keeps running;
|
|
aclose() must still tear down the supervise task and the child process."""
|
|
mp_ctx = mp.get_context("spawn")
|
|
proc, _ = _create_proc(close_timeout=10.0, mp_ctx=mp_ctx)
|
|
|
|
start_task = asyncio.create_task(proc.start())
|
|
await asyncio.sleep(0) # let start() enter the shielded _start()
|
|
start_task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await start_task
|
|
|
|
# mimics ProcPool._proc_spawn_task's cleanup after swallowing the cancellation
|
|
await proc.aclose()
|
|
|
|
assert proc._supervise_atask is not None, "start() should have completed under the shield"
|
|
assert proc._supervise_atask.done()
|
|
assert proc.pid is not None
|
|
assert not psutil.pid_exists(proc.pid)
|
|
|
|
|
|
async def test_shutdown_no_job():
|
|
mp_ctx = mp.get_context("spawn")
|
|
proc, start_args = _create_proc(close_timeout=10.0, mp_ctx=mp_ctx)
|
|
await proc.start()
|
|
await proc.initialize()
|
|
await proc.aclose()
|
|
|
|
assert proc.exitcode == 0
|
|
assert not proc.killed
|
|
assert start_args.shutdown_counter.value == 0, "shutdown_cb isn't called when there is no job"
|
|
|
|
|
|
async def test_job_slow_shutdown():
|
|
mp_ctx = mp.get_context("spawn")
|
|
proc, start_args = _create_proc(close_timeout=0.3, mp_ctx=mp_ctx)
|
|
start_args.shutdown_simulate_work_time = 10.0
|
|
|
|
await proc.start()
|
|
await proc.initialize()
|
|
|
|
fake_job = _generate_fake_job()
|
|
await proc.launch_job(fake_job)
|
|
await _poll_until(lambda: start_args.entrypoint_counter.value >= 1)
|
|
await proc.aclose()
|
|
|
|
# process is killed when there is a job with slow timeout
|
|
assert proc.exitcode != 0, "process should have been killed"
|
|
assert proc.killed
|
|
|
|
|
|
async def test_shutdown_callback_runs_when_session_aclose_hangs():
|
|
"""Regression test: when AgentSession.aclose() blocks indefinitely during
|
|
job shutdown, the _SESSION_ACLOSE_TIMEOUT guardrail must fire and
|
|
user-registered shutdown callbacks must still run. The entrypoint shrinks
|
|
the hardcoded constant to 0.5s so the test stays fast."""
|
|
mp_ctx = mp.get_context("spawn")
|
|
proc, start_args = _create_proc(
|
|
close_timeout=20.0,
|
|
mp_ctx=mp_ctx,
|
|
job_entrypoint_fnc=_job_entrypoint_session_aclose_hangs,
|
|
)
|
|
await proc.start()
|
|
await proc.initialize()
|
|
|
|
fake_job = _generate_fake_job()
|
|
await proc.launch_job(fake_job)
|
|
await _poll_until(lambda: start_args.entrypoint_counter.value >= 1)
|
|
await proc.aclose()
|
|
|
|
assert proc.exitcode == 0, "process should have exited cleanly"
|
|
assert not proc.killed
|
|
assert start_args.shutdown_counter.value == 1, (
|
|
"shutdown callback must run even when session.aclose() hangs"
|
|
)
|
|
|
|
|
|
async def test_shutdown_callback_runs_when_entrypoint_raises():
|
|
"""Regression test: when the entrypoint raises after _shutdown_fut is
|
|
already set (as happens on room disconnect mid-wait_for_participant),
|
|
registered shutdown callbacks must still run."""
|
|
mp_ctx = mp.get_context("spawn")
|
|
proc, start_args = _create_proc(
|
|
close_timeout=10.0,
|
|
mp_ctx=mp_ctx,
|
|
job_entrypoint_fnc=_job_entrypoint_raises_after_shutdown,
|
|
)
|
|
await proc.start()
|
|
await proc.initialize()
|
|
|
|
fake_job = _generate_fake_job()
|
|
await proc.launch_job(fake_job)
|
|
await _poll_until(lambda: start_args.entrypoint_counter.value >= 1)
|
|
await proc.aclose()
|
|
|
|
assert proc.exitcode == 0, "process should have exited cleanly"
|
|
assert not proc.killed
|
|
assert start_args.shutdown_counter.value == 1, (
|
|
"shutdown callback must run even when entrypoint raises"
|
|
)
|
|
|
|
|
|
async def test_job_graceful_shutdown():
|
|
mp_ctx = mp.get_context("spawn")
|
|
proc, start_args = _create_proc(close_timeout=10.0, mp_ctx=mp_ctx)
|
|
start_args.shutdown_simulate_work_time = 0.3
|
|
await proc.start()
|
|
await proc.initialize()
|
|
|
|
fake_job = _generate_fake_job()
|
|
await proc.launch_job(fake_job)
|
|
await _poll_until(lambda: start_args.entrypoint_counter.value >= 1)
|
|
await proc.aclose()
|
|
|
|
assert proc.exitcode == 0, "process should have exited cleanly"
|
|
assert not proc.killed
|
|
assert start_args.shutdown_counter.value == 1
|
|
|
|
|
|
def test_log_queue_drains_before_stop():
|
|
"""All log records must be received by the listener even when stop() is
|
|
called right after the sender closes its end. This reproduces a race where
|
|
LogQueueListener.stop() used to close the socket *before* joining the
|
|
monitor thread, dropping buffered records."""
|
|
NUM_LOGS = 200
|
|
received: list[str] = []
|
|
|
|
parent_sock, child_sock = socket.socketpair()
|
|
parent_dup = duplex_unix._Duplex.open(parent_sock)
|
|
child_dup = duplex_unix._Duplex.open(child_sock)
|
|
|
|
# -- parent (listener) side --
|
|
class _CapturingListener(LogQueueListener):
|
|
def handle(self, record: logging.LogRecord) -> None:
|
|
received.append(record.getMessage())
|
|
# slow down processing so the buffer is not fully drained
|
|
# before stop() is called
|
|
time.sleep(0.001)
|
|
|
|
listener = _CapturingListener(parent_dup, lambda r: None)
|
|
listener.start()
|
|
|
|
# -- child (handler) side --
|
|
handler = LogQueueHandler(child_dup)
|
|
test_logger = logging.getLogger("test_log_queue_drain")
|
|
test_logger.addHandler(handler)
|
|
test_logger.setLevel(logging.DEBUG)
|
|
test_logger.propagate = False
|
|
|
|
for i in range(NUM_LOGS):
|
|
test_logger.info("msg %d", i)
|
|
|
|
# simulate the child process shutting down: close handler then its thread
|
|
handler.close()
|
|
handler.thread.join()
|
|
# child duplex is now closed by _forward_logs
|
|
|
|
# simulate supervised_proc._sync_run: proc.join() returned, now stop listener
|
|
listener.stop()
|
|
|
|
test_logger.removeHandler(handler)
|
|
|
|
assert len(received) == NUM_LOGS, (
|
|
f"Expected {NUM_LOGS} records, got {len(received)}. "
|
|
f"Lost {NUM_LOGS - len(received)} tail log records."
|
|
)
|