From 38b7ef81e232e3a6e1bf0742d2d0d6b0b3abb9fc Mon Sep 17 00:00:00 2001 From: "Vgr E.Barry" Date: Thu, 6 Aug 2015 12:29:47 -0400 Subject: [PATCH] Add proper error handling. Closes #151 All exceptions, if not caught, are unconditionally sent to `sys.stderr`. We only need to use our own custom class with some modifications and we can intercept all errors, even those in threads (which #151 was about). The only real downside from this practice is that `cli` is not accessible to us when the errors happen, so I used a hack to set it on an instance variable. If anyone can find a better solution, please step forward. For the time being, this will have to do. If for some obscure reason this class breaks, `sys.__stderr__` holds the original `sys.stderr` --- src/__init__.py | 56 +++++++++++++++++++++++++++++++++++++++++++++++++ src/handler.py | 43 +++++++++++-------------------------- src/wolfgame.py | 23 +++++++++++--------- 3 files changed, 81 insertions(+), 41 deletions(-) diff --git a/src/__init__.py b/src/__init__.py index 2afdca5..6a5f6d7 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,6 +1,10 @@ +import traceback import argparse import datetime +import socket import time +import sys +import io import botconfig import src.settings as var @@ -104,3 +108,55 @@ def stream(output, level="normal"): stream_handler(output) elif level == "warning": stream_handler(output) + +# Error handler + +buffer = io.BufferedWriter(io.FileIO(file=sys.stderr.fileno(), mode="wb", closefd=False)) + +class ErrorHandler(io.TextIOWrapper): + """Handle tracebacks sent to sys.stderr.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.cli = None + self.target_logger = None + self.data = [] + + def write(self, data): + assert not (self.cli is None or self.target_logger is None) + if self.closed: + raise ValueError("write to closed file") + if not isinstance(data, str): + raise ValueError("can't write %s to text stream" % data.__class__.__name__) + length = len(data) + b = data.encode("utf-8", "replace") + self.buffer.write(b) + self.data.append(data) + if data and not data.startswith(("Traceback", " ", "Exception in thread")): + self.flush() + return length + + def flush(self): + self.buffer.flush() + + msg = "An error has occurred and has been logged." + if not botconfig.PASTEBIN_ERRORS or botconfig.CHANNEL != botconfig.DEV_CHANNEL: + self.cli.msg(botconfig.CHANNEL, msg) + if botconfig.PASTEBIN_ERRORS and botconfig.DEV_CHANNEL: + try: + with socket.socket() as sock: + sock.connect(("termbin.com", 9999)) + sock.send(b"".join(s.encode("utf-8", "replace") for s in self.data) + b"\n") + url = sock.recv(1024).decode("utf-8") + except socket.error: + self.target_logger("".join(self.data), display=False) + else: + self.cli.msg(botconfig.DEV_CHANNEL, " ".join((msg, url))) + del self.data[:] + if var.PHASE in ("join", "day", "night"): + from src.decorators import COMMANDS + for cmd in COMMANDS["fstop"]: + cmd.func(self.cli, "", "", "") + +sys.stderr = ErrorHandler(buffer=buffer, encoding=sys.stderr.encoding, + errors=sys.stderr.errors, line_buffering=sys.stderr.line_buffering) diff --git a/src/handler.py b/src/handler.py index 1b0b7e6..6f162f8 100644 --- a/src/handler.py +++ b/src/handler.py @@ -1,9 +1,9 @@ # The bot commands implemented in here are present no matter which module is loaded -import base64 -import imp -import socket import traceback +import base64 +import socket +import sys from oyoyo.parse import parse_nick @@ -14,31 +14,10 @@ from src import decorators, logger, wolfgame log = logger("errors.log") alog = logger(None) +sys.stderr.target_logger = log + hook = decorators.hook -def notify_error(cli, chan, target_logger): - msg = "An error has occurred and has been logged." - - tb = traceback.format_exc() - - target_logger(tb) - - if (not botconfig.PASTEBIN_ERRORS) or (chan != botconfig.DEV_CHANNEL): - # Don't send a duplicate message if DEV_CHANNEL is the current channel. - cli.msg(chan, msg) - - if botconfig.PASTEBIN_ERRORS and botconfig.DEV_CHANNEL: - try: - with socket.socket() as sock: - sock.connect(("termbin.com", 9999)) - sock.send(tb.encode("utf-8", "replace") + b"\n") - url = sock.recv(1024).decode("utf-8") - except socket.error: - target_logger(traceback.format_exc()) - else: - cli.msg(botconfig.DEV_CHANNEL, " ".join((msg, url))) - - def on_privmsg(cli, rawnick, chan, msg, notice = False): try: @@ -63,7 +42,7 @@ def on_privmsg(cli, rawnick, chan, msg, notice = False): if botconfig.DEBUG_MODE: raise else: - notify_error(cli, chan, log) + traceback.print_exc() for x in decorators.COMMANDS: @@ -83,7 +62,7 @@ def on_privmsg(cli, rawnick, chan, msg, notice = False): if botconfig.DEBUG_MODE: raise else: - notify_error(cli, chan, log) + traceback.print_exc() def unhandled(cli, prefix, cmd, *args): @@ -94,11 +73,11 @@ def unhandled(cli, prefix, cmd, *args): for fn in decorators.HOOKS.get(cmd, []): try: fn.func(cli, prefix, *largs) - except Exception as e: + except Exception: if botconfig.DEBUG_MODE: - raise e + raise else: - notify_error(cli, botconfig.CHANNEL, log) + traceback.print_exc() def connect_callback(cli): @hook("endofmotd", hookid=294) @@ -184,6 +163,8 @@ def connect_callback(cli): # capability but now claims otherwise. alog("Server refused capabilities: {0}".format(" ".join(caps))) + if sys.stderr.cli is None: + sys.stderr.cli = cli # first connection if botconfig.SASL_AUTHENTICATION: @hook("authenticate") diff --git a/src/wolfgame.py b/src/wolfgame.py index 0c9f2e9..7de5d3b 100644 --- a/src/wolfgame.py +++ b/src/wolfgame.py @@ -494,17 +494,17 @@ def forced_exit(cli, nick, chan, rest): try: stop_game(cli) except Exception: - notify_error(cli, chan, errlog) + traceback.print_exc() try: reset_modes_timers(cli) except Exception: - notify_error(cli, chan, errlog) + traceback.print_exc() try: reset() except Exception: - notify_error(cli, chan, errlog) + traceback.print_exc() msg = "{0} quit from {1}" @@ -516,7 +516,7 @@ def forced_exit(cli, nick, chan, rest): nick, rest.strip())) except Exception: - notify_error(cli, chan, errlog) + traceback.print_exc() sys.exit() @@ -540,12 +540,12 @@ def restart_program(cli, nick, chan, rest): try: stop_game(cli) except Exception: - notify_error(cli, chan, errlog) + traceback.print_exc() try: reset_modes_timers(cli) except Exception: - notify_error(cli, chan, errlog) + traceback.print_exc() try: with sqlite3.connect("data.sqlite3", check_same_thread=False) as conn: @@ -554,12 +554,12 @@ def restart_program(cli, nick, chan, rest): if players: c.execute("UPDATE pre_restart_state SET players = ?", (" ".join(players),)) except Exception: - notify_error(cli, chan, errlog) + traceback.print_exc() try: reset() except Exception: - notify_error(cli, chan, errlog) + traceback.print_exc() msg = "{0} restart from {1}".format( "Scheduled" if restart_program.aftergame else "Forced", nick) @@ -596,7 +596,7 @@ def restart_program(cli, nick, chan, rest): try: cli.quit(msg.format(nick, rest.strip())) except Exception: - notify_error(cli, chan, errlog) + traceback.print_exc() @hook("quit") def restart_buffer(cli, raw_nick, reason): @@ -7508,7 +7508,10 @@ def fwait(cli, nick, chan, rest): @cmd("fstop", admin_only=True, phases=("join", "day", "night")) def reset_game(cli, nick, chan, rest): """Forces the game to stop.""" - cli.msg(botconfig.CHANNEL, "\u0002{0}\u0002 has forced the game to stop.".format(nick)) + if nick == "": + cli.msg(botconfig.CHANNEL, "Game stopped due to error.") + else: + cli.msg(botconfig.CHANNEL, "\u0002{0}\u0002 has forced the game to stop.".format(nick)) if var.PHASE != "join": stop_game(cli) else: