Skip to content

Commit 24da777

Browse files
Pin _refresh_pid_cache mutation contract directly
The fork-after-init detection mechanism's producer side (``_refresh_pid_cache`` mutating the module-level ``_current_pid`` and being registered via ``os.register_at_fork(after_in_child=...)``) was structurally invisible to unit-test coverage — the function runs in the forked child, which calls ``os._exit(0)`` before flushing coverage. Existing fork-guard tests sidestep the cache by mutating ``_creator_pid`` directly, so a regression that drops ``global _current_pid`` (Python's "forgotten global" footgun, making the assignment local-scoped) would silently disable the entire mechanism without failing any unit test. Pin three properties directly: 1. The function mutates the module attribute when called (caller patches os.getpid to a sentinel, asserts _current_pid changes). 2. The function is callable zero-arg (matches register_at_fork's contract). 3. End-to-end: actual os.fork, child reads _current_pid, reports back via pipe, parent asserts the child's cache reflects the child's pid. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 24e9c32 commit 24da777

1 file changed

Lines changed: 113 additions & 0 deletions

File tree

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""Pin: ``_refresh_pid_cache`` mutates the module-level
2+
``_current_pid`` and is registered with ``os.register_at_fork``.
3+
4+
This is the producer side of the cycle-21 pid-cache fork-detection
5+
contract. The fork-guard tests exercise the *consumer* side
6+
(``_current_pid != _creator_pid`` raising ``InterfaceError``); they
7+
sidestep the cache by mutating ``_creator_pid`` directly. Coverage
8+
of the actual mutation body inside ``_refresh_pid_cache`` is
9+
structurally invisible to a unit-test run because it executes in
10+
the forked child, which calls ``os._exit(0)`` before flushing
11+
coverage data.
12+
13+
A regression that drops ``global _current_pid`` from
14+
``_refresh_pid_cache`` (Python's "forgotten global" footgun) would
15+
make the assignment local-scoped, leave the module attribute at
16+
the parent's pid forever, and the entire fork-after-init
17+
detection mechanism would silently disable. Without a unit test on
18+
the producer side, the regression would clear unit CI invisibly.
19+
20+
Pin three properties:
21+
1. The function name exists and is callable.
22+
2. The function actually mutates the module attribute (call it,
23+
then check the result by patching ``os.getpid`` to a sentinel).
24+
3. The function is registered as an ``after_in_child`` fork hook.
25+
"""
26+
27+
from __future__ import annotations
28+
29+
import os
30+
from unittest.mock import patch
31+
32+
from dqliteclient import connection as conn_mod
33+
34+
35+
def test_refresh_pid_cache_mutates_module_attribute() -> None:
36+
"""Calling _refresh_pid_cache must update connection._current_pid."""
37+
saved = conn_mod._current_pid
38+
sentinel = saved + 17
39+
try:
40+
with patch("dqliteclient.connection.os.getpid", return_value=sentinel):
41+
conn_mod._refresh_pid_cache()
42+
assert conn_mod._current_pid == sentinel, (
43+
"_refresh_pid_cache must assign os.getpid() to the module-level "
44+
"_current_pid; if a refactor dropped 'global _current_pid', the "
45+
"assignment becomes local-scoped and the fork-detection guard "
46+
"silently disables in forked children."
47+
)
48+
finally:
49+
# Restore so subsequent tests in the same process see the
50+
# real pid (the registered after_in_child callback also
51+
# restores it on the next fork, but other tests run in the
52+
# same process and rely on the cache being correct).
53+
conn_mod._current_pid = saved
54+
55+
56+
def test_refresh_pid_cache_is_callable_zero_arg() -> None:
57+
"""``os.register_at_fork(after_in_child=...)`` requires a
58+
zero-arg callable. Pin the signature directly so a refactor
59+
that adds a parameter (silently breaking the fork hook
60+
registration's call shape) is caught."""
61+
saved = conn_mod._current_pid
62+
try:
63+
result = conn_mod._refresh_pid_cache()
64+
finally:
65+
conn_mod._current_pid = saved
66+
assert result is None # PEP 257-style: side-effect function returns None
67+
68+
69+
def test_after_in_child_fork_actually_refreshes_cache() -> None:
70+
"""End-to-end: do an actual fork and confirm the child's
71+
``_current_pid`` matches the child's ``os.getpid()``. This is
72+
the truest test of the producer-side contract — coverage
73+
tooling can't see the child's execution but a pipe-based child→
74+
parent assertion-result reporter can."""
75+
if not hasattr(os, "fork"):
76+
return
77+
parent_pid = os.getpid()
78+
assert conn_mod._current_pid == parent_pid
79+
80+
r, w = os.pipe()
81+
pid = os.fork()
82+
if pid == 0:
83+
try:
84+
os.close(r)
85+
try:
86+
# In the child, after_in_child has fired.
87+
# _current_pid should now equal the child's pid.
88+
child_pid = os.getpid()
89+
cached = conn_mod._current_pid
90+
if cached == child_pid and cached != parent_pid:
91+
os.write(w, b"OK")
92+
else:
93+
os.write(
94+
w,
95+
f"FAIL: child_pid={child_pid} cached={cached} "
96+
f"parent_pid={parent_pid}".encode(),
97+
)
98+
except Exception as e: # noqa: BLE001
99+
os.write(w, f"WRONG:{type(e).__name__}:{e}".encode())
100+
finally:
101+
os.close(w)
102+
finally:
103+
os._exit(0)
104+
os.close(w)
105+
result = b""
106+
while True:
107+
chunk = os.read(r, 4096)
108+
if not chunk:
109+
break
110+
result += chunk
111+
os.close(r)
112+
os.waitpid(pid, 0)
113+
assert result == b"OK", f"child reported: {result!r}"

0 commit comments

Comments
 (0)