Files
radare2-r2pipe/python/r2pipe/open_async.py

721 lines
26 KiB
Python

# -*- coding: utf-8 -*-
"""open_async.py
This script use code from r2pipe-async/open_p3.py script.
"""
import asyncio
import json
import os
import re
import sys
from collections.abc import Iterable
from concurrent.futures import ThreadPoolExecutor
from contextlib import ContextDecorator, suppress
from types import SimpleNamespace
from urllib.parse import quote, urlparse
from r2pipe.open_base import OpenBase, get_radare_path, jo2po, normalize_open_target
class open(OpenBase, ContextDecorator):
# --------------------------------------------------------------------------
# Context manager functions
# --------------------------------------------------------------------------
def __enter__(self):
if self._running_loop() is not None:
raise RuntimeError("Use 'async with' for r2pipe inside an event loop")
return self
def __exit__(self, *exc):
if self._running_loop() is not None:
raise RuntimeError("Use 'async with' for r2pipe inside an event loop")
self.close()
return False
async def __aenter__(self):
self._bind_loop()
self._ensure_open()
return self
async def __aexit__(self, *exc):
await self.aclose()
return False
async def _close_all_connections(self):
"""Close all connections in the connection pool"""
async with self._connection_lock:
for connections in self._connection_pool.values():
for _, writer in connections:
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
self._connection_pool = {}
async def _close_process(self):
"""Terminate the async subprocess and close its transport before the loop."""
process = getattr(self, "process", None)
if process is None:
return
stdin = getattr(process, "stdin", None)
if stdin is not None:
with suppress(Exception):
if not stdin.is_closing():
stdin.close()
if process.returncode is None:
with suppress(ProcessLookupError):
process.terminate()
try:
await asyncio.wait_for(process.wait(), timeout=1.0)
except asyncio.TimeoutError:
with suppress(ProcessLookupError):
process.kill()
with suppress(Exception):
await process.wait()
else:
with suppress(Exception):
await process.wait()
transport = getattr(process, "_transport", None)
if transport is not None:
with suppress(Exception):
transport.close()
delattr(self, "process")
self._pending_output = b""
async def _shutdown(self):
"""Tear down all async resources while the loop is still alive."""
try:
if self._command_tasks:
await asyncio.gather(*tuple(self._command_tasks), return_exceptions=True)
async with self._cmd_lock:
await self._close_all_connections()
await self._close_process()
await self._close_native()
finally:
self._closed = True
def _free_native(self):
core = self.__dict__.pop("native", None)
if core is not None:
core.free()
async def _close_native(self):
executor = self._native_executor
if executor is None:
return
try:
await self._loop.run_in_executor(executor, self._free_native)
finally:
# The native call has finished; join the now-idle dedicated worker.
executor.shutdown(wait=True)
self._native_executor = None
def _ensure_open(self):
if self._closed or self._closing or self._loop.is_closed():
raise RuntimeError("r2pipe is closed")
@staticmethod
def _running_loop():
try:
if hasattr(asyncio, "get_running_loop"):
return asyncio.get_running_loop()
loop = asyncio.get_event_loop()
return loop if loop.is_running() else None
except RuntimeError:
return None
def _bind_loop(self):
"""Adopt the caller's loop before first use; transports cannot move loops."""
running = self._running_loop()
if running is not None and running is not self._loop:
if self._owns_loop and not self._used and not self._closing:
self._loop.close()
self._loop = running
self._owns_loop = False
self._cmd_lock = self._new_lock()
self._connection_lock = self._new_lock()
else:
raise RuntimeError("r2pipe belongs to a different event loop")
elif running is None and not self._owns_loop and not self._closed:
raise RuntimeError("Use r2pipe from its owning event loop")
return running
def _dispatch(self, coroutine, *args):
running = self._bind_loop()
self._ensure_open()
if running is not None:
return coroutine(*args)
return self._loop.run_until_complete(coroutine(*args))
async def aclose(self):
"""Finish in-flight commands and close resources without closing the loop."""
if self._closed:
return
self._bind_loop()
if self._shutdown_task is None:
self._closing = True
self._shutdown_task = self._loop.create_task(self._shutdown())
canceled = False
while True:
try:
await asyncio.shield(self._shutdown_task)
break
except asyncio.CancelledError:
if self._shutdown_task.cancelled():
raise
# Repeated cancellation must still let resource cleanup finish.
canceled = True
if canceled:
raise asyncio.CancelledError
def close(self):
"""Close synchronously, or return an awaitable inside an event loop."""
if self._closed:
return self.aclose() if self._running_loop() is not None else None
running = self._bind_loop()
if running is not None:
return self.aclose()
try:
self._loop.run_until_complete(self.aclose())
finally:
if self._owns_loop:
self._loop.close()
def quit(self):
"""Use this client's event loop to finish the asynchronous shutdown."""
return self.close()
def _new_lock(self):
if sys.version_info < (3, 10):
return asyncio.Lock(loop=self._loop)
return asyncio.Lock()
def __init__(self, filename="", flags=None, radare2home=None):
filename, file_args = normalize_open_target(filename)
super().__init__(filename, flags)
if flags is None:
flags = []
self.r2home = radare2home
self._loop = self._running_loop()
self._owns_loop = self._loop is None
if self._owns_loop and os.name == "nt":
self._loop = asyncio.ProactorEventLoop()
asyncio.set_event_loop(self._loop)
elif self._owns_loop:
self._loop = asyncio.new_event_loop()
if sys.version_info < (3, 12) and hasattr(asyncio, "get_child_watcher"):
try:
watcher = asyncio.get_child_watcher()
watcher.attach_loop(self._loop)
except RuntimeError:
pass
# Add a lock for synchronizing command execution
self._cmd_lock = self._new_lock()
self._next_cmd_id = 0
self._command_tasks = set()
self._used = False
self._closing = False
self._shutdown_task = None
self._native_executor = None
# Add connection management for HTTP/TCP modes
self._connection_pool = {}
self._pool_size = 5 # Maximum number of concurrent connections
self._connection_lock = self._new_lock()
self._closed = False
self.asyn = True
if "_cmd" in self.__dict__:
# OpenBase selected the embedded #!pipe or r2lang transport.
self.asyn = False
return
if filename.startswith("http://"):
self._cmd_coro = self._cmd_http
self.uri = "/cmd"
_tmp = urlparse(filename)
self._host = _tmp.hostname
self._port = _tmp.port
elif filename.startswith("ccall://"):
self._cmd_coro = self._cmd_native_async
self.uri = filename[8:]
elif filename.startswith("tcp://"):
r = re.match(r"tcp://(\d+\.\d+.\d+.\d+):(\d+)/?", filename)
if not r:
raise ValueError("String doesn't match tcp format")
self._cmd_coro = self._cmd_tcp
self._host = r.group(1)
self._port = r.group(2)
elif filename:
self._cmd_coro = self._cmd_process
r_args = [f"-Rarg{i+1}={a}" for i, a in enumerate(file_args)]
cmd = ["-q0", filename]
cmd = cmd[:1] + flags + r_args + cmd[1:]
self._process_start_cmd = cmd
else:
self.asyn = False
async def _execute_cmd_with_lock(self, cmd, cmd_id, callback, started):
"""Execute command with proper synchronization"""
async with self._cmd_lock:
self._ensure_open()
started[0] = True
self._used = True
result = await self._cmd_coro(cmd, cmd_id)
if callback is not None:
try:
callback(result)
except Exception as error:
self._loop.call_exception_handler({
"message": "Exception in r2pipe command callback",
"exception": error,
})
return result
def _cmd(self, cmd, **kwargs):
return self._dispatch(self._command, cmd, kwargs)
async def _command(self, cmd, kwargs):
self._bind_loop()
self._ensure_open()
callback = kwargs.get("callback")
cmd_id = self._next_cmd_id
self._next_cmd_id += 1
started = [False]
task = self._loop.create_task(
self._execute_cmd_with_lock(cmd, cmd_id, callback, started)
)
self._command_tasks.add(task)
task.add_done_callback(self._command_done)
try:
# A canceled caller must not interrupt reading a command's response.
return await asyncio.shield(task)
except asyncio.CancelledError:
if not started[0]:
task.cancel()
raise
def _command_done(self, task):
self._command_tasks.discard(task)
self._consume_task_error(task)
@staticmethod
def _consume_task_error(task):
if not task.cancelled():
# Retrieve errors even when a canceled caller no longer awaits the task.
task.exception()
async def _json_command(self, cmd, kwargs, object_hook=None, report_error=False):
result = await self._command(cmd, kwargs)
if report_error and result == "":
result = "{}"
try:
return json.loads(result, object_hook=object_hook)
except (ValueError, KeyError, TypeError) as error:
if not report_error:
raise
sys.stderr.write(f"r2pipe.cmdj.Error: {error}\n")
return None
def cmdj(self, cmd, **kwargs):
if not self.asyn:
return super().cmdj(cmd, **kwargs)
return self._dispatch(self._json_command, cmd, kwargs, None, True)
async def _object_command(self, cmd, kwargs):
result = await self._command(cmd, kwargs)
try:
return jo2po(result)
except (ValueError, KeyError, TypeError) as error:
sys.stderr.write(f"r2pipe.cmdj.Error: {error}\n")
return None
def cmdJ(self, cmd, **kwargs):
if not self.asyn:
return super().cmdJ(cmd, **kwargs)
return self._dispatch(self._object_command, cmd, kwargs)
def _r2pipe2(self, cmd, kwargs):
request, transport_kwargs = self._r2pipe2_request(cmd, kwargs)
cmd = "'" + json.dumps(request)
return self._dispatch(
self._json_command, cmd, transport_kwargs, lambda value: SimpleNamespace(**value)
)
def cmd2(self, cmd, **kwargs):
if not self.asyn:
return super().cmd2(cmd, **kwargs)
return self._r2pipe2(cmd, kwargs)
async def _cmd_native_async(self, cmd, _cmd_id):
if self._native_executor is None:
self._native_executor = ThreadPoolExecutor(max_workers=1)
return await self._loop.run_in_executor(
self._native_executor, super()._cmd_native, cmd
)
async def _cmd_process(self, cmd, _cmd_id):
# Process initialization if needed
if not hasattr(self, "process"):
if self.r2home is not None:
if not os.path.isdir(self.r2home):
raise Exception(
"`radare2home` passed to `open` is invalid, leave it None or put a valid path to r2 folder"
)
r2path = os.path.join(self.r2home, "radare2")
if os.name == "nt":
r2path += ".exe"
else:
r2path = get_radare_path()
create = asyncio.create_subprocess_exec(
r2path,
*self._process_start_cmd,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
)
self.process = await create # Init the process
# Track command execution state
if not hasattr(self, "_pending_output"):
self._pending_output = b""
await self.process.stdout.read(1) # Reads initial \x00
# Clean command formatting
cmd = cmd.strip().replace("\n", ";")
# Write command to stdin
await self.process.stdin.drain() # Ensure any previous writes are complete
self.process.stdin.write(bytes(cmd + "\n", "utf-8"))
await self.process.stdin.drain() # Ensure this write is complete
# Read response with proper handling of pending data
out = []
# First check if we have pending output from previous commands
if hasattr(self, "_pending_output") and self._pending_output:
# Check if pending output contains a complete command response
null_pos = self._pending_output.find(b"\x00")
if null_pos != -1:
# Extract the complete response and update pending
response = self._pending_output[:null_pos]
self._pending_output = self._pending_output[null_pos+1:]
return response.decode("utf-8")
# Add pending output to our current response
out.append(self._pending_output)
self._pending_output = b""
# Read response byte by byte until null terminator
buffer_size = 1024 # Read in larger chunks for efficiency
while True:
data = await self.process.stdout.read(buffer_size)
if not data:
raise ConnectionError("r2pipe connection closed before response terminator")
null_pos = data.find(b"\x00")
if null_pos != -1:
# Found the terminator
out.append(data[:null_pos])
# Save any data after the null for future commands
if null_pos < len(data) - 1:
self._pending_output = data[null_pos+1:]
break
else:
out.append(data)
# Join the output and convert to string
return b"".join(out).decode("utf-8")
async def _get_http_connection(self):
"""Get a connection from the pool or create a new one"""
async with self._connection_lock:
# Create a connection pool key
pool_key = f"{self._host}:{self._port}"
# Get or create the connection queue
if pool_key not in self._connection_pool:
self._connection_pool[pool_key] = []
# Check if we have an available connection
if self._connection_pool[pool_key]:
# Reuse existing connection
while self._connection_pool[pool_key]:
reader, writer = self._connection_pool[pool_key].pop(0)
if not reader.at_eof() and not writer.is_closing():
return reader, writer
writer.close()
with suppress(Exception):
await writer.wait_closed()
# Create a new connection
return await asyncio.open_connection(
self._host, self._port
)
async def _return_http_connection(self, reader, writer):
"""Return a connection to the pool"""
if writer.is_closing():
return
async with self._connection_lock:
pool_key = f"{self._host}:{self._port}"
# Only keep connections if within pool size limit
if pool_key in self._connection_pool and len(self._connection_pool[pool_key]) < self._pool_size:
self._connection_pool[pool_key].append((reader, writer))
else:
# Close the connection if pool is full
writer.close()
await writer.wait_closed()
async def _cmd_http(self, cmd, cmd_id):
"""HTTP command execution with connection pooling and request ID tracking"""
writer = None
pooled = False
try:
quocmd = quote(cmd)
# Get a connection from the pool
reader, writer = await self._get_http_connection()
# Create HTTP request with request ID tracking
message = "\r\n".join(
[
"GET /cmd/%s HTTP/1.1" % quocmd,
"Host: %s:%s" % (self._host, self._port),
"User-Agent: r2pipe/Python Client",
"Accept: */*",
"X-R2Pipe-Request-ID: %s" % cmd_id,
"Connection: keep-alive", # Keep connection alive for pooling
"",
"",
]
).encode()
# Send the request
writer.write(message)
await writer.drain() # Ensure the request is fully sent
# Read response with timeout to prevent hanging
try:
# Use a timeout to prevent hanging on incomplete responses
body = []
# Read response with proper chunking
buffer_size = 4096 # Larger buffer size for efficiency
# Header boundaries can span multiple transport reads.
data = await asyncio.wait_for(
reader.readuntil(b'\r\n\r\n'),
timeout=10.0
)
header_lines = data[:-4].decode('utf-8', errors='ignore').split('\r\n')
content_length = None
connection = ""
for line in header_lines[1:]:
if line.lower().startswith('content-length:'):
content_length = int(line.split(':', 1)[1].strip())
if content_length < 0:
raise ValueError("Invalid HTTP Content-Length")
elif line.lower().startswith('connection:'):
connection = line.split(':', 1)[1].strip().lower()
keep_alive = content_length is not None and "close" not in connection and (
header_lines[0].startswith("HTTP/1.1 ") or "keep-alive" in connection
)
if content_length is not None:
remaining = content_length
while remaining > 0:
chunk = await asyncio.wait_for(
reader.read(min(buffer_size, remaining)),
timeout=5.0
)
if not chunk:
raise ConnectionError("Incomplete HTTP response body")
body.append(chunk)
remaining -= len(chunk)
else:
# Responses without a length are delimited by connection close.
while True:
chunk = await asyncio.wait_for(
reader.read(buffer_size),
timeout=5.0
)
if not chunk:
break
body.append(chunk)
# Reuse only responses whose framing and headers allow persistence.
if keep_alive and not reader.at_eof():
await self._return_http_connection(reader, writer)
pooled = True
except asyncio.TimeoutError as error:
# If timeout occurs, close the connection
writer.close()
raise TimeoutError("Timeout while reading HTTP response") from error
body = b"".join(body)
return body.decode("utf-8", errors="ignore")
finally:
if writer is not None and not pooled:
writer.close()
with suppress(Exception):
await writer.wait_closed()
async def _get_tcp_connection(self):
"""Get a TCP connection from the pool or create a new one"""
async with self._connection_lock:
# Create a connection pool key
pool_key = f"tcp:{self._host}:{self._port}"
# Get or create the connection queue
if pool_key not in self._connection_pool:
self._connection_pool[pool_key] = []
# Check if we have an available connection
if self._connection_pool[pool_key]:
# Reuse existing connection
reader, writer = self._connection_pool[pool_key].pop(0)
return reader, writer
# Create a new connection
return await asyncio.open_connection(
self._host, self._port
)
async def _return_tcp_connection(self, reader, writer):
"""Return a TCP connection to the pool"""
if writer.is_closing():
return
async with self._connection_lock:
pool_key = f"tcp:{self._host}:{self._port}"
# Only keep connections if within pool size limit
if pool_key in self._connection_pool and len(self._connection_pool[pool_key]) < self._pool_size:
self._connection_pool[pool_key].append((reader, writer))
else:
# Close the connection if pool is full
writer.close()
await writer.wait_closed()
async def _cmd_tcp(self, cmd, _cmd_id):
"""TCP command execution with connection pooling"""
writer = None
pooled = False
try:
# Get a connection from the pool
reader, writer = await self._get_tcp_connection()
writer.write(cmd.encode("utf-8"))
# Make sure the command is sent
await writer.drain()
try:
# Read with proper timeout handling
res = []
buffer_size = 4096 # Larger buffer for efficiency
# Read data with timeout
first_chunk = await asyncio.wait_for(
reader.read(buffer_size),
timeout=10.0
)
if not first_chunk:
# Connection closed, create new one next time
writer.close()
raise ConnectionError("TCP connection closed by server")
res.append(first_chunk)
# Continue reading until we get all data
# TCP mode indicates end of data by closing the connection
# or by having no more data available after a timeout
try:
while not reader.at_eof():
chunk = await asyncio.wait_for(
reader.read(buffer_size),
timeout=1.0 # Short timeout to detect end of response
)
if not chunk:
break
res.append(chunk)
except asyncio.TimeoutError:
# A short timeout here means we've likely read everything
pass
# TCP connections are often single-use in r2pipe
# But we'll attempt to keep them alive for reuse
if not reader.at_eof():
await self._return_tcp_connection(reader, writer)
pooled = True
else:
# Connection was closed by server, close our end too
writer.close()
except asyncio.TimeoutError as error:
# If we timeout on the initial read, something is wrong
writer.close()
raise TimeoutError("Timeout while reading TCP response") from error
# Process the response
response_data = b"".join(res)
return response_data.decode("utf-8", errors="ignore")
finally:
if writer is not None and not pooled:
writer.close()
with suppress(Exception):
await writer.wait_closed()
def wait(self, task):
"""Wait for awaitables and return completed futures in input order."""
tasks = list(task) if isinstance(task, Iterable) else [task]
return self._dispatch(self._wait, tasks)
async def _wait(self, tasks):
self._bind_loop()
self._ensure_open()
futures = []
for task in tasks:
future = asyncio.ensure_future(task, loop=self._loop)
# A canceled wait() caller no longer receives these wrapper tasks.
future.add_done_callback(self._consume_task_error)
futures.append(future)
if futures:
await asyncio.wait(futures)
return futures