Commit 15ebb988956 for woocommerce
commit 15ebb988956d5210047b7bcadbb58ce637978441
Author: Darren Ethier <darren@roughsmootheng.in>
Date: Mon Nov 9 11:14:45 2026 -0500
Add agent and AI review guidance for hook callbacks that recurse (#70788)
diff --git a/.ai/review/guidelines/hook-callback-reentrancy.md b/.ai/review/guidelines/hook-callback-reentrancy.md
new file mode 100644
index 00000000000..f8c1b22d4d9
--- /dev/null
+++ b/.ai/review/guidelines/hook-callback-reentrancy.md
@@ -0,0 +1,15 @@
+# Hook callbacks that recurse through other plugins
+
+**Source:**
+
+- #70198 (11.3.0 RC-1, rolled back on Atomic): a `get_the_guid` callback read `_wp_attached_file` meta. Real Media Library's `get_post_metadata` callback for that key calls `get_the_guid()`, so stores running both ran out of memory on most pages with media. Fixed in #70782.
+
+**Pattern to catch:** A hunk adds or changes a callback that, inside the callback, reads filtered data or calls something that fires the same hook. The risk is higher when:
+
+- the hook runs many times per request. The following examples are not an exclusive list, but are examples of types of hooks that might be affected. `get_the_guid`, `attachment_link`, `post_link`, `the_permalink`, `the_content`, `get_post_metadata`, `option_*`, `user_has_cap`, `map_meta_cap`, `home_url`;
+- the read is post, user, or term meta, an option, a permalink or GUID, `current_user_can()`, or `home_url()`;
+- the read happens before cheap checks such as the post type or values the hook already passed in.
+
+**How to confirm:** For each read inside the callback, name the filters it fires (`get_{$meta_type}_metadata`, `option_{$name}`, `user_has_cap`, and so on). Ask whether a plausible callback on those filters would fire the hook under review, for example a media plugin reading a GUID or permalink while filtering attachment meta. Then check for a re-entrancy guard around the read that resets in `finally`, and that the nested call returns a value that keeps the outer result correct. An empty in-tree grep for such callbacks proves nothing; they live in other plugins.
+
+**Don't flag when:** The callback only uses its arguments and functions that fire no hooks, the read cannot fire the hook again (for example, a direct `$wpdb` query), or a guard already covers the read.
diff --git a/.ai/review/personas/regression.md b/.ai/review/personas/regression.md
index d1e433fe0f6..c08e0059482 100644
--- a/.ai/review/personas/regression.md
+++ b/.ai/review/personas/regression.md
@@ -102,6 +102,9 @@ don't reach those sites. Page builders and app SDKs often render Woo UI without
callbacks.
- A hook on a hot path (per order read, per meta update) feeds Jetpack Sync, webhooks, and indexers.
Firing it more often causes runaway downstream traffic.
+- A callback that reads filtered data (meta, options, permalinks, GUIDs, capabilities) can be re-entered
+ when another plugin's callback on that read fires the same hook. Without a re-entrancy guard, the two
+ recurse until PHP runs out of memory on every request that reaches the hook.
**Data, meta, and equality.**
diff --git a/.ai/skills/woocommerce-backend-dev/SKILL.md b/.ai/skills/woocommerce-backend-dev/SKILL.md
index d5f8dfab071..aa38791650d 100644
--- a/.ai/skills/woocommerce-backend-dev/SKILL.md
+++ b/.ai/skills/woocommerce-backend-dev/SKILL.md
@@ -24,7 +24,7 @@ Follow WooCommerce project conventions when adding or modifying backend PHP code
2. **Naming conventions**: See [code-entities.md](code-entities.md) for naming methods, variables, and parameters
3. **Coding style**: See [coding-conventions.md](coding-conventions.md) for general coding standards and best practices
4. **Type annotations**: See [type-annotations.md](type-annotations.md) for PHPStan-aware PHPDoc annotations
-5. **Working with hooks**: See [hooks.md](hooks.md) for hook callback conventions and documentation
+5. **Working with hooks**: See [hooks.md](hooks.md) for hook callback conventions, documentation, and recursion risks
6. **Dependency injection**: See [dependency-injection.md](dependency-injection.md) for DI container usage
7. **Data integrity**: See [data-integrity.md](data-integrity.md) for ensuring data integrity when performing CRUD operations
8. **Writing tests**: See [unit-tests.md](unit-tests.md) for unit testing conventions
diff --git a/.ai/skills/woocommerce-backend-dev/hooks.md b/.ai/skills/woocommerce-backend-dev/hooks.md
index 0fe141700c4..de666bd0293 100644
--- a/.ai/skills/woocommerce-backend-dev/hooks.md
+++ b/.ai/skills/woocommerce-backend-dev/hooks.md
@@ -49,6 +49,19 @@ For the `@since` version:
do_action( 'woocommerce_order_processed', $order_id, $order_data );
```
+## Recursion Through Other Plugins' Callbacks
+
+A hook callback can run again before it returns. When it reads data other plugins can filter, such as meta, options, permalinks, GUIDs, or capabilities, their callback on that read can call back into your hook. Neither callback is wrong on its own, but together they recurse until PHP runs out of memory, on every request that reaches the hook.
+
+An example of this type of recursion shipped in 11.3.0 RC-1 ([#70198](https://github.com/woocommerce/woocommerce/pull/70198)). A `get_the_guid` callback read `_wp_attached_file` with `get_post_meta()`. Real Media Library's `get_post_metadata` callback for that key calls `get_the_guid()`, so stores running both ran out of memory on most pages with media.
+
+Watch for this when a callback on a hook that runs many times per request (not an exclusive list, but examples: `get_the_guid`, `attachment_link`, `post_link`, `the_content`, `get_post_metadata`, `option_*`, `user_has_cap`, `map_meta_cap`) reads filtered data or calls a function that fires the same hook. When it does:
+
+- Run the cheap checks first (post type, values the hook already passed in) so most calls return before the filtered read.
+- Add a re-entrancy guard around the filtered read and reset it in `finally`. Classes from the DI container are shared, so an instance property is enough.
+- Have the nested call return its input unchanged, and check that the outer call still reaches the right result. Returning a "safe" value from the nested call can make the outer result wrong.
+- If a test is useful and necessary, make sure any recursion testing fails past a small limit rather than crashing the test environment.
+
## Regenerating the Published Hook Docs
Hooks in `plugins/woocommerce/src/Blocks` and `plugins/woocommerce/src/StoreApi` are published as a developer reference generated from their docblocks:
diff --git a/.ai/skills/woocommerce-code-review/SKILL.md b/.ai/skills/woocommerce-code-review/SKILL.md
index 91bac49d093..720b0ae9a38 100644
--- a/.ai/skills/woocommerce-code-review/SKILL.md
+++ b/.ai/skills/woocommerce-code-review/SKILL.md
@@ -31,6 +31,10 @@ Consult the `woocommerce-backend-dev` skill for detailed standards. Using these
- ❌ **Missing docblocks** - Required for all hooks and methods ([code-entities.md](../woocommerce-backend-dev/code-entities.md))
- ❌ **Verbose docblocks** - Keep concise, one line is ideal ([code-entities.md](../woocommerce-backend-dev/code-entities.md))
+**Hooks:**
+
+- ❌ **Callbacks that can recurse** - A callback on a frequently run hook that reads filtered data (meta, options, permalinks, capabilities) without a re-entrancy guard ([hooks.md](../woocommerce-backend-dev/hooks.md#recursion-through-other-plugins-callbacks))
+
**Data Integrity:**
- ❌ **Missing validation** - Must verify state before deletion/modification ([data-integrity.md](../woocommerce-backend-dev/data-integrity.md))
diff --git a/ai-review-config.json b/ai-review-config.json
index 7e45a231c3f..a79a5a82298 100644
--- a/ai-review-config.json
+++ b/ai-review-config.json
@@ -213,6 +213,23 @@
"regression"
]
},
+ {
+ "name": "hook-callback-reentrancy",
+ "file": ".ai/review/guidelines/hook-callback-reentrancy.md",
+ "path": [
+ "plugins/woocommerce/includes/**/*.php",
+ "plugins/woocommerce/src/**/*.php",
+ "plugins/woocommerce/templates/**/*.php",
+ "plugins/woocommerce/woocommerce.php"
+ ],
+ "signals": [
+ "add_filter",
+ "add_action"
+ ],
+ "personas": [
+ "regression"
+ ]
+ },
{
"name": "php-loose-type-juggling",
"file": ".ai/review/guidelines/php-loose-type-juggling.md",