Skip to content

Fix socketpair() on platforms without _socket.socketpair - #334

Merged
mindflayer merged 4 commits into
mindflayer:mainfrom
simin-yuan:fix/socketpair-without-c-socketpair
Nov 3, 2026
Merged

mindflayer merged 4 commits into
mindflayer:mainfrom
simin-yuan:fix/socketpair-without-c-socketpair

Conversation

@simin-yuan

Copy link
Copy Markdown
Contributor

Fixes #333

Summary

Mocketizer() on Windows raises AttributeError: module '_socket' has no attribute 'socketpair' as soon as asyncio builds an event loop, because
mock_socketpair() calls the C function unconditionally and that function is
POSIX-only. This builds the loopback pair from the real socket class when the C
function is missing.

Root cause

On Windows socket.socketpair() is implemented in Python: it binds an
AF_INET listener on 127.0.0.1, connects a second socket to it, and returns
the pair. That implementation resolves the module-level socket name, which
the injection layer has already swapped for MocketSocket, so it cannot build
the pair while the mock layer is active.

Change

mocket/socket.py:

  • mock_socketpair() keeps using _socket.socketpair() where it exists.
  • Otherwise it calls _loopback_socketpair(), which builds the pair from
    true_socket, binds 127.0.0.1 or ::1, and takes the accepted descriptor
    through _accept() so the accepted side is not wrapped into MocketSocket
    either.
  • The pair is authenticated the same way CPython's fallback does since the
    CVE-2024-3219 fix: getpeername() and getsockname() on both ends have to
    agree, and a mismatch closes both sockets and raises ConnectionError.
  • socket.socket stays patched for the whole call, so mocking is unaffected
    for everything except the loopback pair the event loop makes for itself.

Tests

New tests/test_socketpair.py:

  • test_socketpair_returns_a_real_connected_pair: byte round trip both ways.
  • test_asyncio_event_loop_can_be_created: asyncio.new_event_loop() inside
    Mocketizer().
  • test_socketpair_does_not_disable_the_mock_layer: socket.socket is still
    MocketSocket after socketpair() returns, and the returned pair is not a
    MocketSocket.
  • test_mock_socketpair_without_the_c_function: removes _socket.socketpair
    with monkeypatch, so the new branch is exercised on Linux CI as well.
  • test_loopback_socketpair_rejects_a_foreign_peer: a connection from another
    local peer is accepted by the listener first, and the call must raise
    ConnectionError. Without the authentication step this test fails.

New tests:

CPython main with the patch
3.11.16 5 failed 5 passed
3.12.14 5 failed 5 passed
3.13.13 5 failed 5 passed

Existing suite (test_socket, test_inject, test_asyncio, test_utils,
test_mocket, test_compat), same failure set with and without the patch:

CPython main with the patch
3.11.16 13 failed, 60 passed, 1 skipped 13 failed, 60 passed, 1 skipped
3.12.14 3 failed, 70 passed, 1 skipped 3 failed, 70 passed, 1 skipped
3.13.13 3 failed, 70 passed, 1 skipped 3 failed, 70 passed, 1 skipped

The remaining failures are pre-existing on Windows and unrelated to this
patch. ruff check and ruff format --check pass on the touched files. No
change on POSIX: _socket.socketpair() is still the path taken there.

Related: kiwicom/pytest-recording#212 and kiwicom/pytest-recording#213. Same
class of bug in another network-blocking tool, where the loopback pair created
by socketpair() was being treated as an outbound connection.

Windows has no _socket.socketpair(), so socket.socketpair() falls back to a
pure-Python implementation built on an AF_INET loopback pair. mocket replaced
socket.socket and socket.socketpair, so that fallback either resolved to
MocketSocket or raised AttributeError, and any asyncio event loop created
inside Mocketizer() died before the test body ran.

Build the pair from true_socket when the C function is missing, take the
accepted descriptor through _accept() so it is not wrapped back into
MocketSocket, and authenticate the peer the same way CPython's fallback does
since the CVE-2024-3219 fix. socket.socket stays patched, so mocking is
unchanged for everything except the loopback pair the event loop makes.

Refs kiwicom/pytest-recording#212, kiwicom/pytest-recording#213.
@mindflayer

Copy link
Copy Markdown
Owner

Hi @simin-yuan, please have a look at the failing pipeline.

tests/test_http_gevent.py patches socket at import time, so the hijack has to be installed on mocket.socket.true_socket.

Signed-off-by: Simin Yuan <281164480+simin-yuan@users.noreply.github.com>
@simin-yuan

Copy link
Copy Markdown
Contributor Author

Hi @mindflayer, thanks for approving the run. The failure was in the test, not in the fix: tests/test_http_gevent.py patches socket at import time, so by the time the suite runs, socket.socket is gevent's class and the test's hijack never reached mocket.socket.true_socket. The guard saw our own socket and correctly did not raise. The test now patches true_socket directly, and it still fails if the peer check is removed.

_loopback_socketpair() connects with setblocking(False) -- the same trick
CPython's own fallback uses -- and counts on the accept() that follows to
wait for the handshake, because only then is getpeername() meaningful.
The hijacked _accept() here returned a foreign peer without waiting, so on
Linux csock was still in SYN_SENT when the CVE check called getpeername()
and the test failed with ENOTCONN instead of the ConnectionError it is
about. Windows connects to the loopback synchronously and never showed it.

Drain the real connection inside the hijack, then close it.

Signed-off-by: Simin Yuan <281164480+simin-yuan@users.noreply.github.com>
@simin-yuan

Copy link
Copy Markdown
Contributor Author

The 3.13 failure was in the test, not the patch: it mocked accept() away, so on Linux the non-blocking connect was still in SYN_SENT when getpeername() ran. The test now drains the real accept first. Full matrix (3.8–3.15, pypy) passes on my fork: https://github.com/simin-yuan/python-mocket/actions/runs/41505783453

@codecov

codecov Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.88%. Comparing base (17812a9) to head (55a62de).

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #334      +/-   ##
==========================================
+ Coverage   98.85%   98.88%   +0.03%     
==========================================
  Files          22       22              
  Lines        1224     1261      +37     
==========================================
+ Hits         1210     1247      +37     
  Misses         14       14              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@mindflayer

Copy link
Copy Markdown
Owner

Hi @simin-yuan, the only remaining thing is having a look at codecov's results and add what's missing. Many thanks for your contribution!

The fallback only had its happy path covered, so the checks that guard
the loopback pair had nothing behind them:

- an address family other than AF_INET/AF_INET6, a socket type other
  than SOCK_STREAM and a non-zero protocol are refused before any
  socket is created;
- when accept() fails after the client has connected, the client half
  is closed instead of leaked;
- when the peer check added for CVE-2024-3219 rejects the connection,
  both halves are closed;
- _socket.socketpair() is used as is when the interpreter has it.

The IPv6 case connects over ::1 and is skipped where IPv6 loopback is
not available.

Signed-off-by: Simin Yuan <281164480+simin-yuan@users.noreply.github.com>
@sonarqubecloud

sonarqubecloud Bot commented Nov 3, 2026

Copy link
Copy Markdown

@simin-yuan

Copy link
Copy Markdown
Contributor Author

Thanks! I pushed 55a62de with tests for the error paths of the loopback socketpair fallback: the IPv6 (::1) branch, invalid arguments (AF_UNSPEC / SOCK_DGRAM / proto=6), closing the client socket when accept fails, closing both sockets on a peer mismatch, and the _socket.socketpair fast path. mocket/socket.py is unchanged in this commit.
Locally the previously uncovered lines are now covered, and the new tests also pass with gevent's monkey.patch_socket() applied, as in CI.
The workflow run for the new commit is waiting for approval. Could you approve it when you have a moment?

@mindflayer
mindflayer merged commit dea9d5e into mindflayer:main Nov 3, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Mocketizer breaks asyncio on Windows: module '_socket' has no attribute 'socketpair'

2 participants