"""Bi-directional asyncio.Transport implementations based on file descriptors."""
import asyncio
import collections
import ctypes
import errno
import fcntl
import logging
import os
import select
import signal
import struct
import subprocess
import termios
from threading import Thread
from typing import Any, ClassVar, Sequence
from .jsonutil import JsonObject, get_int
sys_prctl = ctypes.CDLL(None).prctl
def prctl(*args: int) -> None:
if sys_prctl(*args) != 0:
raise OSError('prctl() failed')
SET_PDEATHSIG = 1
logger = logging.getLogger(__name__)
IOV_MAX = 1024
class _Transport(asyncio.Transport):
BLOCK_SIZE: ClassVar[int] = 1024 * 1024
_loop: asyncio.AbstractEventLoop
_protocol: asyncio.Protocol
_queue: 'collections.deque[bytes] | None'
_in_fd: int
_out_fd: int
_closing: bool
_is_reading: bool
_eof: bool
_eio_is_eof: bool = False
def __init__(self,
loop: asyncio.AbstractEventLoop,
protocol: asyncio.Protocol,
in_fd: int = -1, out_fd: int = -1,
extra: 'dict[str, object] | None' = None):
super().__init__(extra)
self._loop = loop
self._protocol = protocol
logger.debug('Created transport %s for protocol %s, fds %d %d', self, protocol, in_fd, out_fd)
self._queue = None
self._is_reading = False
self._eof = False
self._closing = False
self._in_fd = in_fd
self._out_fd = out_fd
os.set_blocking(in_fd, False)
if out_fd != in_fd:
os.set_blocking(out_fd, False)
self._protocol.connection_made(self)
self.resume_reading()
def _read_ready(self) -> None:
logger.debug('Read ready on %s %s %d', self, self._protocol, self._in_fd)
try:
data = os.read(self._in_fd, _Transport.BLOCK_SIZE)
except BlockingIOError:
return
except OSError as exc:
if self._eio_is_eof and exc.errno == errno.EIO:
data = b''
else:
self.abort(exc)
return
if data != b'':
logger.debug(' read %d bytes', len(data))
self._protocol.data_received(data)
else:
logger.debug(' got EOF')
self._close_reader()
keep_open = self._protocol.eof_received()
if not keep_open:
self.close()
def is_reading(self) -> bool:
return self._is_reading
def _close_reader(self) -> None:
self.pause_reading()
self._in_fd = -1
def pause_reading(self) -> None:
if self._is_reading:
self._loop.remove_reader(self._in_fd)
self._is_reading = False
def resume_reading(self) -> None:
if not self._is_reading and self._in_fd != -1:
self._loop.add_reader(self._in_fd, self._read_ready)
self._is_reading = True
def _close(self) -> None:
pass
def abort(self, exc: 'Exception | None' = None) -> None:
self._closing = True
self._close_reader()
self._remove_write_queue()
self._loop.call_soon(self._protocol.connection_lost, exc)
self._close()
def can_write_eof(self) -> bool:
raise NotImplementedError
def write_eof(self) -> None:
assert not self._eof
self._eof = True
if self._queue is None:
logger.debug('%s got EOF. closing backend.', self)
self._write_eof_now()
else:
logger.debug('%s got EOF. bytes in queue, deferring close', self)
def get_write_buffer_size(self) -> int:
if self._queue is None:
return 0
return sum(len(block) for block in self._queue)
def get_write_buffer_limits(self) -> 'tuple[int, int]':
return (0, 0)
def set_write_buffer_limits(self, high: 'int | None' = None, low: 'int | None' = None) -> None:
assert high is None or high == 0
assert low is None or low == 0
def _write_eof_now(self) -> None:
raise NotImplementedError
def _write_ready(self) -> None:
logger.debug('%s _write_ready', self)
assert self._queue is not None
try:
n_bytes = os.writev(self._out_fd, self._queue)
except BlockingIOError:
n_bytes = 0
except OSError as exc:
self.abort(exc)
return
logger.debug(' successfully wrote %d bytes from the queue', n_bytes)
while n_bytes:
block = self._queue.popleft()
if len(block) > n_bytes:
logger.debug(' incomplete block. Stop.')
self._queue.appendleft(block[n_bytes:])
break
n_bytes -= len(block)
logger.debug(' removed complete block. %d remains.', n_bytes)
if not self._queue:
logger.debug('%s queue drained.')
self._remove_write_queue()
if self._eof:
logger.debug('%s queue drained. closing backend now.')
self._write_eof_now()
if self._closing:
self.abort()
def _remove_write_queue(self) -> None:
if self._queue is not None:
self._protocol.resume_writing()
self._loop.remove_writer(self._out_fd)
self._queue = None
def _create_write_queue(self, data: bytes) -> None:
logger.debug('%s creating write queue for fd %s', self, self._out_fd)
assert self._queue is None
self._loop.add_writer(self._out_fd, self._write_ready)
self._queue = collections.deque((data,))
self._protocol.pause_writing()
def write(self, data: bytes) -> None:
if self._closing:
logger.debug('ignoring write() to closing transport fd %i', self._out_fd)
return
assert not self._eof
if self._queue is not None:
self._queue.append(data)
if len(self._queue) > IOV_MAX:
all_data = b''.join(self._queue)
self._queue.clear()
self._queue.append(all_data)
return
try:
n_bytes = os.write(self._out_fd, data)
except BlockingIOError:
n_bytes = 0
except OSError as exc:
self.abort(exc)
return
if n_bytes != len(data):
self._create_write_queue(data[n_bytes:])
def close(self) -> None:
if self._closing:
return
self._closing = True
self._close_reader()
if self._queue is not None:
return
self.abort()
def get_protocol(self) -> asyncio.BaseProtocol:
return self._protocol
def is_closing(self) -> bool:
return self._closing
def set_protocol(self, protocol: asyncio.BaseProtocol) -> None:
raise NotImplementedError
def __del__(self) -> None:
self._close()
class SubprocessProtocol(asyncio.Protocol):
"""An extension to asyncio.Protocol for use with SubprocessTransport."""
def process_exited(self) -> None:
"""Called when subprocess has exited."""
raise NotImplementedError
class WindowSize:
def __init__(self, value: JsonObject):
self.rows = get_int(value, 'rows')
self.cols = get_int(value, 'cols')
class SubprocessTransport(_Transport, asyncio.SubprocessTransport):
"""A bi-directional transport speaking with stdin/out of a subprocess.
Note: this is not really a normal SubprocessTransport. Although it
implements the entire API of asyncio.SubprocessTransport, it is not
designed to be used with asyncio.SubprocessProtocol objects. Instead, it
pair with normal Protocol objects which also implement the
SubprocessProtocol defined in this module (which only has a
process_exited() method). Whatever the protocol writes is sent to stdin,
and whatever comes from stdout is given to the Protocol via the
.data_received() function.
If stderr is configured as a pipe, the transport will separately collect
data from it, making it available via the .get_stderr() method.
"""
_pty_fd: 'int | None' = None
_process: 'subprocess.Popen[bytes] | None' = None
_returncode: 'int | None' = None
_stderr: 'Spooler | None'
def get_stderr(self, *, reset: bool = False) -> str:
if self._stderr is not None:
return self._stderr.get(reset=reset).decode(errors='replace')
else:
return ''
def watch_exit(self, process: 'subprocess.Popen[bytes]') -> None:
def child_exited(pid: int, status: int) -> None:
assert pid == process.pid
assert isinstance(self._protocol, SubprocessProtocol)
try:
self._returncode = os.waitstatus_to_exitcode(status)
except ValueError:
self._returncode = status
logger.debug('Process exited with status %d', self._returncode)
if not self._closing:
self._protocol.process_exited()
def pidfd_ready() -> None:
pid, status = os.waitpid(process.pid, 0)
self._loop.remove_reader(pidfd)
os.close(pidfd)
child_exited(pid, status)
def waitpid_thread() -> None:
pid, status = os.waitpid(process.pid, 0)
self._loop.call_soon_threadsafe(child_exited, pid, status)
try:
pidfd = os.pidfd_open(process.pid)
self._loop.add_reader(pidfd, pidfd_ready)
except (AttributeError, OSError):
Thread(name=f'cockpit-waitpid-{process.pid}', target=waitpid_thread, daemon=True).start()
def __init__(self,
loop: asyncio.AbstractEventLoop,
protocol: SubprocessProtocol,
args: Sequence[str],
*,
pty: bool = False,
window: 'WindowSize | None' = None,
**kwargs: Any):
def preexec_fn() -> None:
prctl(SET_PDEATHSIG, signal.SIGTERM)
if pty:
fcntl.ioctl(0, termios.TIOCSCTTY, 0)
if pty:
self._pty_fd, session_fd = os.openpty()
if window is not None:
self.set_window_size(window)
kwargs['stderr'] = session_fd
self._process = subprocess.Popen(args,
stdin=session_fd, stdout=session_fd,
preexec_fn=preexec_fn, start_new_session=True, **kwargs)
os.close(session_fd)
in_fd, out_fd = self._pty_fd, self._pty_fd
self._eio_is_eof = True
else:
self._process = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
preexec_fn=preexec_fn, **kwargs)
assert self._process.stdin
assert self._process.stdout
in_fd = self._process.stdout.fileno()
out_fd = self._process.stdin.fileno()
if self._process.stderr is not None:
self._stderr = Spooler(loop, self._process.stderr.fileno())
else:
self._stderr = None
super().__init__(loop, protocol, in_fd, out_fd)
self.watch_exit(self._process)
def set_window_size(self, size: WindowSize) -> None:
assert self._pty_fd is not None
rows = max(0, min(size.rows, 2**16 - 1))
cols = max(0, min(size.cols, 2**16 - 1))
fcntl.ioctl(self._pty_fd, termios.TIOCSWINSZ, struct.pack('2H4x', rows, cols))
def can_write_eof(self) -> bool:
assert self._process is not None
return self._process.stdin is not None
def _write_eof_now(self) -> None:
assert self._process is not None
assert self._process.stdin is not None
self._process.stdin.close()
self._out_fd = -1
def get_pid(self) -> int:
assert self._process is not None
return self._process.pid
def get_returncode(self) -> 'int | None':
return self._returncode
def get_pipe_transport(self, fd: int) -> asyncio.Transport:
raise NotImplementedError
def send_signal(self, sig: signal.Signals) -> None:
assert self._process is not None
if self._returncode is not None:
logger.debug("won't attempt %s to process %i. It exited already.", sig, self._process.pid)
return
try:
os.kill(self._process.pid, sig)
logger.debug('sent %s to process %i', sig, self._process.pid)
except ProcessLookupError:
logger.debug("can't send %s to process %i. It's exited just now.", sig, self._process.pid)
def terminate(self) -> None:
self.send_signal(signal.SIGTERM)
def kill(self) -> None:
self.send_signal(signal.SIGKILL)
def _close(self) -> None:
if self._pty_fd is not None:
os.close(self._pty_fd)
self._pty_fd = None
if self._process is not None:
if self._process.stdin is not None:
self._process.stdin.close()
self._process.stdin = None
try:
self.terminate()
except PermissionError:
logger.debug("can't kill %i due to EPERM", self._process.pid)
class StdioTransport(_Transport):
"""A bi-directional transport that corresponds to stdin/out.
Can talk to just about anything:
- files
- pipes
- character devices (including terminals)
- sockets
"""
def __init__(self, loop: asyncio.AbstractEventLoop, protocol: asyncio.Protocol, stdin: int = 0, stdout: int = 1):
super().__init__(loop, protocol, stdin, stdout)
def can_write_eof(self) -> bool:
return False
def _write_eof_now(self) -> None:
raise RuntimeError("Can't write EOF to stdout")
class Spooler:
"""Consumes data from an fd, storing it in a buffer.
This makes a copy of the fd, so you don't have to worry about holding it
open.
"""
_loop: asyncio.AbstractEventLoop
_fd: int
_contents: 'list[bytes]'
def __init__(self, loop: asyncio.AbstractEventLoop, fd: int):
self._loop = loop
self._fd = -1
self._contents = []
self._fd = os.dup(fd)
os.set_blocking(self._fd, False)
loop.add_reader(self._fd, self._read_ready)
def _read_ready(self) -> None:
try:
data = os.read(self._fd, 8192)
except BlockingIOError:
return
except OSError:
data = b''
if data != b'':
self._contents.append(data)
else:
self.close()
def _is_ready(self) -> bool:
if self._fd == -1:
return False
return select.select([self._fd], [], [], 0) != ([], [], [])
def get(self, *, reset: bool = False) -> bytes:
while self._is_ready():
self._read_ready()
result = b''.join(self._contents)
if reset:
self._contents = []
return result
def close(self) -> None:
if self._fd != -1:
self._loop.remove_reader(self._fd)
os.close(self._fd)
self._fd = -1
def __del__(self) -> None:
self.close()