diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cf707d07..c5075a32 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -76,7 +76,13 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest] - cibw_python: ["cp38-*", "cp39-*", "cp310-*", "cp311-*", "cp312-*"] + cibw_python: + - "cp38-*" + - "cp39-*" + - "cp310-*" + - "cp311-*" + - "cp312-*" + - "cp313-*" cibw_arch: ["x86_64", "aarch64", "universal2"] exclude: - os: ubuntu-latest @@ -108,7 +114,7 @@ jobs: run: | brew install gnu-sed libtool autoconf automake - - uses: pypa/cibuildwheel@fff9ec32ed25a9c576750c91e06b410ed0c15db7 # v2.16.2 + - uses: pypa/cibuildwheel@bd033a44476646b606efccdd5eed92d5ea1d77ad # v2.20.0 env: CIBW_BUILD_VERBOSITY: 1 CIBW_BUILD: ${{ matrix.cibw_python }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 345362fd..ed6accb9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -15,7 +15,13 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: + - "3.8" + - "3.9" + - "3.10" + - "3.11" + - "3.12" + - "3.13" os: [ubuntu-latest, macos-latest] env: @@ -42,6 +48,7 @@ jobs: if: steps.release.outputs.version == 0 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Install macOS deps if: matrix.os == 'macos-latest' && steps.release.outputs.version == 0 @@ -50,8 +57,10 @@ jobs: - name: Install Python Deps if: steps.release.outputs.version == 0 + env: + PIP_PRE: ${{ matrix.python-version == '3.13' && '1' || '0' }} run: | - pip install -e .[test] + pip install -e .[test,dev] - name: Test if: steps.release.outputs.version == 0 @@ -59,8 +68,7 @@ jobs: make test - name: Test (debug build) - # XXX Re-enable 3.12 once we migrate to Cython 3 - if: steps.release.outputs.version == 0 && matrix.python-version != '3.12' + if: steps.release.outputs.version == 0 run: | make distclean && make debug && make test diff --git a/pyproject.toml b/pyproject.toml index 85c59bf5..a656b4f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Programming Language :: Python :: Implementation :: CPython", "Topic :: System :: Networking", ] diff --git a/setup.py b/setup.py index 22a61e0c..32d94ae8 100644 --- a/setup.py +++ b/setup.py @@ -140,6 +140,7 @@ def finalize_options(self): v = True directives[k] = v + self.cython_directives = directives self.distribution.ext_modules[:] = cythonize( self.distribution.ext_modules, diff --git a/tests/test_unix.py b/tests/test_unix.py index 8adedeef..0d670e39 100644 --- a/tests/test_unix.py +++ b/tests/test_unix.py @@ -96,10 +96,18 @@ async def start_server(): self.assertFalse(srv.is_serving()) - # asyncio doesn't cleanup the sock file - self.assertTrue(os.path.exists(sock_name)) + if sys.version_info < (3, 13): + # asyncio doesn't cleanup the sock file under Python 3.13 + self.assertTrue(os.path.exists(sock_name)) + else: + self.assertFalse(os.path.exists(sock_name)) + + async def start_server_sock(start_server, is_unix_api=True): + # is_unix_api indicates whether `start_server` is calling + # `loop.create_unix_server()` or `loop.create_server()`, + # because asyncio `loop.create_server()` doesn't cleanup + # the socket file even if it's a UNIX socket. - async def start_server_sock(start_server): nonlocal CNT CNT = 0 @@ -140,8 +148,11 @@ async def start_server_sock(start_server): self.assertFalse(srv.is_serving()) - # asyncio doesn't cleanup the sock file - self.assertTrue(os.path.exists(sock_name)) + if sys.version_info < (3, 13) or not is_unix_api: + # asyncio doesn't cleanup the sock file under Python 3.13 + self.assertTrue(os.path.exists(sock_name)) + else: + self.assertFalse(os.path.exists(sock_name)) with self.subTest(func='start_unix_server(host, port)'): self.loop.run_until_complete(start_server()) @@ -160,7 +171,7 @@ async def start_server_sock(start_server): lambda sock: asyncio.start_server( handle_client, None, None, - sock=sock))) + sock=sock), is_unix_api=False)) self.assertEqual(CNT, TOTAL_CNT) def test_create_unix_server_2(self): @@ -455,16 +466,13 @@ def test_create_unix_server_path_stream_bittype(self): socket.AF_UNIX, socket.SOCK_STREAM | socket.SOCK_NONBLOCK) with tempfile.NamedTemporaryFile() as file: fn = file.name - try: - with sock: - sock.bind(fn) - coro = self.loop.create_unix_server(lambda: None, path=None, - sock=sock) - srv = self.loop.run_until_complete(coro) - srv.close() - self.loop.run_until_complete(srv.wait_closed()) - finally: - os.unlink(fn) + with sock: + sock.bind(fn) + coro = self.loop.create_unix_server(lambda: None, path=None, + sock=sock, cleanup_socket=True) + srv = self.loop.run_until_complete(coro) + srv.close() + self.loop.run_until_complete(srv.wait_closed()) @unittest.skipUnless(sys.platform.startswith('linux'), 'requires epoll') def test_epollhup(self): diff --git a/uvloop/handles/pipe.pyx b/uvloop/handles/pipe.pyx index d30a7366..4b95ed6e 100644 --- a/uvloop/handles/pipe.pyx +++ b/uvloop/handles/pipe.pyx @@ -80,6 +80,27 @@ cdef class UnixServer(UVStreamServer): context) return tr + cdef _close(self): + sock = self._fileobj + if sock is not None and sock in self._loop._unix_server_sockets: + path = sock.getsockname() + else: + path = None + + UVStreamServer._close(self) + + if path is not None: + prev_ino = self._loop._unix_server_sockets[sock] + del self._loop._unix_server_sockets[sock] + try: + if os_stat(path).st_ino == prev_ino: + os_unlink(path) + except FileNotFoundError: + pass + except OSError as err: + aio_logger.error('Unable to clean up listening UNIX socket ' + '%r: %r', path, err) + @cython.no_gc_clear cdef class UnixTransport(UVStream): diff --git a/uvloop/includes/stdlib.pxi b/uvloop/includes/stdlib.pxi index e7957fe2..4152b8a7 100644 --- a/uvloop/includes/stdlib.pxi +++ b/uvloop/includes/stdlib.pxi @@ -112,6 +112,7 @@ cdef os_pipe = os.pipe cdef os_read = os.read cdef os_remove = os.remove cdef os_stat = os.stat +cdef os_unlink = os.unlink cdef os_fspath = os.fspath cdef stat_S_ISSOCK = stat.S_ISSOCK diff --git a/uvloop/loop.pxd b/uvloop/loop.pxd index 56134733..01e39ae1 100644 --- a/uvloop/loop.pxd +++ b/uvloop/loop.pxd @@ -58,6 +58,7 @@ cdef class Loop: set _processes dict _fd_to_reader_fileobj dict _fd_to_writer_fileobj + dict _unix_server_sockets set _signals dict _signal_handlers diff --git a/uvloop/loop.pyx b/uvloop/loop.pyx index 24df3e8a..f9a5a239 100644 --- a/uvloop/loop.pyx +++ b/uvloop/loop.pyx @@ -50,6 +50,7 @@ include "errors.pyx" cdef: int PY39 = PY_VERSION_HEX >= 0x03090000 int PY311 = PY_VERSION_HEX >= 0x030b0000 + int PY313 = PY_VERSION_HEX >= 0x030d0000 uint64_t MAX_SLEEP = 3600 * 24 * 365 * 100 @@ -155,6 +156,8 @@ cdef class Loop: self._fd_to_reader_fileobj = {} self._fd_to_writer_fileobj = {} + self._unix_server_sockets = {} + self._timers = set() self._polls = {} @@ -1704,7 +1707,10 @@ cdef class Loop: 'host/port and sock can not be specified at the same time') return await self.create_unix_server( protocol_factory, sock=sock, backlog=backlog, ssl=ssl, - start_serving=start_serving) + start_serving=start_serving, + # asyncio won't clean up socket file using create_server() API + cleanup_socket=False, + ) server = Server(self) @@ -2089,7 +2095,7 @@ cdef class Loop: *, backlog=100, sock=None, ssl=None, ssl_handshake_timeout=None, ssl_shutdown_timeout=None, - start_serving=True): + start_serving=True, cleanup_socket=PY313): """A coroutine which creates a UNIX Domain Socket server. The return value is a Server object, which can be used to stop @@ -2114,6 +2120,11 @@ cdef class Loop: ssl_shutdown_timeout is the time in seconds that an SSL server will wait for completion of the SSL shutdown before aborting the connection. Default is 30s. + + If *cleanup_socket* is true then the Unix socket will automatically + be removed from the filesystem when the server is closed, unless the + socket has been replaced after the server has been created. + This defaults to True on Python 3.13 and above, or False otherwise. """ cdef: UnixServer pipe @@ -2191,6 +2202,15 @@ cdef class Loop: # we want Python socket object to notice that. sock.setblocking(False) + if cleanup_socket: + path = sock.getsockname() + # Check for abstract socket. `str` and `bytes` paths are supported. + if path[0] not in (0, '\x00'): + try: + self._unix_server_sockets[sock] = os_stat(path).st_ino + except FileNotFoundError: + pass + pipe = UnixServer.new( self, protocol_factory, server, backlog, ssl, ssl_handshake_timeout, ssl_shutdown_timeout)