Repository: sopel-irc/sopel

Issue:
UHNAMES Race Condition
### Description
When Sopel joins a server that supports UHNAMES but not the userhost-in-names CAP, it sends `PROTOCTL UHNAMES` to accomplish the same goal.

In my testing, this results in:
```
>> CAP LS 302
>> NICK Sopel
>> USER ...
<< 001 ...
>> JOIN #test
<< 005 [...] UHNAMES
>> PROTOCTL UHNAMES
<< JOIN #test
<< 353 Sopel = #test :@Op Sopel
```

Followed by an exception (expected 2 to unpack, got 1) on [coretasks.py:524](https://github.com/sopel-irc/sopel/blame/36b7c2f1064e7d734e50a66b24270cf7c8949426/sopel/coretasks.py#L524)

Sending `PROTOCTL UHNAMES` *before* the `JOIN` seems to work.

The same problem _may_ exist with the CAP, if not in practice, than in theory.

Previously: #2102

### Reproduction steps
1. Find a server with UHNAMES but no userhost-in-names
2. Configure Sopel to connect to it and join a channel
3. Start sopel
4. Note exception

### Expected behavior
Joins without exception

### Environment
- Sopel `.version`: 36b7c2f1064e7d734e50a66b24270cf7c8949426
- Sopel installed via: pip
- Python version: 3.9.2
- Operating system: Debian 10
- IRCd `/version`: InspIRCd 2

### Notes
@dgw broke it


Discussion:
I can see a quick fix, that would probably be a safe fix as well: try/catch.
The best solution would simply prevent sending any JOINs until after 005 has been processed.

I would love to just move a bunch of startup tasks from 001 to 251, but unfortunately the latter is no longer required. We could move joining channels to a new handler that triggers on 376 (RPL_ENDOFMOTD) or 422 (ERR_NOMOTD), since [the server "MUST" send](https://modern.ircdocs.horse/#connection-registration) either the MOTD or 422 after 001-005. We already constrain ISUPPORT handling to happen on the main thread, so that should block until after `PROTOCTL UHNAMES` has been sent, after which it will be fine to send JOINs. (Can _also_ try/except the line that currently errors, for maximum safety.)

The advantage of triggering JOINs and such on 001 is that it's existed in the spec for ages. As unlikely as it would be to encounter an IRCd that doesn't send 005, or doesn't send the MOTD automatically after registration, it's far _more_ unlikely to find a server that doesn't send 001—that would completely break the protocol, as I understand it.

As an aside, I have no idea why [this rule](https://github.com/sopel-irc/sopel/blob/36b7c2f1064e7d734e50a66b24270cf7c8949426/sopel/coretasks.py#L341) exists on the 005 handler. It shouldn't be necessary. Though I guess it's a guard against [super outdated](https://www.ircnet.com/articles/rpl_isupport) IRCds that still use [005 as RPL_BOUNCE](https://datatracker.ietf.org/doc/html/rfc2812.html#page-44), per RFC 2812.
> We could move joining channels to a new handler that triggers on 376 (RPL_ENDOFMOTD) or 422 (ERR_NOMOTD), since [the server "MUST" send](https://modern.ircdocs.horse/#connection-registration) either the MOTD or 422 after 001-005.

That shouldn't be too much of a trouble, right? Right... I'll try.
So, hm... you're not going to believe that: it's stupidly simple. All I have to do is move a portion of the code into its own plugin callable in `coretasks`. Everything else is just about managing the automated tests.

Resolving patch:
diff --git a/sopel/coretasks.py b/sopel/coretasks.py
--- a/sopel/coretasks.py
+++ b/sopel/coretasks.py
@@ -64,6 +64,7 @@ def setup(bot):
     The setup phase is used to activate the throttle feature to prevent a flood
     of JOIN commands when there are too many channels to join.
     """
+    bot.memory['retry_join'] = SopelMemory()
     bot.memory['join_events_queue'] = collections.deque()
 
     # Manage JOIN flood protection
@@ -82,6 +83,7 @@ def setup(bot):
 
 def shutdown(bot):
     """Clean up coretasks-related values in the bot's memory."""
+    bot.memory['retry_join'] = SopelMemory()
     try:
         bot.memory['join_events_queue'].clear()
     except KeyError:
@@ -286,39 +288,6 @@ def startup(bot, trigger):
             modes = '+' + modes
         bot.write(('MODE', bot.nick, modes))
 
-    # join channels
-    bot.memory['retry_join'] = SopelMemory()
-
-    channels = bot.config.core.channels
-    if not channels:
-        LOGGER.info("No initial channels to JOIN.")
-    elif bot.config.core.throttle_join:
-        throttle_rate = int(bot.config.core.throttle_join)
-        throttle_wait = max(bot.config.core.throttle_wait, 1)
-        channels_joined = 0
-
-        LOGGER.info(
-            "Joining %d channels (with JOIN throttle ON); "
-            "this may take a moment.",
-            len(channels))
-
-        for channel in channels:
-            channels_joined += 1
-            if not channels_joined % throttle_rate:
-                LOGGER.debug(
-                    "Waiting %ds before next JOIN batch.",
-                    throttle_wait)
-                time.sleep(throttle_wait)
-            bot.join(channel)
-    else:
-        LOGGER.info(
-            "Joining %d channels (with JOIN throttle OFF); "
-            "this may take a moment.",
-            len(channels))
-
-        for channel in bot.config.core.channels:
-            bot.join(channel)
-
     # warn for insecure auth method if necessary
     if (not bot.config.core.owner_account and
             'account-tag' in bot.enabled_capabilities and
@@ -402,6 +371,44 @@ def handle_isupport(bot, trigger):
             bot.write(('PROTOCTL', 'UHNAMES'))
 
 
+@plugin.event(events.RPL_ENDOFMOTD, events.ERR_NOMOTD)
+@plugin.thread(False)
+@plugin.unblockable
+@plugin.priority('medium')
+def join_channels(bot, trigger):
+    # join channels
+    channels = bot.config.core.channels
+    if not channels:
+        LOGGER.info("No initial channels to JOIN.")
+
+    elif bot.config.core.throttle_join:
+        throttle_rate = int(bot.config.core.throttle_join)
+        throttle_wait = max(bot.config.core.throttle_wait, 1)
+        channels_joined = 0
+
+        LOGGER.info(
+            "Joining %d channels (with JOIN throttle ON); "
+            "this may take a moment.",
+            len(channels))
+
+        for channel in channels:
+            channels_joined += 1
+            if not channels_joined % throttle_rate:
+                LOGGER.debug(
+                    "Waiting %ds before next JOIN batch.",
+                    throttle_wait)
+                time.sleep(throttle_wait)
+            bot.join(channel)
+    else:
+        LOGGER.info(
+            "Joining %d channels (with JOIN throttle OFF); "
+            "this may take a moment.",
+            len(channels))
+
+        for channel in bot.config.core.channels:
+            bot.join(channel)
+
+
 @plugin.event(events.RPL_MYINFO)
 @plugin.thread(False)
 @plugin.unblockable


Test patch: