Repository: sopel-irc/sopel

Issue:
Broken interrupt handling when connection is inactive
For the record, I'm testing on macOS today because that's what I have available. However, the same behavior occurs with identical or similar tracebacks on my Ubuntu machine at home, so I don't think it's related to the fact that I'm using Homebrew Python or anything. It's probably related to Sopel's sometimes-wonky exception handling.

Here's what I did:

1. Run `sopel`
2. Press Ctrl-C after "Connecting to &lt;server&gt;..."
3. Run `sopel` again
4. Press Ctrl-C while Sopel is waiting to reconnect after an expected SSL failure

Interrupting the connection phase resulted in an `AttributeError`, probably as expected (since quitting tries to send something to the socket, and the socket object doesn't exist before connecting finishes):

```
Connecting to irc.network.net:6667...
^CGot quit signal, shutting down.
Traceback (most recent call last):
  File "/Users/dgw/github/sopel/sopel/__init__.py", line 91, in run
    p.run(config.core.host, int(config.core.port))
  File "/Users/dgw/github/sopel/sopel/irc.py", line 167, in run
    self.initiate_connect(host, port)
  File "/Users/dgw/github/sopel/sopel/irc.py", line 177, in initiate_connect
    source_address=source_address))
  File "/usr/local/Cellar/python/3.7.2/Frameworks/Python.framework/Versions/3.7/lib/python3.7/socket.py", line 707, in create_connection
    for res in getaddrinfo(host, port, 0, SOCK_STREAM):
  File "/usr/local/Cellar/python/3.7.2/Frameworks/Python.framework/Versions/3.7/lib/python3.7/socket.py", line 748, in getaddrinfo
    for res in _socket.getaddrinfo(host, port, family, type, proto, flags):
  File "/Users/dgw/github/sopel/sopel/__init__.py", line 73, in signal_handler
    p.quit('Closing')
  File "/Users/dgw/github/sopel/sopel/irc.py", line 199, in quit
    self.write(['QUIT'], message)
  File "/Users/dgw/github/sopel/sopel/bot.py", line 166, in write
    irc.Bot.write(self, args, text=text)
  File "/Users/dgw/github/sopel/sopel/irc.py", line 161, in write
    self.send(temp.encode('utf-8'))
  File "/usr/local/Cellar/python/3.7.2/Frameworks/Python.framework/Versions/3.7/lib/python3.7/asyncore.py", line 360, in send
    result = self.socket.send(data)
AttributeError: 'NoneType' object has no attribute 'send'
```

Interrupting the reconnect delay yields a completely different traceback, and additionally requires pressing Ctrl-C twice:

```
Warning: Disconnected. Reconnecting in 20 seconds...
^CGot quit signal, shutting down.
Traceback (most recent call last):
  File "./sopel.py", line 7, in <module>
    sys.exit(run_script.main())
  File "/Users/dgw/github/sopel/sopel/run_script.py", line 351, in main
    ret = run(config_module, pid_file_path)
  File "/Users/dgw/github/sopel/sopel/__init__.py", line 120, in run
    time.sleep(delay)
  File "/Users/dgw/github/sopel/sopel/__init__.py", line 73, in signal_handler
    p.quit('Closing')
  File "/Users/dgw/github/sopel/sopel/irc.py", line 199, in quit
    self.write(['QUIT'], message)
  File "/Users/dgw/github/sopel/sopel/bot.py", line 166, in write
    irc.Bot.write(self, args, text=text)
  File "/Users/dgw/github/sopel/sopel/irc.py", line 161, in write
    self.send(temp.encode('utf-8'))
  File "/Users/dgw/github/sopel/sopel/irc.py", line 327, in _ssl_send
    result = self.socket.send(data)
OSError: [Errno 9] Bad file descriptor
^CGot quit signal, shutting down.
Exception ignored in: <module 'threading' from '/usr/local/Cellar/python/3.7.2/Frameworks/Python.framework/Versions/3.7/lib/python3.7/threading.py'>
Traceback (most recent call last):
  File "/usr/local/Cellar/python/3.7.2/Frameworks/Python.framework/Versions/3.7/lib/python3.7/threading.py", line 1273, in _shutdown
    t.join()
  File "/usr/local/Cellar/python/3.7.2/Frameworks/Python.framework/Versions/3.7/lib/python3.7/threading.py", line 1032, in join
    self._wait_for_tstate_lock()
  File "/usr/local/Cellar/python/3.7.2/Frameworks/Python.framework/Versions/3.7/lib/python3.7/threading.py", line 1048, in _wait_for_tstate_lock
    elif lock.acquire(block, timeout):
  File "/Users/dgw/github/sopel/sopel/__init__.py", line 73, in signal_handler
    p.quit('Closing')
  File "/Users/dgw/github/sopel/sopel/irc.py", line 199, in quit
    self.write(['QUIT'], message)
  File "/Users/dgw/github/sopel/sopel/bot.py", line 166, in write
    irc.Bot.write(self, args, text=text)
  File "/Users/dgw/github/sopel/sopel/irc.py", line 161, in write
    self.send(temp.encode('utf-8'))
  File "/Users/dgw/github/sopel/sopel/irc.py", line 327, in _ssl_send
    result = self.socket.send(data)
OSError: [Errno 9] Bad file descriptor
```

I've run into this second issue relatively often while testing things in the last few months, since I often intentionally interrupt Sopel during states other than "Connected to network and running normally".

The exception itself isn't a big deal (though it would be nice not to spit it out). I consider needing to press Ctrl-C twice to be the main bug here. That simply shouldn't be required.

Interrupting Sopel after the "Loading modules..." line (but before it starts to connect) yields a clean exit, though. That's kind of weird, and I haven't had time to look into why.

Since I know we have a few people digging around in Sopel's internals and refactoring things now (and doing damn good work, too!), I'm hoping one of them will look into this at some point. 😹

This isn't a high-severity bug, but I do want to get it fixed if possible, ideally in the next year or two. That need to press Ctrl-C twice carries over into, for example, `sopel --quit`. Sopel should _never_ need to be told to quit twice, unless something is catastrophically wrong—and waiting to reconnect is a totally normal situation that shouldn't break things the way it does now.


Discussion:

Resolving patch:
diff --git a/sopel/cli/run.py b/sopel/cli/run.py
--- a/sopel/cli/run.py
+++ b/sopel/cli/run.py
@@ -67,7 +67,11 @@ def signal_handler(sig, frame):
             tools.stderr('Got restart signal.')
             p.restart('Restarting')
 
+    # Define empty variable `p` for bot
+    p = None
     while True:
+        if p and p.hasquit:  # Check if `hasquit` was set for bot during disconnected phase
+            break
         try:
             p = bot.Sopel(config, daemon=daemon)
             if hasattr(signal, 'SIGUSR1'):


Test patch: