Skip to content

modlwip.c does not properly return POLL_HUP and POLL_ERR socket errors #5172

Description

@t35tB0t

extmod/modlwip.c only returns POLL_HUP and POLL_ERR if the events flags specify them. AFIAK this is not POSIX compliant which specifies these return events shall be unsolicited. e.g. see http://man7.org/linux/man-pages/man2/poll.2.html

The impact is that uasyncio (which does not set POLL_HUP and POLL_ERR flags) will not see these failed socket connections and co-routines will hang and consume memory. Eventually micropython will run out of memory. Adding these flags to uasyncio is a work-around to getting the socket errors returned but is not the expected behavior of these ioctl polls. The modlwip.c changes below were tested and, work when appropriate exception handling is added to uasync (specifically in the start_server() loop). Socket errors will also now be returned the the yielding co-routined where appropriate exception handling including stream read.aclose() and write.aclose() must be called to clean up upon pre-mature connection termination.

extmod_modlwip_c.diff.txt

note: In diff, (flags,ret) renamed to (events,revents) to align with common parlance in ioctl polling

Activity

  1. peterhinch commented on Oct 4, 2019

    @peterhinch
    Contributor

    Compare #4290

  2. t35tB0t commented on Oct 4, 2019

    @t35tB0t
    Author

    @peterhinch - Kudos to you for the finding this issue way back in #4290 and documenting better than I did here. I'm very certain that its the very same issue and fixed for STM32s with the mods made to the related modules. I have not dug into the ESP code base to understand the related behaviors. However, I'm not sure I agree with the expected error returns. Reading the related linux man page, it is pretty clear that the revents shall NOT return the callers event flags. So we shouldn't be getting POLL_IN + POLL_HUP (17) returned. If so, then the ESP stacks are also broken in a non-compliant way. Of course we can deal with that in the upper layers but it isn't a good direction to be moving in. One thing to try with the ESPs is simply adding the POLL_HUP | POLL_ERR flags to the uasyncio poll read and write calls and see how that changes your results. This is a socket ioctl API issue that may be impacting multiple targets. However, the lack of error handling in uasyncio's start_server loop seems to be a global issue. Yes, one can trap that error higher up, but crashing and restarting a server on every connection dropped before accept is not OK.

    As soon as I have a break here, I will clean all the excess debug notes in the related files and post a complete working example which is AFAIK, tested and ready for merge consideration.

  3. peterhinch commented on Oct 5, 2019

    @peterhinch
    Contributor

    As a very general comment the testing I and @kevinkk525 performed was to determine the response to dropped connections and, critically, to WiFi dropouts. The latter are commonplace. Any updates should be tested against these conditions in addition to testing against malicious peers.

    It's possible that uasncio.start_server is only used by Picoweb. We avoid it because of the fragility mentioned above.

  4. t35tB0t commented on Oct 6, 2019

    @t35tB0t
    Author

    After a few tweaks - there is nothing wrong with uasyncio.start_server, it is no longer fragile.

    Here's some simple testing tools crafted generate abandoned connections, reset connections etc. The sock_test.py script should be launched as a multiple subprocesses. This will generate a flood of overlapping reset HTTP connections. With randomized delays and multiple generators, all the code race conditions are eventually hit. To simulate the Wifi dropped connections, the scapy library is used with its lower level packet manipulations. The command-line launch info is commented in the scapy-cAbandon.py script. One of the options here will provide a simple SYN flood attack. But I suggest using the -v True mode for our purposes here.

    scapy-cAbandon.zip
    socket_test.zip

  5. t35tB0t commented on Oct 6, 2019

    @t35tB0t
    Author

    Here's a complete working test case with enhanced exception handling. Note that extra poll flags are included to force modlwip.c ioctl poll to return the POLL_HUP and POLL_ERR revents just in case you haven't re-compiled a fixed modlwip.c yet. This makes it "just work" in either case but should be considered debug code and removed at some point. The test case below was run on a STM32F767. Prior testing has been performed with a STM32F429. Scripting and object coding errors were correctly trapped and reported from all layers. Socket state errors were correctly handled (AFAICT) for floods of connection abandons, resets, and simple SYN attacks. In all cases browser was able to access the demo webapp GET / info page unless the stack was "jammed" where it would then wait until the nasty connections flood subsided and then would resume. The memory stats reporting isn't high-fidelity but it does at least show that there isn't any leakage after a lengthy barrage of bad connection behaviors. Compared to where we started, this works for me and IMHO it also addresses the popular WiFi dropout issue in addition to connection resets (at least from the aspect of not chewing up memory and crashing). If we have any other connection states we need to consider, let me know.

    As a sidenote - I noticed WireShark showing a browser (client) sending TCP keep-alive packets and LWIP (server) appeared to be responding correctly. I know there is an interest in using keep-alives to sustain connections. This is at least a positive indications that some of the mechanisms are in place and functioning. It may just be a matter of LWIP API to expose more of this within micropython environment?

    PYBFLASH_20191006.zip

    P.S. I apologise if there are any issues or offense with the copyright clauses or other header fluff - this is just text/example code.

  6. peterhinch commented on Oct 6, 2019

    @peterhinch
    Contributor

    I'm looking at your uasyncio changes with a view to implementing them in my fast_io fork. The only non-debug change seems to be in __init__.py in the start_server() function.

    I appreciate it isn't final code but the following two lines in __init__.py seem to be redundant:

    import sys

    and the final pass statement in start_server.

    The change seems benign: I can't see it breaking any existing code, although I appreciate it needs firmware changes to achieve its aim.

    Please let me know if I'm missing anything.

    I've also taken a quick look at PicoWeb. I notice that you issue

    reader.aclose()

    As I understand it StreamReader.aclose() is asynchronous so I'd expect to see

    yield from reader.aclose()
  7. t35tB0t commented on Oct 6, 2019

    @t35tB0t
    Author

    @peterhinch - correct, the sys import related to exception stack trace dumps debug code that I deleted. And, as I've been saying, the start_server() function has a race condition between its IORead(s) and s.accept that must be trapped at this layer or we end up crashing the entire server loop over spurious network behaviors. Sure, its a rare event but it certainly would be a head-scratcher for many users. Finally, the changes in PicoWeb appear simple but are a bit trickier.

    And, AFAICT, you don't need to yield on closing reader polling objects. Actually, doing so in an exception will result in a dictionary key error because of how the yielding works. I wasn't that smart - I added all the yeilds and watched it fail. So, the constructs that are in place will properly handle the exceptions we were able to trigger without internal errors and without hanging the client due incomplete HTTP exchanges.

    And its not perfect yet. After running all night, the enhanced exception trapping code tripped over an unusual exception type and it halted the execution. My original approach was to wrap all the errors in this middleware and blame the network but @pfalcon has raised the bar and we're trying to provide that solution.

    Traceback (most recent call last):
    File "main.py", line 18, in
    File "web.py", line 28, in run
    File "picoweb/init.py", line 309, in run
    File "uasyncio/core.py", line 109, in run_forever
    File "picoweb/init.py", line 210, in _handle
    IndexError: tuple index out of range
    MicroPython v1.11-292-g5641aa55d-dirty on 2019-09-25; NUCLEO-F767ZI with STM32F767
    Type "help()" for more information.`

    The offending PicoWeb Line 210 in the demo code is:
    if not uerrno.errorcode.get(e.args[0],False):

    I was just being curt when I wrote that line last night. So its vulnerable to e being an empty tuple. I suppose we need to beef this up a bit. Keep in mind that, when we simply pass on all errors here, we never crash. So this can't be a major resource issue and may very well just be another socket triggered error type that needs to be added to the exception handler here. I have an idea of how to beef this up and am running it now...

  8. t35tB0t commented on Oct 6, 2019

    @t35tB0t
    Author

    UPDATE: Made this modification in PicoWeb (and similar in e.args length trap in uasyncio start_server). This should show us what rare event is throwing the error with zero length e.args tuple. It may take several hours or all day to trip this error. I'll post the results when that happens...

        except Exception as e:
            if len(e.args)==0:
                # Handle exception as an unexpected unknonwn error: collect details here then close try to continue running
                sys.print_exception(e)
                reader.aclose()
                yield from writer.aclose()
                raise
            elif not uerrno.errorcode.get(e.args[0],False):
                # Handle exception as internal error: close and terminate handler (user must trap or crash)
                reader.aclose()
                yield from writer.aclose()
                raise
            else:
                # Handle exception as bad socket state: close and continue
                close=True
                if self.debug:
                    self.log.exc(e, "%.3f %s %s %r" % (utime.time(), req, writer, e))
    
  9. t35tB0t commented on Oct 6, 2019

    @t35tB0t
    Author
  10. peterhinch commented on Oct 7, 2019

    @peterhinch
    Contributor

    Apologies if I'm missing something but I still think issuing

    reader.aclose()

    will never close the socket: it will simply instantiate a generator object. The code is:

        def aclose(self):
            yield IOReadDone(self.polls)
            self.ios.close()

    Consider the following:

    def foo():
        yield 1
        print('foo')

    If I paste this at the REPL and issue

    >>> foo()
    <generator object 'foo' at 7f9b346d69a0>
    >>> 
    

    it does not print anything, but merely returns a generator instance.

  11. t35tB0t commented on Oct 7, 2019

    @t35tB0t
    Author
  12. peterhinch commented on Oct 7, 2019

    @peterhinch
    Contributor

    I can't see that the line

    reader.aclose()

    is doing anything useful. It's instantiating a generator and discarding it. Commenting the line out would prove the point one way or the other.

  13. t35tB0t commented on Oct 7, 2019

    @t35tB0t
    Author

    @peterhinch ...because you're correct. I had a prior test case where the web browser was hanging waiting for the server to finish. Adding these appeared to change the behavior and the remote client was able to happily complete its page rendering. Its likely that it they just slowed micropython down with enough of a delay for more bytes to get out on the wire before the writer.aclose killed the outbound packet in LWIP. I'll have to build back up to that more complex case and see if we can re-create it. Meanwhile, I've commented out the useless reader.aclose()s but will keep in mind that we may want to add a brief delay in the exception handler or more directly check status to permit awrite()en data to get out onto the wire before the socket evaporates. That's the best I've got on that at this time.

    The zip'd project below is where I've ended up after a more careful trace of the exceptions thrown at the various levels and locations. The illusive e.args empty tuple error was only seen twice and I couldn't determine where it may have come from. So, I've added some traps (and made it non-fatal) so we get more details when it ever happens again. It doubt we're 100% done with this yet but its running annoyingly crash-free at the moment which may sound odd but is not.

    Note: My primary long-run test at the moment is the socket reset because its pretty straight-forward. With the default STM32 socket limit of qty 5, I'm running three subprocesses of the socket_test randomly reset HTTP GETs. I tried adding more data after the HTTP header in the request but that didn't do anything interesting (ie no errors thrown and only slowed the connection rate down)

    PYBFLASH_20191007.zip

    (this now has more substantial changes plus some added cosmetic alterations for readabilty)

  14. peterhinch commented on Oct 7, 2019

    @peterhinch
    Contributor

    I've looked at the uasyncio changes.
    Re core.py I'd be surprised if there's a significant bug in core.py: it has been extensively tested. The added int() statements seem to do nothing as the quantities to which they apply are already integers:

                    delay = int(time.ticks_diff(t, tnow))

    and

    l = int(len(self.runq))

    Does the added

    except Exception as e:

    ever get called? I appreciate this may be debug code but if it gets called I would suspect that the underlying cause is an error in __init__.py. If this code is to be permanent, we need to be sure it won't affect normal exception handling in user coroutines.

    Re __init__.py You're clearly making good progress in addressing the socket handling.

    In start_server you have:

            except Exception as e:
                if len(e.args)==0:

    Are you getting exceptions without args? If so I think we need to find out where these are coming from as they may be indicative of a MicroPython bug. My understanding is that Python exceptions always have an args tuple with at least one element. Unless there is a legitimate case of which I'm unaware?

    Your zipfiles don't include your changes to extmod/modlwip.c. I'm not the best person to review this, but maybe someone with appropriate experience would take a look. When you have a version which you regard as final I could test for breakages to my existing libraries.

  15. t35tB0t commented on Oct 7, 2019

    @t35tB0t
    Author
  16. 12 remaining items

  17. dpgeorge commented on Oct 16, 2019

    @dpgeorge
    Member

    Thanks @t35tB0t and @peterhinch for the detailed report and discussion.

    extmod/modlwip.c only returns POLL_HUP and POLL_ERR if the events flags specify them. AFIAK this is not POSIX compliant which specifies these return events shall be unsolicited.

    I agree, it should be POSIX compliant. See #5222 for an attempted fix which is slightly different to the one submitted by @t35tB0t .

  18. dpgeorge commented on Oct 31, 2019

    @dpgeorge
    Member

    I'll close this issue because the original bug is fixed: POLLHUP and POLLERR (and now POLLNVAL) are unconditionally returned in socket polling. See 71401d5, feaa251 and 26d8fd2

  19. dpgeorge commented on Oct 31, 2019

    @dpgeorge
    Member

    Note that current uasyncio may actually handle the case of unsolicited POLLERR/POLLHUP being returned from poll because the unix port already has this behaviour.

  20. t35tB0t commented on Nov 1, 2019

    @t35tB0t
    Author
  21. added a commit that references this issue on Aug 19, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions