1# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: 2 3# Copyright 2014-2021 Florian Bruhin (The Compiler) <mail@qutebrowser.org> 4# 5# This file is part of qutebrowser. 6# 7# qutebrowser is free software: you can redistribute it and/or modify 8# it under the terms of the GNU General Public License as published by 9# the Free Software Foundation, either version 3 of the License, or 10# (at your option) any later version. 11# 12# qutebrowser is distributed in the hope that it will be useful, 13# but WITHOUT ANY WARRANTY; without even the implied warranty of 14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15# GNU General Public License for more details. 16# 17# You should have received a copy of the GNU General Public License 18# along with qutebrowser. If not, see <https://www.gnu.org/licenses/>. 19 20"""Utilities for IPC with existing instances.""" 21 22import os 23import time 24import json 25import getpass 26import binascii 27import hashlib 28 29from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, Qt 30from PyQt5.QtNetwork import QLocalSocket, QLocalServer, QAbstractSocket 31 32import qutebrowser 33from qutebrowser.utils import log, usertypes, error, standarddir, utils 34from qutebrowser.qt import sip 35 36 37CONNECT_TIMEOUT = 100 # timeout for connecting/disconnecting 38WRITE_TIMEOUT = 1000 39READ_TIMEOUT = 5000 40ATIME_INTERVAL = 5000 * 60 # 5 minutes 41PROTOCOL_VERSION = 1 42 43 44# The ipc server instance 45server = None 46 47 48def _get_socketname_windows(basedir): 49 """Get a socketname to use for Windows.""" 50 try: 51 username = getpass.getuser() 52 except ImportError: 53 # getpass.getuser() first tries a couple of environment variables. If 54 # none of those are set (i.e., USERNAME is missing), it tries to import 55 # the "pwd" module which is unavailable on Windows. 56 raise Error("Could not find username. This should only happen if " 57 "there is a bug in the application launching qutebrowser, " 58 "preventing the USERNAME environment variable from being " 59 "passed. If you know more about when this happens, please " 60 "report this to mail@qutebrowser.org.") 61 62 parts = ['qutebrowser', username] 63 if basedir is not None: 64 md5 = hashlib.md5(basedir.encode('utf-8')).hexdigest() 65 parts.append(md5) 66 return '-'.join(parts) 67 68 69def _get_socketname(basedir): 70 """Get a socketname to use.""" 71 if utils.is_windows: # pragma: no cover 72 return _get_socketname_windows(basedir) 73 74 parts_to_hash = [getpass.getuser()] 75 if basedir is not None: 76 parts_to_hash.append(basedir) 77 78 data_to_hash = '-'.join(parts_to_hash).encode('utf-8') 79 md5 = hashlib.md5(data_to_hash).hexdigest() 80 81 prefix = 'i-' if utils.is_mac else 'ipc-' 82 filename = '{}{}'.format(prefix, md5) 83 return os.path.join(standarddir.runtime(), filename) 84 85 86class Error(Exception): 87 88 """Base class for IPC exceptions.""" 89 90 91class SocketError(Error): 92 93 """Exception raised when there was an error with a QLocalSocket. 94 95 Args: 96 code: The error code. 97 message: The error message. 98 action: The action which was taken when the error happened. 99 """ 100 101 def __init__(self, action, socket): 102 """Constructor. 103 104 Args: 105 action: The action which was taken when the error happened. 106 socket: The QLocalSocket which has the error set. 107 """ 108 super().__init__() 109 self.action = action 110 self.code = socket.error() 111 self.message = socket.errorString() 112 113 def __str__(self): 114 return "Error while {}: {} (error {})".format( 115 self.action, self.message, self.code) 116 117 118class ListenError(Error): 119 120 """Exception raised when there was a problem with listening to IPC. 121 122 Args: 123 code: The error code. 124 message: The error message. 125 """ 126 127 def __init__(self, local_server): 128 """Constructor. 129 130 Args: 131 local_server: The QLocalServer which has the error set. 132 """ 133 super().__init__() 134 self.code = local_server.serverError() 135 self.message = local_server.errorString() 136 137 def __str__(self): 138 return "Error while listening to IPC server: {} (error {})".format( 139 self.message, self.code) 140 141 142class AddressInUseError(ListenError): 143 144 """Emitted when the server address is already in use.""" 145 146 147class IPCServer(QObject): 148 149 """IPC server to which clients connect to. 150 151 Attributes: 152 ignored: Whether requests are ignored (in exception hook). 153 _timer: A timer to handle timeouts. 154 _server: A QLocalServer to accept new connections. 155 _socket: The QLocalSocket we're currently connected to. 156 _socketname: The socketname to use. 157 _atime_timer: Timer to update the atime of the socket regularly. 158 159 Signals: 160 got_args: Emitted when there was an IPC connection and arguments were 161 passed. 162 got_args: Emitted with the raw data an IPC connection got. 163 got_invalid_data: Emitted when there was invalid incoming data. 164 """ 165 166 got_args = pyqtSignal(list, str, str) 167 got_raw = pyqtSignal(bytes) 168 got_invalid_data = pyqtSignal() 169 170 def __init__(self, socketname, parent=None): 171 """Start the IPC server and listen to commands. 172 173 Args: 174 socketname: The socketname to use. 175 parent: The parent to be used. 176 """ 177 super().__init__(parent) 178 self.ignored = False 179 self._socketname = socketname 180 181 self._timer = usertypes.Timer(self, 'ipc-timeout') 182 self._timer.setInterval(READ_TIMEOUT) 183 self._timer.timeout.connect(self.on_timeout) 184 185 if utils.is_windows: # pragma: no cover 186 self._atime_timer = None 187 else: 188 self._atime_timer = usertypes.Timer(self, 'ipc-atime') 189 self._atime_timer.setInterval(ATIME_INTERVAL) 190 self._atime_timer.timeout.connect(self.update_atime) 191 self._atime_timer.setTimerType(Qt.VeryCoarseTimer) 192 193 self._server = QLocalServer(self) 194 self._server.newConnection.connect( # type: ignore[attr-defined] 195 self.handle_connection) 196 197 self._socket = None 198 self._old_socket = None 199 200 if utils.is_windows: # pragma: no cover 201 # As a WORKAROUND for a Qt bug, we can't use UserAccessOption on Unix. If we 202 # do, we don't get an AddressInUseError anymore: 203 # https://bugreports.qt.io/browse/QTBUG-48635 204 # 205 # Thus, we only do so on Windows, and handle permissions manually in 206 # listen() on Linux. 207 log.ipc.debug("Calling setSocketOptions") 208 self._server.setSocketOptions(QLocalServer.UserAccessOption) 209 else: # pragma: no cover 210 log.ipc.debug("Not calling setSocketOptions") 211 212 def _remove_server(self): 213 """Remove an existing server.""" 214 ok = QLocalServer.removeServer(self._socketname) 215 if not ok: 216 raise Error("Error while removing server {}!".format( 217 self._socketname)) 218 219 def listen(self): 220 """Start listening on self._socketname.""" 221 log.ipc.debug("Listening as {}".format(self._socketname)) 222 if self._atime_timer is not None: # pragma: no branch 223 self._atime_timer.start() 224 self._remove_server() 225 ok = self._server.listen(self._socketname) 226 if not ok: 227 if self._server.serverError() == QAbstractSocket.AddressInUseError: 228 raise AddressInUseError(self._server) 229 raise ListenError(self._server) 230 231 if not utils.is_windows: # pragma: no cover 232 # WORKAROUND for QTBUG-48635, see the comment in __init__ for details. 233 try: 234 os.chmod(self._server.fullServerName(), 0o700) 235 except FileNotFoundError: 236 # https://github.com/qutebrowser/qutebrowser/issues/1530 237 # The server doesn't actually exist even if ok was reported as 238 # True, so report this as an error. 239 raise ListenError(self._server) 240 241 @pyqtSlot('QLocalSocket::LocalSocketError') 242 def on_error(self, err): 243 """Raise SocketError on fatal errors.""" 244 if self._socket is None: 245 # Sometimes this gets called from stale sockets. 246 log.ipc.debug("In on_error with None socket!") 247 return 248 self._timer.stop() 249 log.ipc.debug("Socket 0x{:x}: error {}: {}".format( 250 id(self._socket), self._socket.error(), 251 self._socket.errorString())) 252 if err != QLocalSocket.PeerClosedError: 253 raise SocketError("handling IPC connection", self._socket) 254 255 @pyqtSlot() 256 def handle_connection(self): 257 """Handle a new connection to the server.""" 258 if self.ignored: 259 return 260 if self._socket is not None: 261 log.ipc.debug("Got new connection but ignoring it because we're " 262 "still handling another one (0x{:x}).".format( 263 id(self._socket))) 264 return 265 socket = self._server.nextPendingConnection() 266 if socket is None: 267 log.ipc.debug( # type: ignore[unreachable] 268 "No new connection to handle.") 269 return 270 log.ipc.debug("Client connected (socket 0x{:x}).".format(id(socket))) 271 self._socket = socket 272 self._timer.start() 273 socket.readyRead.connect( # type: ignore[attr-defined] 274 self.on_ready_read) 275 if socket.canReadLine(): 276 log.ipc.debug("We can read a line immediately.") 277 self.on_ready_read() 278 socket.error.connect(self.on_error) # type: ignore[attr-defined] 279 if socket.error() not in [QLocalSocket.UnknownSocketError, 280 QLocalSocket.PeerClosedError]: 281 log.ipc.debug("We got an error immediately.") 282 self.on_error(socket.error()) 283 socket.disconnected.connect( # type: ignore[attr-defined] 284 self.on_disconnected) 285 if socket.state() == QLocalSocket.UnconnectedState: 286 log.ipc.debug("Socket was disconnected immediately.") 287 self.on_disconnected() 288 289 @pyqtSlot() 290 def on_disconnected(self): 291 """Clean up socket when the client disconnected.""" 292 log.ipc.debug("Client disconnected from socket 0x{:x}.".format( 293 id(self._socket))) 294 self._timer.stop() 295 if self._old_socket is not None: 296 self._old_socket.deleteLater() 297 self._old_socket = self._socket 298 self._socket = None 299 # Maybe another connection is waiting. 300 self.handle_connection() 301 302 def _handle_invalid_data(self): 303 """Handle invalid data we got from a QLocalSocket.""" 304 assert self._socket is not None 305 log.ipc.error("Ignoring invalid IPC data from socket 0x{:x}.".format( 306 id(self._socket))) 307 self.got_invalid_data.emit() 308 self._socket.error.connect(self.on_error) 309 self._socket.disconnectFromServer() 310 311 def _handle_data(self, data): 312 """Handle data (as bytes) we got from on_ready_read.""" 313 try: 314 decoded = data.decode('utf-8') 315 except UnicodeDecodeError: 316 log.ipc.error("invalid utf-8: {!r}".format(binascii.hexlify(data))) 317 self._handle_invalid_data() 318 return 319 320 log.ipc.debug("Processing: {}".format(decoded)) 321 try: 322 json_data = json.loads(decoded) 323 except ValueError: 324 log.ipc.error("invalid json: {}".format(decoded.strip())) 325 self._handle_invalid_data() 326 return 327 328 for name in ['args', 'target_arg']: 329 if name not in json_data: 330 log.ipc.error("Missing {}: {}".format(name, decoded.strip())) 331 self._handle_invalid_data() 332 return 333 334 try: 335 protocol_version = int(json_data['protocol_version']) 336 except (KeyError, ValueError): 337 log.ipc.error("invalid version: {}".format(decoded.strip())) 338 self._handle_invalid_data() 339 return 340 341 if protocol_version != PROTOCOL_VERSION: 342 log.ipc.error("incompatible version: expected {}, got {}".format( 343 PROTOCOL_VERSION, protocol_version)) 344 self._handle_invalid_data() 345 return 346 347 args = json_data['args'] 348 349 target_arg = json_data['target_arg'] 350 if target_arg is None: 351 # https://www.riverbankcomputing.com/pipermail/pyqt/2016-April/037375.html 352 target_arg = '' 353 354 cwd = json_data.get('cwd', '') 355 assert cwd is not None 356 357 self.got_args.emit(args, target_arg, cwd) 358 359 def _get_socket(self, warn=True): 360 """Get the current socket for on_ready_read. 361 362 Arguments: 363 warn: Whether to warn if no socket was found. 364 """ 365 if self._socket is None: # pragma: no cover 366 # This happens when doing a connection while another one is already 367 # active for some reason. 368 if self._old_socket is None: 369 if warn: 370 log.ipc.warning("In _get_socket with None socket and old_socket!") 371 return None 372 log.ipc.debug("In _get_socket with None socket!") 373 socket = self._old_socket 374 else: 375 socket = self._socket 376 377 if sip.isdeleted(socket): # pragma: no cover 378 log.ipc.warning("Ignoring deleted IPC socket") 379 return None 380 381 return socket 382 383 @pyqtSlot() 384 def on_ready_read(self): 385 """Read json data from the client.""" 386 self._timer.stop() 387 388 socket = self._get_socket() 389 while socket is not None and socket.canReadLine(): 390 data = bytes(socket.readLine()) 391 self.got_raw.emit(data) 392 log.ipc.debug("Read from socket 0x{:x}: {!r}".format( 393 id(socket), data)) 394 self._handle_data(data) 395 socket = self._get_socket(warn=False) 396 397 if self._socket is not None: 398 self._timer.start() 399 400 @pyqtSlot() 401 def on_timeout(self): 402 """Cancel the current connection if it was idle for too long.""" 403 assert self._socket is not None 404 log.ipc.error("IPC connection timed out " 405 "(socket 0x{:x}).".format(id(self._socket))) 406 self._socket.disconnectFromServer() 407 if self._socket is not None: # pragma: no cover 408 # on_socket_disconnected sets it to None 409 self._socket.waitForDisconnected(CONNECT_TIMEOUT) 410 if self._socket is not None: # pragma: no cover 411 # on_socket_disconnected sets it to None 412 self._socket.abort() 413 414 @pyqtSlot() 415 def update_atime(self): 416 """Update the atime of the socket file all few hours. 417 418 From the XDG basedir spec: 419 420 To ensure that your files are not removed, they should have their 421 access time timestamp modified at least once every 6 hours of monotonic 422 time or the 'sticky' bit should be set on the file. 423 """ 424 path = self._server.fullServerName() 425 if not path: 426 log.ipc.error("In update_atime with no server path!") 427 return 428 429 log.ipc.debug("Touching {}".format(path)) 430 431 try: 432 os.utime(path) 433 except OSError: 434 log.ipc.exception("Failed to update IPC socket, trying to " 435 "re-listen...") 436 self._server.close() 437 self.listen() 438 439 @pyqtSlot() 440 def shutdown(self): 441 """Shut down the IPC server cleanly.""" 442 log.ipc.debug("Shutting down IPC (socket 0x{:x})".format( 443 id(self._socket))) 444 if self._socket is not None: 445 self._socket.deleteLater() 446 self._socket = None 447 self._timer.stop() 448 if self._atime_timer is not None: # pragma: no branch 449 self._atime_timer.stop() 450 try: 451 self._atime_timer.timeout.disconnect(self.update_atime) 452 except TypeError: 453 pass 454 self._server.close() 455 self._server.deleteLater() 456 self._remove_server() 457 458 459def send_to_running_instance(socketname, command, target_arg, *, socket=None): 460 """Try to send a commandline to a running instance. 461 462 Blocks for CONNECT_TIMEOUT ms. 463 464 Args: 465 socketname: The name which should be used for the socket. 466 command: The command to send to the running instance. 467 target_arg: --target command line argument 468 socket: The socket to read data from, or None. 469 470 Return: 471 True if connecting was successful, False if no connection was made. 472 """ 473 if socket is None: 474 socket = QLocalSocket() 475 476 log.ipc.debug("Connecting to {}".format(socketname)) 477 socket.connectToServer(socketname) 478 479 connected = socket.waitForConnected(CONNECT_TIMEOUT) 480 if connected: 481 log.ipc.info("Opening in existing instance") 482 json_data = {'args': command, 'target_arg': target_arg, 483 'version': qutebrowser.__version__, 484 'protocol_version': PROTOCOL_VERSION} 485 try: 486 cwd = os.getcwd() 487 except OSError: 488 pass 489 else: 490 json_data['cwd'] = cwd 491 line = json.dumps(json_data) + '\n' 492 data = line.encode('utf-8') 493 log.ipc.debug("Writing: {!r}".format(data)) 494 socket.writeData(data) 495 socket.waitForBytesWritten(WRITE_TIMEOUT) 496 if socket.error() != QLocalSocket.UnknownSocketError: 497 raise SocketError("writing to running instance", socket) 498 socket.disconnectFromServer() 499 if socket.state() != QLocalSocket.UnconnectedState: 500 socket.waitForDisconnected(CONNECT_TIMEOUT) 501 return True 502 else: 503 if socket.error() not in [QLocalSocket.ConnectionRefusedError, 504 QLocalSocket.ServerNotFoundError]: 505 raise SocketError("connecting to running instance", socket) 506 log.ipc.debug("No existing instance present (error {})".format( 507 socket.error())) 508 return False 509 510 511def display_error(exc, args): 512 """Display a message box with an IPC error.""" 513 error.handle_fatal_exc( 514 exc, "Error while connecting to running instance!", 515 no_err_windows=args.no_err_windows) 516 517 518def send_or_listen(args): 519 """Send the args to a running instance or start a new IPCServer. 520 521 Args: 522 args: The argparse namespace. 523 524 Return: 525 The IPCServer instance if no running instance was detected. 526 None if an instance was running and received our request. 527 """ 528 global server 529 try: 530 socketname = _get_socketname(args.basedir) 531 try: 532 sent = send_to_running_instance(socketname, args.command, 533 args.target) 534 if sent: 535 return None 536 log.init.debug("Starting IPC server...") 537 server = IPCServer(socketname) 538 server.listen() 539 return server 540 except AddressInUseError: 541 # This could be a race condition... 542 log.init.debug("Got AddressInUseError, trying again.") 543 time.sleep(0.5) 544 sent = send_to_running_instance(socketname, args.command, 545 args.target) 546 if sent: 547 return None 548 else: 549 raise 550 except Error as e: 551 display_error(e, args) 552 raise 553