Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement Unix Socket Controller #247

Merged
merged 47 commits into from
Feb 25, 2021
Merged

Conversation

pepoluan
Copy link
Collaborator

@pepoluan pepoluan commented Feb 12, 2021

What do these changes do?

Add a new controller class, UnixSocketController, that listens on a Unix socket file instead of IP:port

Are there changes in behavior for the user?

Due to decomposition of Controller into BaseThreadedController + (pared down) Controller, user expecting Controller to not have a superclass is in for a surprise.

Other than that, there should be no problems.

Related issue number

Closes #114

Checklist

  • I think the code is well written
  • Unit tests for the changes exist
  • tox testenvs have been executed in the following environments:
    • Windows 10 (via PyCharm tox runner): (ALL)
    • Windows 10 (via PSCore 7.1.1): (ALL)
    • Windows 10 (via Cygwin): qa,py-nocov
    • Ubuntu 18.04 on WSL 1.0: (ALL) + pypy3-{nocov,cov,diffcov}
    • FreeBSD 12.2 on VBox: (ALL) + pypy3-{nocov,cov,diffcov}
    • OpenSUSE Leap 15.2 on VBox: (ALL) + pypy3-{nocov,cov,diffcov}
  • Documentation reflects the changes
  • Add a news fragment into the NEWS.rst file

Split the BaseThreadedController logic from Controller, and make
BaseThreadedController an Abstract Base Class.

This is in preparation of implementing the UnixSocketController, which
shares lots of logic, but not quite, with Controller.
Also exclude this class from testing if on Windows.
Too noisy. log.warning is more subtle but still provide ample warning.
So we can ensure that it works.

A test case accompanies this change.
Because that is its purpose: To trigger creation of the SMTP server.
* BaseThreadedController is its own entry
* Controller reduced to just the difference with BaseThreadedController
* UnixSocketController added in same vein
Because pytest's doctest runner can't conditionally skip on blocks.
* Indentation: the ":skipif:" option must be aligned with the doctest
* AF_UNIX instead of AF_INET
* Grab first response after connecting
* Some proper SMTP exchange
* Closing code
* And replace a blank line with >>>
* Remove unused things
* Update Sphinx requirement
@pepoluan pepoluan added this to the 1.4 milestone Feb 12, 2021
I forgot that the "unix_socket" is a different group, and thus have
different imports, classes, etc.

Was not visible in Windows because it's skipped, but it exploded on
Linux

So rather than using `ExampleHandler` (which was a lengthy custom class)
, we simply use Sink instead.
While we're at it, let's "decorate" that lone controller.stop() line.
Now that we run sphinx-build directly and not through setup.py, let's
add the "--color" options to make life more colorful :)
Copy link
Collaborator Author

@pepoluan pepoluan left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here are some comments for some possibly interesting points...

pytest -v aiosmtpd/docs
python setup.py build_sphinx
sphinx-build -b doctest -d build/.doctree aiosmtpd/docs build/doctest
sphinx-build -b html -d build/.doctree aiosmtpd/docs build/html
Copy link
Collaborator Author

@pepoluan pepoluan Feb 12, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pytest doctest can't conditionally skip blocks. Which we need to do on Win32.

So we now use Sphinx' own sphinx.ext.doctest.

Comment on lines -112 to -165
srv_coro: Coroutine = self.loop.create_server(
self._factory_invoker,
host=self.hostname,
port=self.port,
ssl=self.ssl_context,
)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shifted to self._create_server to allow different methods of listening.

Comment on lines 155 to 156
if not ready_event.is_set():
raise TimeoutError("SMTP server failed to start within allotted time")
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously we just wait and didn't actually do anything if timeout is reached.

This is now fixed, inspired by #244

Comment on lines 236 to 272
class UnixSocketController(BaseThreadedController): # pragma: on-win32
def __init__(
self,
handler,
unix_socket: Optional[Union[str, Path]],
loop=None,
*,
ready_timeout=1.0,
ssl_context=None,
# SMTP parameters
server_hostname: str = None,
**SMTP_parameters,
):
super().__init__(
handler,
loop,
ready_timeout=ready_timeout,
ssl_context=ssl_context,
server_hostname=server_hostname,
**SMTP_parameters
)
self.unix_socket = str(unix_socket)

def _create_server(self) -> Coroutine:
return self.loop.create_unix_server(
self._factory_invoker,
path=self.unix_socket,
ssl=self.ssl_context,
)

def _trigger_server(self):
with ExitStack() as stk:
s: socket = stk.enter_context(socket(AF_UNIX, SOCK_STREAM))
s.connect(self.unix_socket)
if self.ssl_context:
s = stk.enter_context(self.ssl_context.wrap_socket(s))
_ = s.recv(1024)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New class! The gist of Issue #114

It's amazing how simple the diffcode is after the shared code is extracted to BaseThreadedController

@@ -51,21 +47,23 @@ def syspath_insert(pth: Path):

# autoprogramm needs Sphinx>=1.2.2
# :classmethod: needs Sphinx>=2.1
needs_sphinx = "2.1"
# :noindex: needs Sphinx>=3.2
needs_sphinx = "3.2"
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RTD uses 3.3.1 so this is still safe.

Comment on lines 37 to 38
def in_win32():
return platform.system().casefold() == "windows"
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UnixSocketController is not testable on Windows 32.

Comment on lines 163 to 164
.. doctest:: unix_socket
:skipif: in_win32
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The :skipif: there is why I revert from using pytest doctest and change to using sphinx-build -b doctest instead.

Comment on lines 89 to 96
def test_reuse_loop(self, temp_event_loop):
cont = Controller(Sink(), loop=temp_event_loop)
assert cont.loop is temp_event_loop
try:
cont.start()
assert cont.smtpd.loop is temp_event_loop
finally:
cont.stop()
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apparently we haven't test the loop behavior before...

Test it as well.

Comment on lines +6 to +7
# addopts = """--doctest-glob="*.rst" --strict-markers -rfEX"""
addopts = """--strict-markers -rfEX"""
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're now using Sphinx's sphinx.ext.doctest which is more flexible.

Comment on lines +72 to +73
sphinx-build --color -b doctest -d build/.doctree aiosmtpd/docs build/doctest
sphinx-build --color -b html -d build/.doctree aiosmtpd/docs build/html
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes things a bit faster, actually, since the doctest builder and the html builder shared the same doctree.

Also, add expected message for TimeoutError
@codecov
Copy link

codecov bot commented Feb 12, 2021

Codecov Report

Merging #247 (db6ee28) into master (be24d86) will not change coverage.
The diff coverage is 100.00%.

Impacted file tree graph

@@            Coverage Diff            @@
##            master      #247   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files            7         7           
  Lines         1571      1606   +35     
  Branches       293       297    +4     
=========================================
+ Hits          1571      1606   +35     
Impacted Files Coverage Δ
aiosmtpd/smtp.py 100.00% <ø> (ø)
aiosmtpd/__init__.py 100.00% <100.00%> (ø)
aiosmtpd/controller.py 100.00% <100.00%> (ø)

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update be24d86...db6ee28. Read the comment docs.

@pepoluan
Copy link
Collaborator Author

Ugh, got hit by "AF_UNIX path too long" issue on MacOS 😑

Same issue as aio-libs/aiohttp#3572

Their fix is a bit too complex for my liking, though ... Let me write a simpler fixture for aiosmtpd 😉

We can now skip the assert; useful for exception handling in which
stop() needs to be manually invoked, but thread possibly hasn't started
yet.

Also, added some ref-cleaning to assist gc.

Finally, we added some test cases.
By setting its scope to "module", so it will be called -- and cleaned up
 -- exactly once.
Comment on lines 170 to 173
if not self._factory_invoked.wait(self.ready_timeout):
raise TimeoutError("SMTP server not responding within allotted time")
if self._thread_exception is not None:
raise self._thread_exception
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If for some reasons factory() or _factory_invoker() take their sweet time in doing their job, we might miss a "late exception" because when main thread progresses to if self._thread_exception line there, _factory_invoker() might not have the opportunity yet to set the self._thread_exception variable.

Ideally, the timeout should be "what's left after waiting for create_server()", but that adds more complexity and I'm reluctant to do that.

Comment on lines +187 to +192
def stop(self, no_assert=False):
assert no_assert or self._thread is not None, "SMTP daemon not running"
self.loop.call_soon_threadsafe(self._stop)
self._thread.join()
self._thread = None
if self._thread is not None:
self._thread.join()
self._thread = None
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We now can bypass the assert test. Helpful for external exception handling because at that time, thread is possibly not started yet.

Comment on lines 44 to 50
def factory(self):
time.sleep(self.ready_timeout * 3)
return super().factory()

def _factory_invoker(self):
time.sleep(self.ready_timeout * 3)
return super()._factory_invoker()
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Despite the HUGE multiplier (total is 6), this does not affect test speed because test speed depends solely on the ready_timeout parameter.

@pepoluan
Copy link
Collaborator Author

If no issues, let's merge this by Wednesday (2021-02-17)

Due to the urgent bugfix of #248, we'll squamerge this not earlier than Friday (2021-02-19). To give time to marinate this PR after merging the changed master into this PR.

# Conflicts:
#	aiosmtpd/__init__.py
#	aiosmtpd/controller.py
#	aiosmtpd/docs/NEWS.rst
#	aiosmtpd/docs/controller.rst
#	aiosmtpd/tests/test_server.py
# Conflicts:
#	aiosmtpd/__init__.py
#	aiosmtpd/docs/controller.rst
# Conflicts:
#	.github/workflows/unit-testing-and-coverage.yml
#	aiosmtpd/docs/NEWS.rst
#	aiosmtpd/docs/conf.py
#	tox.ini
Artefact due to merging with master.

After all, we have changed to using sphinx-build doctest (5156b4d)
In accordance with the Developer's Guidelines:

https://github.com/aio-libs/aiosmtpd/wiki/Configuration

Also, fix a typo in tox.ini
Cygwin's AF_UNIX is emulated using AF_INET, and there are quirks that
will cause problems with how we use AF_UNIX.

So we disable the doctest on Cygwin as well, disable the whole Test
Class, and exclude the UnixSocketController from coverage.
# Conflicts:
#	.gitignore
#	aiosmtpd/docs/NEWS.rst
#	pyproject.toml
#	tox.ini
pytest -v aiosmtpd/docs
sphinx-build --color -b html -d build/.doctree aiosmtpd/docs build/html
sphinx-build --color -b man -d build/.doctree aiosmtpd/docs build/man
sphinx-build --color -b doctest -d build/.doctree aiosmtpd/docs build/doctest
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need selective doctest (UnixSocketController doesn't work on Windows). pytest can't do that, but sphinx-build can.

A side benefit: No longer receive the DeprecationWarning warning :-)

_ = s.recv(1024)


class UnixSocketController(BaseThreadedController): # pragma: on-win32 on-cygwin
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This class doesn't work properly on Windows and/or Cygwin.

Comment on lines +153 to +157
doctest_global_setup = """
import sys
in_win32 = sys.platform == "win32"
in_cygwin = sys.platform == "cygwin"
"""
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to check for Cygwin as well (UnixSocketController does not work properly on Cygwin)

Comment on lines +74 to +91
@pytest.fixture(scope="module")
def safe_socket_dir() -> Generator[Path, None, None]:
# See:
# - https://github.com/aio-libs/aiohttp/issues/3572
# - https://github.com/aio-libs/aiohttp/pull/3832/files
# - https://unix.stackexchange.com/a/367012/5589
tmpdir = Path(mkdtemp()).absolute()
assert len(str(tmpdir)) <= 87 # 92 (max on HP-UX) minus 5 (allow 4-char fn)
#
yield tmpdir
#
plist = [p for p in tmpdir.rglob("*")]
for p in reversed(plist):
if p.is_dir():
p.rmdir()
else:
p.unlink()
tmpdir.rmdir()
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To create socket successfully in MacOS. The GA runner path is too deep on MacOS.

Comment on lines -111 to -114
[flake8]
jobs = 1
max-line-length = 88
ignore = E123, E133, W503, W504, W293, E203
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved to setup.cfg. See wiki for reasoning.

@pepoluan
Copy link
Collaborator Author

As soon as this last commit passes green, I'll squamerge.

@pepoluan pepoluan merged commit 0da45f7 into aio-libs:master Feb 25, 2021
@pepoluan pepoluan deleted the socket-controller branch February 26, 2021 07:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Support for UNIX sockets in the Controller
1 participant