fix(runner): remove __getstate__/__setstate__, load driver hooks at init#1267
Conversation
PR #1126 fixed dynamic driver hooks on workers by (1) discovering external drivers at worker startup (celery.py IN_WORKER) and (2) adding a custom __getstate__/__setstate__ pair that strips hooks on pickle and re-registers them from context['drivers'] on every unpickle. Because (1) already makes every secator.hooks.<driver> module importable on the worker, hook functions pickle/unpickle natively by qualified name, making (2) redundant. Worse, __setstate__ calls register_hooks() (one debug log per hook) on every unpickle, and Celery's replace()/chord synchronization re-deserialize the runner-bearing signatures O(chunks) times — so a chunked task with SECATOR_DEBUG=runner emitted hundreds of thousands of "hook registered" lines. Changes: - register_hooks() is now idempotent (skip a hook already present, and stop mutating a shared module-level HOOKS list in place). Keeps both driver-hook sources — explicit hooks= (library callers) and context['drivers'] — without double-registering. Only actually-registered hooks are logged. - New _apply_context_drivers(), called once in __init__, loads context['drivers'] hooks at construction (the logic __setstate__ used to run on unpickle). Every runner — initial dispatch, chunk task rebuilt by run_command, chord callback — gets its driver hooks exactly once. Registering on the client is inert because dispatching runners set enable_hooks=False (run_hooks gates firing on it). - Remove __getstate__/__setstate__. Native pickling preserves the already- registered resolved_hooks across the Celery boundary; nothing re-registers on unpickle, so the flood is gone. Also fixes a latent regression for library callers passing hooks=HOOKS with no context['drivers'] (their hooks were stripped on pickle and never restored on the worker). Tests: new tests/unit/test_runner_hooks.py (init-time load, native pickle survival with zero re-registration, idempotent registration, library-mode hooks=); updated TestRunnerPickle to the native-pickling contract. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P5vSjfkBuGAAHdKxHS3ySm
WalkthroughRunner initialization now applies context-driven hooks via a new ChangesDriver Hook Lifecycle Fix
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant Init as Runner.__init__
participant Apply as _apply_context_drivers
participant Discover as discover_external_drivers
participant Register as register_hooks
Init->>Register: register_hooks(hooks)
Init->>Apply: _apply_context_drivers()
Apply->>Discover: discover and order drivers
Discover-->>Apply: driver modules
Apply->>Apply: import HOOKS, deep-merge
Apply->>Register: register_hooks(merged_hooks)
Register->>Register: skip hooks already in resolved_hooks
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (2)
secator/runners/_base.py (1)
468-515: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueLogic is sound; consider deduplicating the driver-hook loading path.
_apply_context_drivers()and_apply_profile_drivers()(Lines 1527-1567) share the same core sequence:discover_external_drivers()→import_dynamic('secator.hooks.{driver}', 'HOOKS')→deep_merge_dicts(*hooks_list)→register_hooks(...). Extracting a small helper (e.g._load_driver_hooks(drivers) -> merged_hooks) would remove the duplication and keep the discovery/merge behavior in one place.Note: the
_apply_profile_driversdocstring/comment still reference the now-removedRunner.__setstate__(Lines 1500, 1531) — worth updating while touching this area.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@secator/runners/_base.py` around lines 468 - 515, Extract the repeated driver-hook loading sequence from _apply_context_drivers() and _apply_profile_drivers() into a shared helper that handles discover_external_drivers(), import_dynamic('secator.hooks.{driver}', 'HOOKS'), deep_merge_dicts(*hooks_list), and the register_hooks call. Keep the context/profile-specific selection logic in each caller, and update the surrounding docstring/comments in Runner to remove references to the removed __setstate__ path.tests/unit/test_runner_hooks.py (1)
38-56: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valuePrefer
addClassCleanupfor robust restoration of global state.
setUpClassmutates process-global state (CONFIG.dirs.templatesand thediscover_external_driverscache). If any step after Line 44 raises (e.g.discover_external_drivers()on Line 48),tearDownClassis not invoked andCONFIG.dirs.templatesis left pointing at a temp dir that later gets cleaned up — polluting subsequent test classes. Registering cleanups right after each mutation guarantees restoration even on partial-setup failure.♻️ Suggested restructure
`@classmethod` def setUpClass(cls): cls._tmpdir = tempfile.TemporaryDirectory() + cls.addClassCleanup(cls._tmpdir.cleanup) tmp = Path(cls._tmpdir.name) (tmp / 'mydriver.py').write_text(CUSTOM_DRIVER) cls._orig_templates = CONFIG.dirs.templates CONFIG.dirs.templates = tmp + cls.addClassCleanup(setattr, CONFIG.dirs, 'templates', cls._orig_templates) + cls.addClassCleanup(sys.modules.pop, 'secator.hooks.mydriver', None) + cls.addClassCleanup(discover_external_drivers.cache_clear) # discover_external_drivers is `@cache`'d; clear it so it re-scans our tmp dir # (an earlier test/import may have populated the cache with the real dir) discover_external_drivers.cache_clear() discover_external_drivers()(and drop the now-redundant
tearDownClass.)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/unit/test_runner_hooks.py` around lines 38 - 56, `test_runner_hooks.setUpClass` mutates process-wide state (`CONFIG.dirs.templates` and `discover_external_drivers.cache_clear()`), so register restoration with `addClassCleanup` immediately after each mutation instead of relying on `tearDownClass`. Ensure the cleanup restores `CONFIG.dirs.templates`, clears the cache again, and removes `secator.hooks.mydriver` so state is reset even if `discover_external_drivers()` fails during setup. After moving cleanup into `setUpClass`, remove the now redundant `tearDownClass`.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@secator/runners/_base.py`:
- Around line 468-515: Extract the repeated driver-hook loading sequence from
_apply_context_drivers() and _apply_profile_drivers() into a shared helper that
handles discover_external_drivers(), import_dynamic('secator.hooks.{driver}',
'HOOKS'), deep_merge_dicts(*hooks_list), and the register_hooks call. Keep the
context/profile-specific selection logic in each caller, and update the
surrounding docstring/comments in Runner to remove references to the removed
__setstate__ path.
In `@tests/unit/test_runner_hooks.py`:
- Around line 38-56: `test_runner_hooks.setUpClass` mutates process-wide state
(`CONFIG.dirs.templates` and `discover_external_drivers.cache_clear()`), so
register restoration with `addClassCleanup` immediately after each mutation
instead of relying on `tearDownClass`. Ensure the cleanup restores
`CONFIG.dirs.templates`, clears the cache again, and removes
`secator.hooks.mydriver` so state is reset even if `discover_external_drivers()`
fails during setup. After moving cleanup into `setUpClass`, remove the now
redundant `tearDownClass`.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: d397a750-87da-4f88-ac93-0f1c736ad4ac
📒 Files selected for processing (3)
secator/runners/_base.pytests/unit/test_celery.pytests/unit/test_runner_hooks.py
## Problem The `Tests` workflow is **red on main** — the `coverage` job fails at *Run coverage report* (`coverage combine --keep` succeeds, then `coverage report -m` prints no table and exits 1). Introduced by #1267. Root cause (reproduced locally): ``` No source for code: '/tmp/tmp8dwgpdlo/mydriver.py'; see .../messages.html#error-no-source EXIT=1 ``` #1267's `tests/unit/test_runner_hooks.py` dropped the fake driver into a system `TemporaryDirectory` and imported it as `secator.hooks.mydriver`. Coverage recorded that `/tmp/...` path. The unit job passed (it only *writes* coverage data), but the separate `coverage` job combines the unit+integration data and runs `coverage report`; by then the tempdir is gone, so coverage raises `NoSource` and exits 1. The path isn't caught by `--omit=*/site-packages/*,*/tests/*,*/templates/*`. ## Fix Mirror the existing `tests/unit/test_loader.py` convention: write the driver into the **real** `CONFIG.dirs.templates` dir (its path contains `/templates/`, so the report's `--omit=*/templates/*` excludes it) and clean it up in `tearDownClass`. No change to the runtime fix from #1267 — this only relocates where the test's throwaway driver file lives. ## Verification - `coverage run … test_runner_hooks.py` → 4 passed; then `coverage report --omit=…` → **exit 0, full table, 0 "No source" errors** (was exit 1 before). - Driver file is removed after the run (no stray file left in `~/.secator/templates/`). - `flake8` clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Tests** * Updated hook-related test coverage to use a real external driver fixture during initialization. * Improved validation for custom hook registration, persistence after serialization, and repeated context application. <!-- end of auto-generated comment: release notes by coderabbit.ai --> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
🤖 I have created a release *beep* *boop* --- ## [0.39.0](v0.38.0...v0.39.0) (2026-07-03) ### Features * **vulnerability:** add status field + carry-over across re-scans ([#1209](#1209)) ([0d7bf7d](0d7bf7d)) ### Bug Fixes * **cli:** missing "help" field in option raises ValueError ([#1230](#1230)) ([74a6b39](74a6b39)) * **config:** SECATOR_ADDONS_* invalid key override breaks Secator totally ([#1205](#1205)) ([2164993](2164993)) * dynamic task import order causing SyntaxError with external tasks ([#1213](#1213)) ([0e2fcd6](0e2fcd6)) * **exporters:** add runner warnings to report ([#1228](#1228)) ([1734f6b](1734f6b)) * **output:** display full node id instead of task name ([#1227](#1227)) ([e26f394](e26f394)) * **output:** Vulnerability colors display by severity, show tags for Tag ([#1229](#1229)) ([6ad35e3](6ad35e3)) * **runner:** remove __getstate__/__setstate__, load driver hooks at init ([#1267](#1267)) ([2c6d2e8](2c6d2e8)) * **tests:** coverage report NoSource failure on main ([#1270](#1270)) ([8b3a2a3](8b3a2a3)) ### Performance Improvements * **output-types:** cache keys() + O(1) deduplicate membership check ([#1220](#1220)) ([7a956e9](7a956e9)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please).
Problem
With
SECATOR_DEBUG=runner, a chunked task on a worker emits hundreds ofthousands of
hook registeredlines. The flood appears during thereplace(self, workflow)call (celery.py), right after Celery logs"Adding chain task secator.celery.run_command from request chain". Reproduced
with a custom driver dropped in
~/.secator/templates/+ a chunked task.Root cause
PR #1126 (fixes #1124) added two things:
discover_external_drivers()at worker startup (celery.py,IN_WORKER) —the actual fix: it puts every
secator.hooks.<driver>module (built-in andcustom ones from
~/.secator/templates/) intosys.modules, so pickled hookfunctions resolve on the worker.
__getstate__/__setstate__onRunner:__getstate__strips hooks;__setstate__rebuilds them fromcontext['drivers']by callingregister_hooks().Because (1) already makes the driver modules importable on the worker, hook
functions pickle/unpickle natively by qualified name — making (2) redundant.
And
register_hooks()logs one line per hook, while__setstate__runs onevery unpickle. Celery's
replace()+ chord synchronization re-deserializethe runner-bearing signatures O(chunks) times → the flood.
Fix
register_hooks()is idempotent — skip a hook already present (and stopmutating a shared module-level
HOOKSlist in place). Keeps both driversources — explicit
hooks=(library callers) andcontext['drivers'](CLI) —without registering or logging anything twice.
_apply_context_drivers(), called once in__init__— loadscontext['drivers']hooks at construction (the logic__setstate__used torun on unpickle). Every runner — initial dispatch, chunk task rebuilt by
run_command, chord callback — gets its driver hooks exactly once.Registering on the client is inert: dispatching runners set
enable_hooks=False, andrun_hooksgates firing on it.__getstate__/__setstate__. Native pickling preserves thealready-registered
resolved_hooksacross the Celery boundary; nothingre-registers on unpickle → the flood is gone. This structurally removes the
fix: restore on_end hooks for chunk-parent tasks on unpickle #1180 class of bug too (hooks are never stripped/rebuilt).
Backwards compatibility
Library callers who pass driver hooks explicitly keep working:
This actually fixes a latent regression for exactly those callers: today
__getstate__strips theirhooks=, and__setstate__restores only fromcontext['drivers']— so a caller passinghooks=with no matching driver incontext silently lost their hooks on the worker. Native pickling preserves
them.
Tests
tests/unit/test_runner_hooks.py: driver hooks load at init fromcontext['drivers']; a custom-driver hook survives a pickle round-trip withzero re-registration on unpickle; registration is idempotent; library-mode
explicit
hooks=survive pickling.TestRunnerPickleintests/unit/test_celery.pyto the native-picklingcontract (module present as on a discovered worker; unpickle does not
re-register).
tests/unitsuite: no new failures (the remaining reds are pre-existingand environmental — AI guardrails / CLI install / offline / CVE / config — and
fail identically on
main).Fixes the
SECATOR_DEBUG=runnerlog flood on chunked tasks; builds on #1126.Summary by CodeRabbit