Skip to content

Commit 0013176

Browse files
committed
fix: exclude event dispatcher from worker services reset
ResetServicesListener calls ServicesResetter::reset() during WorkerRunningEvent dispatch. In debug mode, the global services_resetter includes debug.event_dispatcher (TraceableEventDispatcher), whose reset() clears dispatchDepth mid-dispatch — causing "Undefined array key" in postProcess() with symfony/event-dispatcher >= 8.0.8. Add WorkerResetCompilerPass that builds a worker-specific ServicesResetter excluding the debug.event_dispatcher, so the global resetter remains unchanged for HTTP request resets. Fixes #309
1 parent 1eb6b6b commit 0013176

3 files changed

Lines changed: 232 additions & 0 deletions

File tree

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\EventSourcingBundle\DependencyInjection;
6+
7+
use Patchlevel\EventSourcingBundle\Subscription\ResetServicesListener;
8+
use Symfony\Component\DependencyInjection\Argument\IteratorArgument;
9+
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
10+
use Symfony\Component\DependencyInjection\ContainerBuilder;
11+
use Symfony\Component\DependencyInjection\ContainerInterface;
12+
use Symfony\Component\DependencyInjection\Reference;
13+
use Symfony\Component\HttpKernel\DependencyInjection\ServicesResetter;
14+
15+
use function is_string;
16+
17+
/**
18+
* Builds a worker-specific ServicesResetter that excludes the event dispatcher.
19+
*
20+
* In debug mode, Symfony decorates the event dispatcher with TraceableEventDispatcher
21+
* and tags it with kernel.reset. When the ResetServicesListener fires during
22+
* WorkerRunningEvent dispatch, the global services_resetter calls reset() on the
23+
* TraceableEventDispatcher — clearing its internal state (dispatchDepth) while the
24+
* event is still being dispatched. This causes an "Undefined array key" warning
25+
* in postProcess() (symfony/event-dispatcher >= 8.0.8).
26+
*
27+
* This pass creates a separate ServicesResetter for the worker that excludes the
28+
* debug.event_dispatcher, so the global services_resetter remains unchanged for
29+
* HTTP request resets.
30+
*/
31+
final class WorkerResetCompilerPass implements CompilerPassInterface
32+
{
33+
public function process(ContainerBuilder $container): void
34+
{
35+
if (!$container->hasDefinition(ResetServicesListener::class)) {
36+
return;
37+
}
38+
39+
if (!$container->hasDefinition('debug.event_dispatcher')) {
40+
return;
41+
}
42+
43+
$services = [];
44+
/** @var array<string, list<string>> $methods */
45+
$methods = [];
46+
47+
foreach ($container->findTaggedServiceIds('kernel.reset', true) as $id => $tags) {
48+
if ($id === 'debug.event_dispatcher') {
49+
continue;
50+
}
51+
52+
$services[$id] = new Reference($id, ContainerInterface::IGNORE_ON_UNINITIALIZED_REFERENCE);
53+
54+
foreach ($tags as $attributes) {
55+
/** @var array{method?: string, on_invalid?: string} $attributes */
56+
if (!isset($attributes['method'])) {
57+
continue;
58+
}
59+
60+
$methods[$id] ??= [];
61+
62+
$method = $attributes['method'];
63+
64+
if (($attributes['on_invalid'] ?? null) === 'ignore') {
65+
$method = '?' . $method;
66+
}
67+
68+
$methods[$id][] = $method;
69+
}
70+
}
71+
72+
if ($services === []) {
73+
return;
74+
}
75+
76+
$container->register('patchlevel.worker.services_resetter', ServicesResetter::class)
77+
->setArguments([
78+
new IteratorArgument($services),
79+
$methods,
80+
]);
81+
82+
$container->getDefinition(ResetServicesListener::class)
83+
->setArgument(0, new Reference('patchlevel.worker.services_resetter'));
84+
}
85+
}

src/PatchlevelEventSourcingBundle.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use Patchlevel\EventSourcingBundle\DependencyInjection\RepositoryCompilerPass;
1313
use Patchlevel\EventSourcingBundle\DependencyInjection\SubscriberGuardCompilePass;
1414
use Patchlevel\EventSourcingBundle\DependencyInjection\TranslatorCompilerPass;
15+
use Patchlevel\EventSourcingBundle\DependencyInjection\WorkerResetCompilerPass;
1516
use Symfony\Component\DependencyInjection\ContainerBuilder;
1617
use Symfony\Component\HttpKernel\Bundle\Bundle;
1718

@@ -27,5 +28,6 @@ public function build(ContainerBuilder $container): void
2728
$container->addCompilerPass(new TranslatorCompilerPass());
2829
$container->addCompilerPass(new DoctrineCleanupCompilerPass());
2930
$container->addCompilerPass(new HydratorCompilerPass());
31+
$container->addCompilerPass(new WorkerResetCompilerPass());
3032
}
3133
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\EventSourcingBundle\Tests\Unit\DependencyInjection;
6+
7+
use Patchlevel\EventSourcingBundle\DependencyInjection\WorkerResetCompilerPass;
8+
use Patchlevel\EventSourcingBundle\Subscription\ResetServicesListener;
9+
use PHPUnit\Framework\TestCase;
10+
use Symfony\Component\DependencyInjection\ContainerBuilder;
11+
use Symfony\Component\DependencyInjection\Reference;
12+
use Symfony\Component\HttpKernel\DependencyInjection\ServicesResetter;
13+
14+
final class WorkerResetCompilerPassTest extends TestCase
15+
{
16+
public function testCreatesFilteredResetterWhenDebugDispatcherExists(): void
17+
{
18+
$container = new ContainerBuilder();
19+
20+
$container->register(ResetServicesListener::class)
21+
->setArguments([new Reference('services_resetter')]);
22+
23+
$container->register('debug.event_dispatcher')
24+
->addTag('kernel.reset', ['method' => 'reset']);
25+
26+
$container->register('some.other.service')
27+
->addTag('kernel.reset', ['method' => 'reset']);
28+
29+
$pass = new WorkerResetCompilerPass();
30+
$pass->process($container);
31+
32+
self::assertTrue($container->hasDefinition('patchlevel.worker.services_resetter'));
33+
34+
$definition = $container->getDefinition('patchlevel.worker.services_resetter');
35+
self::assertSame(ServicesResetter::class, $definition->getClass());
36+
37+
/** @var array<string, list<string>> $methods */
38+
$methods = $definition->getArgument(1);
39+
self::assertArrayNotHasKey('debug.event_dispatcher', $methods);
40+
self::assertArrayHasKey('some.other.service', $methods);
41+
self::assertSame(['reset'], $methods['some.other.service']);
42+
43+
$listenerDef = $container->getDefinition(ResetServicesListener::class);
44+
self::assertEquals(
45+
new Reference('patchlevel.worker.services_resetter'),
46+
$listenerDef->getArgument(0),
47+
);
48+
}
49+
50+
public function testSkipsWhenNoDebugDispatcher(): void
51+
{
52+
$container = new ContainerBuilder();
53+
54+
$container->register(ResetServicesListener::class)
55+
->setArguments([new Reference('services_resetter')]);
56+
57+
$container->register('some.other.service')
58+
->addTag('kernel.reset', ['method' => 'reset']);
59+
60+
$pass = new WorkerResetCompilerPass();
61+
$pass->process($container);
62+
63+
self::assertFalse($container->hasDefinition('patchlevel.worker.services_resetter'));
64+
65+
$listenerDef = $container->getDefinition(ResetServicesListener::class);
66+
self::assertEquals(
67+
new Reference('services_resetter'),
68+
$listenerDef->getArgument(0),
69+
);
70+
}
71+
72+
public function testSkipsWhenNoResetServicesListener(): void
73+
{
74+
$container = new ContainerBuilder();
75+
76+
$container->register('debug.event_dispatcher')
77+
->addTag('kernel.reset', ['method' => 'reset']);
78+
79+
$pass = new WorkerResetCompilerPass();
80+
$pass->process($container);
81+
82+
self::assertFalse($container->hasDefinition('patchlevel.worker.services_resetter'));
83+
}
84+
85+
public function testSkipsWhenNoResettableServicesRemain(): void
86+
{
87+
$container = new ContainerBuilder();
88+
89+
$container->register(ResetServicesListener::class)
90+
->setArguments([new Reference('services_resetter')]);
91+
92+
$container->register('debug.event_dispatcher')
93+
->addTag('kernel.reset', ['method' => 'reset']);
94+
95+
$pass = new WorkerResetCompilerPass();
96+
$pass->process($container);
97+
98+
self::assertFalse($container->hasDefinition('patchlevel.worker.services_resetter'));
99+
}
100+
101+
public function testHandlesOnInvalidIgnoreAttribute(): void
102+
{
103+
$container = new ContainerBuilder();
104+
105+
$container->register(ResetServicesListener::class)
106+
->setArguments([new Reference('services_resetter')]);
107+
108+
$container->register('debug.event_dispatcher')
109+
->addTag('kernel.reset', ['method' => 'reset']);
110+
111+
$container->register('some.service')
112+
->addTag('kernel.reset', ['method' => 'reset', 'on_invalid' => 'ignore']);
113+
114+
$pass = new WorkerResetCompilerPass();
115+
$pass->process($container);
116+
117+
self::assertTrue($container->hasDefinition('patchlevel.worker.services_resetter'));
118+
119+
/** @var array<string, list<string>> $methods */
120+
$methods = $container->getDefinition('patchlevel.worker.services_resetter')->getArgument(1);
121+
self::assertSame(['?reset'], $methods['some.service']);
122+
}
123+
124+
public function testSkipsTagsWithoutMethodAttribute(): void
125+
{
126+
$container = new ContainerBuilder();
127+
128+
$container->register(ResetServicesListener::class)
129+
->setArguments([new Reference('services_resetter')]);
130+
131+
$container->register('debug.event_dispatcher')
132+
->addTag('kernel.reset', ['method' => 'reset']);
133+
134+
$container->register('some.service')
135+
->addTag('kernel.reset', [])
136+
->addTag('kernel.reset', ['method' => 'reset']);
137+
138+
$pass = new WorkerResetCompilerPass();
139+
$pass->process($container);
140+
141+
/** @var array<string, list<string>> $methods */
142+
$methods = $container->getDefinition('patchlevel.worker.services_resetter')->getArgument(1);
143+
self::assertSame(['reset'], $methods['some.service']);
144+
}
145+
}

0 commit comments

Comments
 (0)