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`
This commit is contained in:
Vgr E.Barry 2015-08-06 12:29:47 -04:00
parent 74555f013a
commit 38b7ef81e2
3 changed files with 81 additions and 41 deletions

View File

@ -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, "<stderr>", "", "")
sys.stderr = ErrorHandler(buffer=buffer, encoding=sys.stderr.encoding,
errors=sys.stderr.errors, line_buffering=sys.stderr.line_buffering)

View File

@ -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")

View File

@ -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,6 +7508,9 @@ 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."""
if nick == "<stderr>":
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)