559 lines
20 KiB
Python
559 lines
20 KiB
Python
# SPDX-FileCopyrightText: Florian Bruhin (The Compiler) <mail@qutebrowser.org>
|
|
#
|
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
"""Utilities for IPC with existing instances."""
|
|
|
|
import os
|
|
import time
|
|
import json
|
|
import getpass
|
|
import binascii
|
|
import hashlib
|
|
from typing import Optional
|
|
|
|
from qutebrowser.qt.core import pyqtSignal, pyqtSlot, QObject, Qt
|
|
from qutebrowser.qt.network import QLocalSocket, QLocalServer, QAbstractSocket
|
|
|
|
import qutebrowser
|
|
from qutebrowser.utils import log, usertypes, error, standarddir, utils, debug, qtutils
|
|
from qutebrowser.qt import sip
|
|
|
|
|
|
CONNECT_TIMEOUT = 100 # timeout for connecting/disconnecting
|
|
WRITE_TIMEOUT = 1000
|
|
READ_TIMEOUT = 5000
|
|
ATIME_INTERVAL = 5000 * 60 # 5 minutes
|
|
PROTOCOL_VERSION = 1
|
|
|
|
|
|
# The ipc server instance
|
|
server: Optional["IPCServer"] = None
|
|
|
|
|
|
def _get_socketname_windows(basedir):
|
|
"""Get a socketname to use for Windows."""
|
|
try:
|
|
username = getpass.getuser()
|
|
except ImportError:
|
|
# getpass.getuser() first tries a couple of environment variables. If
|
|
# none of those are set (i.e., USERNAME is missing), it tries to import
|
|
# the "pwd" module which is unavailable on Windows.
|
|
raise Error("Could not find username. This should only happen if "
|
|
"there is a bug in the application launching qutebrowser, "
|
|
"preventing the USERNAME environment variable from being "
|
|
"passed. If you know more about when this happens, please "
|
|
"report this to mail@qutebrowser.org.")
|
|
|
|
parts = ['qutebrowser', username]
|
|
if basedir is not None:
|
|
md5 = hashlib.md5(basedir.encode('utf-8')).hexdigest()
|
|
parts.append(md5)
|
|
return '-'.join(parts)
|
|
|
|
|
|
def _get_socketname(basedir):
|
|
"""Get a socketname to use."""
|
|
if utils.is_windows: # pragma: no cover
|
|
return _get_socketname_windows(basedir)
|
|
|
|
parts_to_hash = [getpass.getuser()]
|
|
if basedir is not None:
|
|
parts_to_hash.append(basedir)
|
|
|
|
data_to_hash = '-'.join(parts_to_hash).encode('utf-8')
|
|
md5 = hashlib.md5(data_to_hash).hexdigest()
|
|
|
|
prefix = 'i-' if utils.is_mac else 'ipc-'
|
|
filename = '{}{}'.format(prefix, md5)
|
|
return os.path.join(standarddir.runtime(), filename)
|
|
|
|
|
|
class Error(Exception):
|
|
|
|
"""Base class for IPC exceptions."""
|
|
|
|
|
|
class SocketError(Error):
|
|
|
|
"""Exception raised when there was an error with a QLocalSocket.
|
|
|
|
Args:
|
|
code: The error code.
|
|
message: The error message.
|
|
action: The action which was taken when the error happened.
|
|
"""
|
|
|
|
def __init__(self, action, socket):
|
|
"""Constructor.
|
|
|
|
Args:
|
|
action: The action which was taken when the error happened.
|
|
socket: The QLocalSocket which has the error set.
|
|
"""
|
|
super().__init__()
|
|
self.action = action
|
|
self.code: QLocalSocket.LocalSocketError = socket.error()
|
|
self.message: str = socket.errorString()
|
|
|
|
def __str__(self):
|
|
return "Error while {}: {} ({})".format(
|
|
self.action, self.message, debug.qenum_key(QLocalSocket, self.code))
|
|
|
|
|
|
class ListenError(Error):
|
|
|
|
"""Exception raised when there was a problem with listening to IPC.
|
|
|
|
Args:
|
|
code: The error code.
|
|
message: The error message.
|
|
"""
|
|
|
|
def __init__(self, local_server):
|
|
"""Constructor.
|
|
|
|
Args:
|
|
local_server: The QLocalServer which has the error set.
|
|
"""
|
|
super().__init__()
|
|
self.code: QAbstractSocket.SocketError = local_server.serverError()
|
|
self.message: str = local_server.errorString()
|
|
|
|
def __str__(self):
|
|
return "Error while listening to IPC server: {} ({})".format(
|
|
self.message, debug.qenum_key(QAbstractSocket, self.code))
|
|
|
|
|
|
class AddressInUseError(ListenError):
|
|
|
|
"""Emitted when the server address is already in use."""
|
|
|
|
|
|
class IPCServer(QObject):
|
|
|
|
"""IPC server to which clients connect to.
|
|
|
|
Attributes:
|
|
ignored: Whether requests are ignored (in exception hook).
|
|
_timer: A timer to handle timeouts.
|
|
_server: A QLocalServer to accept new connections.
|
|
_socket: The QLocalSocket we're currently connected to.
|
|
_socketname: The socketname to use.
|
|
_atime_timer: Timer to update the atime of the socket regularly.
|
|
|
|
Signals:
|
|
got_args: Emitted when there was an IPC connection and arguments were
|
|
passed.
|
|
got_args: Emitted with the raw data an IPC connection got.
|
|
got_invalid_data: Emitted when there was invalid incoming data.
|
|
"""
|
|
|
|
got_args = pyqtSignal(list, str, str)
|
|
got_raw = pyqtSignal(bytes)
|
|
got_invalid_data = pyqtSignal()
|
|
|
|
def __init__(self, socketname, parent=None):
|
|
"""Start the IPC server and listen to commands.
|
|
|
|
Args:
|
|
socketname: The socketname to use.
|
|
parent: The parent to be used.
|
|
"""
|
|
super().__init__(parent)
|
|
self.ignored = False
|
|
self._socketname = socketname
|
|
|
|
self._timer = usertypes.Timer(self, 'ipc-timeout')
|
|
self._timer.setInterval(READ_TIMEOUT)
|
|
self._timer.timeout.connect(self.on_timeout)
|
|
|
|
if utils.is_windows: # pragma: no cover
|
|
self._atime_timer = None
|
|
else:
|
|
self._atime_timer = usertypes.Timer(self, 'ipc-atime')
|
|
self._atime_timer.setInterval(ATIME_INTERVAL)
|
|
self._atime_timer.timeout.connect(self.update_atime)
|
|
self._atime_timer.setTimerType(Qt.TimerType.VeryCoarseTimer)
|
|
|
|
self._server: QLocalServer | None = QLocalServer(self)
|
|
self._server.newConnection.connect(self.handle_connection)
|
|
|
|
self._socket = None
|
|
self._old_socket = None
|
|
|
|
if utils.is_windows: # pragma: no cover
|
|
# As a WORKAROUND for a Qt bug, we can't use UserAccessOption on Unix. If we
|
|
# do, we don't get an AddressInUseError anymore:
|
|
# https://bugreports.qt.io/browse/QTBUG-48635
|
|
#
|
|
# Thus, we only do so on Windows, and handle permissions manually in
|
|
# listen() on Linux.
|
|
log.ipc.debug("Calling setSocketOptions")
|
|
self._server.setSocketOptions(QLocalServer.SocketOption.UserAccessOption)
|
|
else: # pragma: no cover
|
|
log.ipc.debug("Not calling setSocketOptions")
|
|
|
|
def _remove_server(self):
|
|
"""Remove an existing server."""
|
|
ok = QLocalServer.removeServer(self._socketname)
|
|
if not ok:
|
|
raise Error("Error while removing server {}!".format(
|
|
self._socketname))
|
|
|
|
def listen(self):
|
|
"""Start listening on self._socketname."""
|
|
assert self._server is not None
|
|
log.ipc.debug("Listening as {}".format(self._socketname))
|
|
if self._atime_timer is not None: # pragma: no branch
|
|
self._atime_timer.start()
|
|
self._remove_server()
|
|
ok = self._server.listen(self._socketname)
|
|
if not ok:
|
|
if self._server.serverError() == QAbstractSocket.SocketError.AddressInUseError:
|
|
raise AddressInUseError(self._server)
|
|
raise ListenError(self._server)
|
|
|
|
if not utils.is_windows: # pragma: no cover
|
|
# WORKAROUND for QTBUG-48635, see the comment in __init__ for details.
|
|
try:
|
|
os.chmod(self._server.fullServerName(), 0o700)
|
|
except FileNotFoundError:
|
|
# https://github.com/qutebrowser/qutebrowser/issues/1530
|
|
# The server doesn't actually exist even if ok was reported as
|
|
# True, so report this as an error.
|
|
raise ListenError(self._server)
|
|
|
|
@pyqtSlot('QLocalSocket::LocalSocketError')
|
|
def on_error(self, err):
|
|
"""Raise SocketError on fatal errors."""
|
|
if self._socket is None:
|
|
# Sometimes this gets called from stale sockets.
|
|
log.ipc.debug("In on_error with None socket!")
|
|
return
|
|
self._timer.stop()
|
|
log.ipc.debug("Socket 0x{:x}: error {}: {}".format(
|
|
id(self._socket), self._socket.error(),
|
|
self._socket.errorString()))
|
|
if err != QLocalSocket.LocalSocketError.PeerClosedError:
|
|
raise SocketError("handling IPC connection", self._socket)
|
|
|
|
@pyqtSlot()
|
|
def handle_connection(self):
|
|
"""Handle a new connection to the server."""
|
|
if self.ignored or self._server is None:
|
|
return
|
|
if self._socket is not None:
|
|
log.ipc.debug("Got new connection but ignoring it because we're "
|
|
"still handling another one (0x{:x}).".format(
|
|
id(self._socket)))
|
|
return
|
|
socket = qtutils.add_optional(self._server.nextPendingConnection())
|
|
if socket is None:
|
|
log.ipc.debug("No new connection to handle.")
|
|
return
|
|
log.ipc.debug("Client connected (socket 0x{:x}).".format(id(socket)))
|
|
self._socket = socket
|
|
self._timer.start()
|
|
socket.readyRead.connect(self.on_ready_read)
|
|
if socket.canReadLine():
|
|
log.ipc.debug("We can read a line immediately.")
|
|
self.on_ready_read()
|
|
|
|
socket.errorOccurred.connect(self.on_error)
|
|
|
|
# FIXME:v4 Ignore needed due to overloaded signal/method in Qt 5
|
|
socket_error = socket.error() # type: ignore[operator,unused-ignore]
|
|
if socket_error not in [
|
|
QLocalSocket.LocalSocketError.UnknownSocketError,
|
|
QLocalSocket.LocalSocketError.PeerClosedError
|
|
]:
|
|
log.ipc.debug("We got an error immediately.")
|
|
self.on_error(socket_error)
|
|
|
|
socket.disconnected.connect(self.on_disconnected)
|
|
if socket.state() == QLocalSocket.LocalSocketState.UnconnectedState:
|
|
log.ipc.debug("Socket was disconnected immediately.")
|
|
self.on_disconnected()
|
|
|
|
@pyqtSlot()
|
|
def on_disconnected(self):
|
|
"""Clean up socket when the client disconnected."""
|
|
log.ipc.debug("Client disconnected from socket 0x{:x}.".format(
|
|
id(self._socket)))
|
|
self._timer.stop()
|
|
if self._old_socket is not None:
|
|
self._old_socket.deleteLater()
|
|
self._old_socket = self._socket
|
|
self._socket = None
|
|
# Maybe another connection is waiting.
|
|
self.handle_connection()
|
|
|
|
def _handle_invalid_data(self):
|
|
"""Handle invalid data we got from a QLocalSocket."""
|
|
assert self._socket is not None
|
|
log.ipc.error("Ignoring invalid IPC data from socket 0x{:x}.".format(
|
|
id(self._socket)))
|
|
self.got_invalid_data.emit()
|
|
self._socket.errorOccurred.connect(self.on_error)
|
|
self._socket.disconnectFromServer()
|
|
|
|
def _handle_data(self, data):
|
|
"""Handle data (as bytes) we got from on_ready_read."""
|
|
try:
|
|
decoded = data.decode('utf-8')
|
|
except UnicodeDecodeError:
|
|
log.ipc.error("invalid utf-8: {!r}".format(binascii.hexlify(data)))
|
|
self._handle_invalid_data()
|
|
return
|
|
|
|
log.ipc.debug("Processing: {}".format(decoded))
|
|
try:
|
|
json_data = json.loads(decoded)
|
|
except ValueError:
|
|
log.ipc.error("invalid json: {}".format(decoded.strip()))
|
|
self._handle_invalid_data()
|
|
return
|
|
|
|
for name in ['args', 'target_arg']:
|
|
if name not in json_data:
|
|
log.ipc.error("Missing {}: {}".format(name, decoded.strip()))
|
|
self._handle_invalid_data()
|
|
return
|
|
|
|
try:
|
|
protocol_version = int(json_data['protocol_version'])
|
|
except (KeyError, ValueError):
|
|
log.ipc.error("invalid version: {}".format(decoded.strip()))
|
|
self._handle_invalid_data()
|
|
return
|
|
|
|
if protocol_version != PROTOCOL_VERSION:
|
|
log.ipc.error("incompatible version: expected {}, got {}".format(
|
|
PROTOCOL_VERSION, protocol_version))
|
|
self._handle_invalid_data()
|
|
return
|
|
|
|
args = json_data['args']
|
|
|
|
target_arg = json_data['target_arg']
|
|
if target_arg is None:
|
|
# https://www.riverbankcomputing.com/pipermail/pyqt/2016-April/037375.html
|
|
target_arg = ''
|
|
|
|
cwd = json_data.get('cwd', '')
|
|
assert cwd is not None
|
|
|
|
self.got_args.emit(args, target_arg, cwd)
|
|
|
|
def _get_socket(self, warn=True):
|
|
"""Get the current socket for on_ready_read.
|
|
|
|
Arguments:
|
|
warn: Whether to warn if no socket was found.
|
|
"""
|
|
if self._socket is None: # pragma: no cover
|
|
# This happens when doing a connection while another one is already
|
|
# active for some reason.
|
|
if self._old_socket is None:
|
|
if warn:
|
|
log.ipc.warning("In _get_socket with None socket and old_socket!")
|
|
return None
|
|
log.ipc.debug("In _get_socket with None socket!")
|
|
socket = self._old_socket
|
|
else:
|
|
socket = self._socket
|
|
|
|
if sip.isdeleted(socket): # pragma: no cover
|
|
log.ipc.warning("Ignoring deleted IPC socket")
|
|
return None
|
|
|
|
return socket
|
|
|
|
@pyqtSlot()
|
|
def on_ready_read(self):
|
|
"""Read json data from the client."""
|
|
self._timer.stop()
|
|
|
|
socket = self._get_socket()
|
|
while socket is not None and socket.canReadLine():
|
|
data = bytes(socket.readLine())
|
|
self.got_raw.emit(data)
|
|
log.ipc.debug("Read from socket 0x{:x}: {!r}".format(
|
|
id(socket), data))
|
|
self._handle_data(data)
|
|
socket = self._get_socket(warn=False)
|
|
|
|
if self._socket is not None:
|
|
self._timer.start()
|
|
|
|
@pyqtSlot()
|
|
def on_timeout(self):
|
|
"""Cancel the current connection if it was idle for too long."""
|
|
assert self._socket is not None
|
|
if not self._timer.check_timeout_validity():
|
|
# WORKAROUND for https://bugreports.qt.io/browse/QTBUG-124496
|
|
log.ipc.debug("Ignoring early on_timeout call")
|
|
return
|
|
|
|
log.ipc.error("IPC connection timed out "
|
|
"(socket 0x{:x}).".format(id(self._socket)))
|
|
self._socket.disconnectFromServer()
|
|
if self._socket is not None: # pragma: no cover
|
|
# on_socket_disconnected sets it to None
|
|
self._socket.waitForDisconnected(CONNECT_TIMEOUT)
|
|
if self._socket is not None: # pragma: no cover
|
|
# on_socket_disconnected sets it to None
|
|
self._socket.abort()
|
|
|
|
@pyqtSlot()
|
|
def update_atime(self):
|
|
"""Update the atime of the socket file all few hours.
|
|
|
|
From the XDG basedir spec:
|
|
|
|
To ensure that your files are not removed, they should have their
|
|
access time timestamp modified at least once every 6 hours of monotonic
|
|
time or the 'sticky' bit should be set on the file.
|
|
"""
|
|
assert self._server is not None
|
|
path = self._server.fullServerName()
|
|
if not path:
|
|
log.ipc.error("In update_atime with no server path!")
|
|
return
|
|
|
|
log.ipc.debug("Touching {}".format(path))
|
|
|
|
try:
|
|
os.utime(path)
|
|
except OSError:
|
|
log.ipc.exception("Failed to update IPC socket, trying to "
|
|
"re-listen...")
|
|
self._server.close()
|
|
self.listen()
|
|
|
|
@pyqtSlot()
|
|
def shutdown(self):
|
|
"""Shut down the IPC server cleanly."""
|
|
if self._server is None:
|
|
# We can get called twice when using :restart -- there, IPC is shut down
|
|
# early to avoid processing new connections while shutting down, and then
|
|
# we get called again when the application is about to quit.
|
|
return
|
|
|
|
log.ipc.debug("Shutting down IPC (socket 0x{:x})".format(
|
|
id(self._socket)))
|
|
|
|
if self._socket is not None:
|
|
self._socket.deleteLater()
|
|
self._socket = None
|
|
|
|
self._timer.stop()
|
|
if self._atime_timer is not None: # pragma: no branch
|
|
self._atime_timer.stop()
|
|
try:
|
|
self._atime_timer.timeout.disconnect(self.update_atime)
|
|
except TypeError:
|
|
pass
|
|
|
|
self._server.close()
|
|
self._server.deleteLater()
|
|
self._remove_server()
|
|
self._server = None
|
|
|
|
|
|
def send_to_running_instance(socketname, command, target_arg, *, socket=None):
|
|
"""Try to send a commandline to a running instance.
|
|
|
|
Blocks for CONNECT_TIMEOUT ms.
|
|
|
|
Args:
|
|
socketname: The name which should be used for the socket.
|
|
command: The command to send to the running instance.
|
|
target_arg: --target command line argument
|
|
socket: The socket to read data from, or None.
|
|
|
|
Return:
|
|
True if connecting was successful, False if no connection was made.
|
|
"""
|
|
if socket is None:
|
|
socket = QLocalSocket()
|
|
|
|
log.ipc.debug("Connecting to {}".format(socketname))
|
|
socket.connectToServer(socketname)
|
|
|
|
connected = socket.waitForConnected(CONNECT_TIMEOUT)
|
|
if connected:
|
|
log.ipc.info("Opening in existing instance")
|
|
json_data = {'args': command, 'target_arg': target_arg,
|
|
'version': qutebrowser.__version__,
|
|
'protocol_version': PROTOCOL_VERSION}
|
|
try:
|
|
cwd = os.getcwd()
|
|
except OSError:
|
|
pass
|
|
else:
|
|
json_data['cwd'] = cwd
|
|
line = json.dumps(json_data) + '\n'
|
|
data = line.encode('utf-8')
|
|
log.ipc.debug("Writing: {!r}".format(data))
|
|
socket.writeData(data)
|
|
socket.waitForBytesWritten(WRITE_TIMEOUT)
|
|
if socket.error() != QLocalSocket.LocalSocketError.UnknownSocketError:
|
|
raise SocketError("writing to running instance", socket)
|
|
socket.disconnectFromServer()
|
|
if socket.state() != QLocalSocket.LocalSocketState.UnconnectedState:
|
|
socket.waitForDisconnected(CONNECT_TIMEOUT)
|
|
return True
|
|
else:
|
|
if socket.error() not in [QLocalSocket.LocalSocketError.ConnectionRefusedError,
|
|
QLocalSocket.LocalSocketError.ServerNotFoundError]:
|
|
raise SocketError("connecting to running instance", socket)
|
|
log.ipc.debug("No existing instance present ({})".format(
|
|
debug.qenum_key(QLocalSocket, socket.error())))
|
|
return False
|
|
|
|
|
|
def display_error(exc, args):
|
|
"""Display a message box with an IPC error."""
|
|
error.handle_fatal_exc(
|
|
exc, "Error while connecting to running instance!",
|
|
no_err_windows=args.no_err_windows)
|
|
|
|
|
|
def send_or_listen(args):
|
|
"""Send the args to a running instance or start a new IPCServer.
|
|
|
|
Args:
|
|
args: The argparse namespace.
|
|
|
|
Return:
|
|
The IPCServer instance if no running instance was detected.
|
|
None if an instance was running and received our request.
|
|
"""
|
|
global server
|
|
try:
|
|
socketname = _get_socketname(args.basedir)
|
|
try:
|
|
sent = send_to_running_instance(socketname, args.command,
|
|
args.target)
|
|
if sent:
|
|
return None
|
|
log.init.debug("Starting IPC server...")
|
|
server = IPCServer(socketname)
|
|
server.listen()
|
|
return server
|
|
except AddressInUseError:
|
|
# This could be a race condition...
|
|
log.init.debug("Got AddressInUseError, trying again.")
|
|
time.sleep(0.5)
|
|
sent = send_to_running_instance(socketname, args.command,
|
|
args.target)
|
|
if sent:
|
|
return None
|
|
else:
|
|
raise
|
|
except Error as e:
|
|
display_error(e, args)
|
|
raise
|