Skip to content

ppc-simulate-cart Ajax Session Race Conditions #4616

Description

@kaushikasomaiya

In a recent merchant case 11563873-zen, the complain was that intermittently add to cart does not work - the item added to cart message displays fine - clicking the basket/cart link, it shows Cart is empty.

After session logging - it was seen that the ?wc-ajax=ppc-simulate-cart is potentially overwriting a stale session due to race condition between the POST add to cart request and the ?wc-ajax=ppc-simulate-cart which can occur right during the add to cart request.

One needs to be lucky to replicate this - however an intentional delay described below helps

  1. A simple product
  2. A shipping method that applies to all locations regardless anything (Eg flat rate)
  3. An intentional delay added to wc_ajax_ppc-simulate-cart'
add_action( 'wc_ajax_ppc-simulate-cart', 'ks_delay_forced_ppc_simulation', 1 );

function ks_delay_forced_ppc_simulation() {
	$forced = isset( $_GET['ks_forced'] ) ? sanitize_text_field( wp_unslash( $_GET['ks_forced'] ) ) : '';

	if ( 'timeout' === $forced ) {
		usleep( 2000000 );
	}
}
  1. Force the PPC simulate cart request via JS
(() => {
	const delayMs = 300;
	const form = document.querySelector('form.cart');
	const button = form.querySelector('.single_add_to_cart_button');
	const config = window.PayPalCommerceGateway;

	button.addEventListener('click', () => {
		const payload = {
			nonce: config.ajax.simulate_cart.nonce,
			products: [{
				id: form.querySelector('[name="add-to-cart"]').value,
				quantity: form.querySelector('[name="quantity"]')?.value || 1,
				variations: [...form.querySelectorAll('[name^="attribute_"]')]
					.map(({ name, value }) => ({ name, value }))
			}]
		};

		setTimeout(() => {
			const endpoint = new URL(
				config.ajax.simulate_cart.endpoint,
				location.href
			);
			endpoint.searchParams.set('ks_forced', 'timeout');

			fetch(endpoint, {
				method: 'POST',
				credentials: 'same-origin',
				keepalive: true,
				headers: { 'Content-Type': 'application/json' },
				body: JSON.stringify(payload)
			});
		}, delayMs);
	}, { capture: true });
})();
  1. Run the above JS before pressing add to cart on the frontend
  2. See Cart is empty

In practical scenarios - the PPC request can naturally race with the POST add to cart - eg qty change -> Add to cart or variation change -> Add to cart.

What happens when the requests race

  1. ppc-simulate-cart initializes WooCommerce using an existing cartless/stale session snapshot.
  2. IsolatedCartSimulator creates a temporary cart and calls $cart->calculate_totals().
  3. Shipping calculation uses the global WC()->shipping() instance, which writes shipping_for_package_0 into the customer's real WC()->session.
  4. This marks the real session as dirty, despite the simulated cart being intended to be isolated.
  5. The concurrent Add-to-cart POST completes and saves a session containing the newly added cart.
  6. The delayed simulation finishes afterward. During shutdown, WC_Session_Handler::save_data() replaces the complete serialized session row using its older snapshot.
  7. Because this is a whole-row, last-writer-wins update without merging individual session keys, the simulation's cartless snapshot removes the cart written by the Add-to-cart request.

The resulting write order is:

Add-to-cart POST:
session = { ..., cart, cart_totals, shipping_for_package_0 }

ppc-simulate-cart finishes afterward:
session = { ..., shipping_for_package_0 } // no cart

It was additionally observed that ppc-simulate-cart runs even when no PayPal button is visibly rendered on the product page. The product controller appears to gate simulation on the presence of form.cart and simulate_cart.enabled, rather than on whether a PayPal button was successfully rendered or is visible.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions