fix: restore parent IP-lookup state in pool workers under spawn/forkserver (#930)

* fix: restore parent IP-lookup state in pool workers under spawn/forkserver

parsedmarc.utils keeps the IPinfo Lite REST API token
(_IPINFO_API_TOKEN, configure_ipinfo_api) and the selected IP database
path (_IP_DB_PATH, load_ip_db) in process-global variables, and only
the parent process sets them. Under the fork start method a pool worker
inherits them ("All resources of the parent are inherited by the child
process", https://docs.python.org/3/library/multiprocessing.html), but
under spawn (macOS/Windows default) and forkserver (the Linux default
from Python 3.14) it does not ("The child process will only inherit
those resources necessary to run the process object's run() method").
A worker there started from scratch: its IPinfo API lookups were
silently skipped, and _get_ip_database_path() fell back to the bundled
MMDB instead of the downloaded or cached copy the parent selected, so
enrichment differed from the operator's configuration and from the same
run under fork. The CLI parses file inputs through the process pool
even with -n 1, so every file-based CLI run on such platforms was
affected.

parallel_map() now snapshots both globals in the parent and restores
them in every worker via its pool initializer, so IP enrichment matches
the parent's configuration under every start method. The initializer
previously only reconstructed logging and is renamed _init_worker
accordingly.

Fixes #928

Co-authored-by: ZCode Scout <noreply@zcode.local>
Signed-off-by: NefelibateLG <3453510037@qq.com>

* Maintainer edits: Unreleased changelog, PSL overrides, forkserver coverage

- CHANGELOG: move the entry under "## Unreleased" (feature/fix PRs never
  pick a version number) and describe the psl_overrides gap.
- Restore parsedmarc.utils.psl_overrides in pool workers as a separate
  initarg, in place ([:] =), never rebound. A fresh spawn/forkserver
  worker loads the bundled psl_overrides.txt at import and only switches
  to the operator's path/URL on its first reverse DNS map load, so
  get_base_domain() calls before that load (the first aggregate report's
  org_name, the first source IP's reverse DNS name) used the bundled list.
- Cross-reference _init_worker and cli._utils_globals_snapshot(), which
  enumerate the same three utils globals.
- Extend TestWorkerIPState to forkserver (where available) and to the
  PSL overrides list; without the restore, both subtests fail with the
  worker reporting the bundled list.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Signed-off-by: NefelibateLG <3453510037@qq.com>
Co-authored-by: ZCode Scout <noreply@zcode.local>
Co-authored-by: Sean Whalen <44679+seanthegeek@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-26 21:54:22 -04:00
1 parent 16a9016e2a
commit c7a9d294b5
5 files changed
+182 -13

No files matched your search

+90 -7
View File
@@ -9,7 +9,7 @@ from unittest.mock import patch
import parsedmarc
from parsedmarc.parallel import (
_init_worker_logging,
_init_worker,
_parse_report_email_job,
_parse_report_file_job,
parallel_map,
@@ -240,10 +240,11 @@ class TestWorkerLogging(_ParallelTestCase):
class TestInitWorkerLogging(unittest.TestCase):
"""_init_worker_logging must be usable directly as a pool initializer:
with no log files it sets the level (configure_logging still ensures
a console handler), and with log files it attaches a FileHandler per
path."""
"""_init_worker must be usable directly as a pool initializer: with no
log files it sets the level (configure_logging still ensures a console
handler), and with log files it attaches a FileHandler per path. The
IP-lookup state and PSL overrides it also restores are passed through
unchanged here so these tests exercise only the logging side."""
def setUp(self):
from parsedmarc.log import logger as plog
@@ -263,19 +264,31 @@ class TestInitWorkerLogging(unittest.TestCase):
plog.setLevel(self._saved_level)
def test_no_log_files_sets_level_only(self):
from parsedmarc import utils
from parsedmarc.log import logger as plog
_init_worker_logging(logging.WARNING, [])
_init_worker(
logging.WARNING,
[],
utils._get_ip_worker_state(),
list(utils.psl_overrides),
)
self.assertEqual(plog.level, logging.WARNING)
def test_log_files_attach_file_handlers(self):
from parsedmarc import utils
from parsedmarc.log import logger as plog
with tempfile.NamedTemporaryFile(suffix=".log", delete=False) as tf:
log_path = tf.name
self.addCleanup(lambda: os.path.exists(log_path) and os.remove(log_path))
_init_worker_logging(logging.DEBUG, [log_path])
_init_worker(
logging.DEBUG,
[log_path],
utils._get_ip_worker_state(),
list(utils.psl_overrides),
)
self.assertEqual(plog.level, logging.DEBUG)
file_handlers = [h for h in plog.handlers if isinstance(h, logging.FileHandler)]
@@ -284,6 +297,76 @@ class TestInitWorkerLogging(unittest.TestCase):
h.close()
def _ip_worker_state_job(_):
"""Read the worker process's IP-lookup state and PSL overrides so a
test can compare them with what the parent configured. Module-level and
picklable (a spawn child re-imports this module to unpickle it)."""
from parsedmarc import utils
return (utils._IPINFO_API_TOKEN, utils._IP_DB_PATH, list(utils.psl_overrides))
class TestWorkerIPState(unittest.TestCase):
"""parallel_map workers must make the same IPinfo API / MMDB selections
and use the same PSL overrides the parent configured, whatever the
start method.
parsedmarc.utils._IPINFO_API_TOKEN (configure_ipinfo_api),
parsedmarc.utils._IP_DB_PATH (load_ip_db), and the module-level
parsedmarc.utils.psl_overrides list (load_psl_overrides) are process
globals. Under ``fork`` a worker inherits them ("All resources of the
parent are inherited by the child process"), but under ``spawn`` and
``forkserver`` it does not ("The child process will only inherit those
resources necessary to run the process object's ``run()`` method") -
https://docs.python.org/3/library/multiprocessing.html. The pool
initializer therefore restores the parent's snapshot in every worker.
This test forces each non-fork start method so it guards that path on
every Python version, not only the ones whose default start method
already exercises it (Linux defaults to forkserver from 3.14, macOS
and Windows to spawn).
With the ``psl_overrides`` restore removed from ``_init_worker``, the
PSL assertion failed under both ``spawn`` and ``forkserver``: each
worker reported the bundled ``psl_overrides.txt`` list it loads at
import, not the parent's ``["example.test"]``.
"""
def test_workers_see_parent_ip_state_under_spawn_and_forkserver(self):
import multiprocessing
import parsedmarc.utils as utils
methods = [
m
for m in ("spawn", "forkserver")
if m in multiprocessing.get_all_start_methods()
]
self.assertIn("spawn", methods)
original_state = utils._get_ip_worker_state()
original_psl = list(utils.psl_overrides)
original_method = multiprocessing.get_start_method()
utils._set_ip_worker_state(
"worker-state-test-token", "/parent/only/ipinfo_lite.mmdb"
)
utils.psl_overrides[:] = ["example.test"]
self.addCleanup(utils._set_ip_worker_state, *original_state)
self.addCleanup(utils.psl_overrides.__setitem__, slice(None), original_psl)
self.addCleanup(multiprocessing.set_start_method, original_method, force=True)
for method in methods:
with self.subTest(start_method=method):
multiprocessing.set_start_method(method, force=True)
results = list(parallel_map(_ip_worker_state_job, range(8), n_procs=2))
self.assertEqual(len(results), 8)
for token, db_path, psl in results:
self.assertEqual(token, "worker-state-test-token")
self.assertEqual(db_path, "/parent/only/ipinfo_lite.mmdb")
self.assertEqual(psl, ["example.test"])
class TestParseReportEmailJob(_ParallelTestCase):
"""_parse_report_email_job must return a ParserError as a value (never
raise it) on an invalid message."""