Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Hooks/Dispatcher - Close loopholes that occur around "preboot" hooks #17831

Merged
merged 3 commits into from
Jul 17, 2020

Conversation

totten
Copy link
Member

@totten totten commented Jul 14, 2020

Overview

In CRM_Utils_Hook and Civi::dispatcher(), there is a subtle distinction between normal hooks and "preboot" hooks. This eliminates the distinction, making it easier to reason about and modify the hook mechanism.

Before

  • To listen to almost any hook, you may use function PREFIX_civicrm_HOOK($param...) or Civi::dispatcher().
  • Except... if you wish to listen to the preboot hooks (hook_civicrm_container and hook_civicrm_entityTypes), then Civi::dispatcher() does not work.
  • One cannot register any listeners via Civi::dispatcher() until fairly late during bootstrap.

After

  • All hooks - including hook_civicrm_container and hook_civicrm_entityTypes should work with Civi::dispatcher().
  • There is a built-in guard-rail - if some future change causes a hook to fire too early (eg in a way that breaks Civi::dispatcher()), then it will raise an exception. This is not specifically a unit-test - but it would raised by the unit-tests for any use-cases involving a premature hook.
  • One can register listeners via Civi::dispatcher() fairly early during bootstrap.

Comments

In "Before/After", the third item speaks about "early" and "late" during bootstrap, and this is probably a bit abstract. My long-term aim is to move some of the civix boilerplate which will require other PR(s). For something immediate+concrete, this may be a better demonstration of the principle:

  • Before, an extension which uses Civi::dispatcher() must call it from within some other hook:
    // FILE: mymod.php
    function mymod_civicrm_config() {
      ...
      Civi::dispatcher()->addListener('hook_civicrm_pre', ...);
      ..
    }
  • After, an extension may directly use Civi::dispatcher() at the top-level of the PHP file:
    // FILE: mymod.php
    Civi::dispatcher()->addListener('hook_civicrm_pre', ...);

I haven't fully tested the lifecycle of an extension using this code-style (because it's a bit incidental/by-the-by), but it hopefully demonstrates the principle of the change.

Before
------

TLDR: There are superfluous queries+hooks in every bootstrap. Specifically:

* During early stages of bootstrap, the extension system loads the `CRM_Extension_Manager_*` classes.
* The constructors for these classes lookup the option-group IDs in anticipation that they'll beeded.
* The lookup uses a helper method which relies on `hook_entityTypes`.
* Since all this happens during early bootstrap, `hook_entityTypes` becomes a preboot hook.
* But it's silly - because we don't actually these groupIds. They're only used during installation/uninstallation
  of obscure extensions.

After
-----

TLDR: Less superfluous queries+hooks.

* The `CRM_Extension_Manager_*` classes do not trigger any extraneous OG lookkups during bootstrap.
* The `CRM_Extension_Manager_*` will only do the query when it really needs to.
* `hook_entityTypes` fires later in bootstrap - when more services are available.
Before
------

* The event-dispatcher is fully instantiated as a container service (`Container::createEventDispatcher()`).
* It is not possible to add listeners to `Civi::dispatcher()` before the container is instantiated.
* It is not possible to add listeners to `Civi::dispatcher()` in the top-level logic of an extension file (`myext.php`)
* There is a distinction between "pre-boot" hooks (`hook_civicrm_container` or `hook_civicrm_entityType`) and
  normal hooks. Pre-boot hooks do not work with `Civi::dispatcher()`.

After
-----

* The event-dispatcher is instantiated as boot service (ie very early during bootstrap).
* To preserve compatibility (with `RegisterListenersPass`, with any downstream modifications of `createEventDispatcher`,
  and with any dependency-injection) the event-dispatcher will still use `createEventDispatcher()` for extra
  intialization.
* It is possible to add listeners to `Civi::dispatcher()` in several more places, e.g.
  at the top-level of an extension's `.php` file and in `CRM_Utils_System_*::initialize()`
* There is no distinction between "preboot" hooks and normal hooks. `Civi::dispatcher()` can
  register listeners for all hooks.
* To ensure that we do not accidentally re-create "preboot" hooks, this patch enables a
  "dispatch policy" => if any hook fires too early, it will throw an exception.
@civibot
Copy link

civibot bot commented Jul 14, 2020

(Standard links)

@eileenmcnaughton
Copy link
Contributor

test this please

@eileenmcnaughton
Copy link
Contributor

@totten so I'm just looking at this from the point of view of whether it should get the 'has-test', 'ok-without-test' o 'needs-test' flag. It's definitely not in the first category. I think I apply the second when it either

  1. is very form-layer (wording changes, css, tpl but not postProcess handling)
  2. is unreasonably hard to write a test for
  3. we are dealing with aa regression under pressure & we need to respect the fact that someone has just 'dropped everything to solve a regression' and not pile more on that situation

This list probably can and should be expanded - & I'm hoping that if you make the case for 'ok -without-test' here it will help to clarify it

@@ -517,6 +520,8 @@ public static function boot($loadFromDB) {

$bootServices['lockManager'] = self::createLockManager();

$bootServices['dispatcher.boot']->setDispatchPolicy(\CRM_Core_Config::isUpgradeMode() ? \CRM_Upgrade_DispatchPolicy::get('upgrade.main') : NULL);

if ($loadFromDB && $runtime->dsn) {
Copy link
Member Author

@totten totten Jul 15, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@eileenmcnaughton Yeah, good comment. It is fairly hard to frame a unit-test for this. It took me a bit to think up a QA/defensive mechanism. The primary risk, IMHO, is that some innocuous-looking patch will have some obscure side-effect which inadvertently causes some hook to dispatch in the midst of bootstrap.

(The stuff in a7718ac is a pretty good example of otherwise innocuous code which inadvertently dispatches hook_civicrm_entityTypes.)

This patch offers a defense using setDispatchPolicy(). To see this defense in action, you can sprinkle in calls to \CRM_Utils_Hook::entityTypes($x); in various places (e.g. in createLockManager() or SettingsManager::__construct() or CRM_Utils_System_*::initialize()) - if anything tries to fire a hook before the boot system is good-and-ready for it, then you'll get an exception.

(Clarification: The setDispatchPolicy() notation is probably unfamiliar. The first policy ['/./' => 'not-ready'] matches the wildcard regex and throws a "Not ready" exception if any hooks are dispatched. The second policy is typically NULL, which allows all hooks -- notwithstanding the pre-existing edge-case for upgrades.)

The key thing here is that the defensive measure is always used during boot(). Consequently, every unit-test and every E2E test will employ this defensive measure. IMHO, the odds are quite high that some automated-test (if not all automated-tests) will raise a redflag on regression.

Arguably, the defense should be a little stronger - 42ccedc relaxes the dispatch-policy a little sooner than I'd like. It's just that the branch (if ($loadFromDB...) means that the More Correct patch will also be More Ugly. But I suppose it's worth it - I can push up a tweak to tighten it.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ab79d6c extends the defense a little further and expands the comments.

Before: The protection extends up until `createLockManager()`

After: The protection extends up until `CRM_Extension_System...->register()`
@eileenmcnaughton
Copy link
Contributor

OK - I think we are homing in on dealing with this ongoing whack-a-mole - merging

@eileenmcnaughton eileenmcnaughton merged commit 5b89700 into civicrm:master Jul 17, 2020
@totten totten deleted the master-no-preboot branch July 28, 2020 00:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants