[CoreBundle] ShopBasedCartContext is missing the kernel.reset tag, cart leaks between requests in worker mode
Sylius version(s) affected
2.2.7 (code unchanged on 2.2 / 2.x branches)
Description
ShopBasedCartContext memoizes the resolved cart in a private property and implements ResettableCartContextInterface (which extends Symfony\Contracts\Service\ResetInterface) precisely so it can be reset between requests. But the sylius.context.cart.new_shop_based service never receives the kernel.reset tag, so in a long-running runtime (FrankenPHP worker mode, RoadRunner…) the memoized cart leaks into every subsequent request handled by that worker.
$ bin/console debug:container sylius.context.cart.new
// This service is a private alias for the service sylius.context.cart.new_shop_based
Service ID sylius.context.cart.new_shop_based
Class Sylius\Component\Core\Cart\Context\ShopBasedCartContext
Tags sylius.context.cart (priority: -999)
container.decorator (id: sylius.context.cart.new, inner: sylius.context.cart.new_shop_based.inner)
Shared yesNo kernel.reset, and the service does not appear in debug:container --tag=kernel.reset.
Two mechanisms that should have tagged it both miss:
Sylius\Bundle\OrderBundle\DependencyInjection\Compiler\TagResettableCartContextsPassiteratesfindTaggedServiceIds('sylius.context.cart')and tags the resettable ones.sylius.context.cart.new_shop_basedis a decorator ofsylius.context.cart.newand does not carry thesylius.context.carttag itself — it only inherits it later, whenDecoratorServicePasscopies tags from the decorated definition onto the decorator. The pass is registered inSyliusOrderBundle::build()with the defaultPassConfig::TYPE_BEFORE_OPTIMIZATION, i.e. beforeDecoratorServicePass(optimization phase,PassConfig.php:69), so it never sees the service.- Symfony's
ResetInterfaceautoconfiguration (FrameworkExtension) does not apply either, because the definition comes from XML withautoconfigure=false.
Because getCart() returns the memoized cart at the very top of the method — before the block that assigns
channel, currency, locale and customer — a cart memoized during a guest request stays createdByGuest = true
with tokenValue = null for every later request in that worker, whoever is authenticated.
The most visible consequence is in ApiBundle: ApiCartBlamerListener::onLoginSuccess() receives that stale cart and dispatches new BlameCart($user->getEmail(), $cart->getTokenValue()) with null, which is a hard 500:
Sylius\Bundle\ApiBundle\Command\Cart\BlameCart::__construct(): Argument #2 ($orderTokenValue)
must be of type string, null given, called in src/Sylius/Bundle/ApiBundle/EventListener/ApiCartBlamerListener.php on line 52Secondary issue, independent of the runtime: ApiCartBlamerListener has no null check on getTokenValue(), although Sylius itself acknowledges that carts with a null token value exist — PickupCartHandler backfills them (if (null === $activeCart->getTokenValue()) { $activeCart->setTokenValue(...) }). So any tokenless cart reaching that listener turns a login into a 500 instead of being skipped.
How to reproduce
Container-level (no runtime needed)
On a stock 2.2.7 install:
bin/console debug:container sylius.context.cart.new # tags: sylius.context.cart, container.decorator
bin/console debug:container --tag=kernel.reset | grep cart # no matchShopBasedCartContext implements ResettableCartContextInterface, so it is expected to be listed.
Runtime
Setup: Sylius 2.2.7 with ApiBundle, served by FrankenPHP in worker mode
(symfony/runtime + runtime/frankenphp-symfony), dev env so the profiler is enabled.
- Send any shop request as a guest. In
devthe profiler'ssylius.collector.cart(CartCollector) callssylius.context.cart->getCart()on every request; it falls through toShopBasedCartContext, which creates a brand-new transient cart (tokenValue = null,createdByGuest = true, never persisted) and memoizes it. The profiler is only the most reliable trigger — any consumer of the cart context on a guest request does the same (e.g.sylius_adyen.controller.shop.express_checkout.*), which is why this is not a dev-only problem. - On the same worker process, send
POST /api/v2/shop/orderswith a shop-user JWT, for a customer with no non-emptycreatedByGuest = falsecart in that channel.LoginSuccessEventfires (stateless firewall), the URI containsordersso the section isShopApiOrdersSubSection, andApiCartBlamerListenerresolves the cart:TokenValueBasedCartContext→CartNotFoundException(notokenValuerequest attribute on this route)CustomerAndChannelBasedCartContext→CartNotFoundException(no matching cart)- composite falls through to
ShopBasedCartContext→ returns the stale cart memoized in step 1
- Response is 500 with the
BlameCart::__construct()TypeError above.
The same request in non-worker mode returns 201: a fresh ShopBasedCartContext runs its assignment block,
setCustomerWithAuthorization() sets createdByGuest = false, and the listener returns early at
!$cart->isCreatedByGuest().
More generally, and regardless of ApiBundle: in worker mode any two requests handled by the same worker share the cart resolved by the first one.
Possible Solution
1. Make the tag reach the decorator.
Simplest: tag it explicitly in src/Sylius/Bundle/CoreBundle/Resources/config/services/context.xml:
<service id="sylius.context.cart.new_shop_based" class="Sylius\Component\Core\Cart\Context\ShopBasedCartContext"
decorates="sylius.context.cart.new" decoration-priority="256">
<argument type="service" id="sylius.context.cart.new_shop_based.inner" />
<argument type="service" id="sylius.context.shopper" />
<argument type="service" id="sylius.resolver.cart.created_by_guest_flag" />
<tag name="kernel.reset" method="reset" />
</service>Note that simply moving TagResettableCartContextsPass after DecoratorServicePass would not work:
ResettableServicePass, which consumes kernel.reset and builds services_resetter, is registered at
TYPE_BEFORE_OPTIMIZATION priority -32 (FrameworkBundle.php:195), so it also runs before
DecoratorServicePass. If you prefer to keep the tagging automatic, the pass would have to resolve decorators
itself, e.g. also inspect definitions whose getDecoratedService() points at a sylius.context.cart-tagged id.
Application-side workaround in the meantime — redeclare the decorator with the tag:
services:
sylius.context.cart.new_shop_based:
class: Sylius\Component\Core\Cart\Context\ShopBasedCartContext
decorates: sylius.context.cart.new
decoration_priority: 256
arguments:
- '@.inner'
- '@sylius.context.shopper'
- '@sylius.resolver.cart.created_by_guest_flag'
tags:
- { name: kernel.reset, method: reset }2. Guard ApiCartBlamerListener against a tokenless cart, so it degrades gracefully instead of returning a 500 during login:
$tokenValue = $cart->getTokenValue();
if (null === $tokenValue) {
return;
}
$this->commandBus->dispatch(new BlameCart($user->getEmail(), $tokenValue));BlameCartHandler looks the cart up with findCartByTokenValue(), so a cart without a token value is not blameable anyway.
I'm happy to open a PR for either or both if you agree with the direction.
Additional Context
No response
Source: Sylius/Sylius