ServiceDefinition (the binding table) * - $instances : id -> resolved object (shared-instance cache) * - $pulseInstances : WeakMap> (auto-evicts on Fiber GC) * Resolved pulse-scoped object cache (per-Fiber). * - $pulseDefinitions : WeakMap> (auto-evicts on * Fiber GC) — per-Fiber pulse() binding table (Shape C). * Replaces the previous (broken) practice of writing pulse() * bindings into the global $definitions array, which leaked * per-Pulse state across Fiber boundaries (S-003/S-004). * - $fiberResolving : WeakMap — per-Fiber cycle * detection state (auto-evicts on Fiber GC) * - $mainResolving : concrete-key -> true — cycle detection set for the * main (non-Fiber) context; presence == on stack * - $mainResolvingChain: ordered list of [id, concrete] tuples for diagnostics, * main context only (per-Fiber chains live inside * $fiberResolving) * - $compilerPasses : list of passes to run during compile() * - $compiled : freeze flag; mutation methods assert false before running * * Resolution flow (see also the sequence diagram in this blueprint): * make(A) -> * 1. if A in $instances -> return cached object * 2. compute concrete (definition->concrete ?? $id); if concrete already in * $resolving -> throw CircularDependencyException(chain from $resolvingChain) * 3. push [id, concrete] onto $resolving and $resolvingChain * 4. try { build(concrete) } finally { pop from both } * 5. if shared -> cache in $instances * 6. return object * * Cycle detection is resolution-time, not graph-time, and keys on the *concrete* * class being constructed (not the binding id). This is deliberate: autowire() * recurses by calling make() with class-string FQCNs (e.g. `make(B::class)`), * not with the user's binding id. Keying on concrete catches cycles whether * the cycle enters via the user's bound id or via an autowired FQCN, and never * stack-overflows. The full chain of [id, concrete] pairs is preserved on the * exception via getResolutionChain() for diagnostics. */ final class Container implements ContainerInterface, ContainerBuilderInterface { /** @var array */ private array $definitions = []; /** @var array Worker-scoped instance cache (singleton + instance). */ private array $instances = []; /** * Pulse-scoped instance cache, keyed on the Fiber object itself. * * @var \WeakMap<\Fiber, array> * * WeakMap> — when a Fiber is garbage-collected * (Pulse completes), PHP automatically evicts the entire inner array for * that Fiber. No manual cleanup, no scheduler coupling, no memory leak * over a long-running FrankenPHP worker lifetime. * * Outside any Fiber context (main context), pulse-scoped bindings behave * as transient — each make() call returns a fresh instance with no caching. * This is intentional: pulse() is defined as per-Pulse, and the main context * is not a Pulse. */ private \WeakMap $pulseInstances; /** * Pulse-scoped definition table (Shape C), keyed on the Fiber object itself. * * @var \WeakMap<\Fiber, array> * * Holds the per-Fiber pulse() bindings. Each pulse($id, $value) call writes * a ServiceDefinition into the CURRENT Fiber's bucket only — never into the * global $definitions array. make() consults this table FIRST (before the * worker-scoped $instances cache) so per-Pulse value bindings shadow * worker-scoped singletons for the current Fiber. * * WeakMap — when a Fiber is garbage-collected (Pulse completes), * PHP automatically evicts that Fiber's entire pulse binding table. This is * the structural guarantee that per-Pulse state never leaks to a subsequent * Pulse on the same worker (S-003/S-004 remediation). * * pulse() requires a current Fiber: calling it from the main (non-Fiber) * context throws {@see ContainerException} — there is no Pulse to bind to. */ /** * @var \WeakMap<\Fiber, array> */ private \WeakMap $pulseDefinitions; /** * Per-Fiber cycle-detection state, keyed on the Fiber object itself. * * Under ADR-017's cooperative scheduler (Fibers), cycle detection MUST be * per-Fiber — otherwise a constructor that calls Fiber::suspend() leaves * the cycle stack populated, and any other Fiber resolving the same id * spuriously throws CircularDependencyException. * * WeakMap — when a Fiber is garbage-collected, PHP * automatically evicts its cycle-detection state. Mirrors $pulseInstances * (same rationale, same P2 fix — this was previously a plain * `array` keyed on spl_object_id($fiber), which worked but * never evicted stale entries for long-dead Fibers over a worker's * lifetime). * * @var \WeakMap<\Fiber, array{resolving: array, chain: list}> */ private \WeakMap $fiberResolving; /** * Cycle-detection state for the main (non-Fiber) context. * * WeakMap requires an object key, so the main context — which has no * Fiber to key on — is tracked separately here instead of the old * "fiberId 0" bucket inside the array. * * @var array */ private array $mainResolving = []; /** @var list */ private array $mainResolvingChain = []; /** @var list */ private array $compilerPasses = []; private bool $compiled = false; public function __construct() { $this->pulseInstances = new \WeakMap(); $this->pulseDefinitions = new \WeakMap(); $this->fiberResolving = new \WeakMap(); } public function bind(string $id, mixed $concrete = null, bool $singleton = false): void { $this->assertNotCompiled(); $concrete ??= $id; $this->definitions[$id] = new ServiceDefinition( abstract: $id, concrete: $concrete, shared: $singleton, tags: [], ); // Invalidate cached instance — re-binding must not return stale. unset($this->instances[$id]); $this->invalidatePulseInstances($id); } public function singleton(string $id, mixed $concrete = null): void { $this->bind($id, $concrete, true); } /** * Register a Pulse-scoped binding — one instance per Fiber (per Pulse). * * Shape C contract (A3-C revised, S-048): * pulse() accepts materialized values literally. Objects and scalar * values — including strings — are bound literally to the current Fiber. * Closures are rejected because they are executable factories. * pulse() never performs dependency resolution. Invalid Pulse state * fails closed and never falls through to global definitions. * * Specifically: * - $concrete is REQUIRED (no default; no fallthrough to $id). * - Strings are literal values — no class_exists() check, no class-string * interpretation. * - Closures throw ContainerException (use bind() or singleton() for * factory registration). * - All other values (objects, strings, ints, floats, bools, null, * arrays) are bound literally to the current Fiber's pulse table. * * @param string $id The service identifier. * @param mixed $concrete The materialized value to bind literally. Required. * * @throws \LogicException If the container has already been compiled. * @throws ContainerException If called outside a Fiber, or if $concrete * is a Closure. */ public function pulse(string $id, mixed $concrete): void { $this->assertNotCompiled(); // Shape C: pulse() is a request-time, Fiber-local value-binding // operation. It requires a current Fiber — there is no Pulse scope // to bind to from the main (non-Fiber) context. Per CORE-02 edge // case #1, this throws ContainerException (NOT LogicException, which // is reserved for the compiled-container guard above). /** @var \Fiber|null $fiber */ $fiber = \Fiber::getCurrent(); if ($fiber === null) { throw new ContainerException( 'pulse() requires a current Fiber context — it binds a value to ' . 'the current Pulse scope. Called from the main (non-Fiber) context. ' . 'Use bind() or singleton() for boot-time registration.' ); } // Shape C: Closures are factories, not materialized values. Reject them // explicitly — pulse() accepts only values that are the instance, not // factories that produce the instance. Use bind()/singleton() for // factory registration. if ($concrete instanceof \Closure) { throw new ContainerException( 'pulse() accepts materialized values only; Closures are factories. ' . 'Use bind() or singleton() for factory registration.' ); } // Write the definition into the CURRENT Fiber's pulse binding table, // NOT the global $definitions array. Per-Pulse state must never leak // across Fiber boundaries (S-003/S-004 remediation, CORE-02 Shape C). // Initialize the inner array on first write to this Fiber — avoids // "cannot assign offset to null" and the mixed-type inference // ambiguity on chained WeakMap[$fiber][$id] = $def. // // All non-Closure values are stored literally — objects, strings, // scalars, null, arrays. make() returns them as-is (Shape C: the // value IS the instance). if (!isset($this->pulseDefinitions[$fiber])) { $this->pulseDefinitions[$fiber] = []; } $this->pulseDefinitions[$fiber][$id] = new ServiceDefinition( abstract: $id, concrete: $concrete, shared: false, pulseScoped: true, tags: [], ); // Invalidate the cached pulse instance for THIS ID in the CURRENT // Fiber only — re-binding must not return the stale cached object. // Per CORE-02 edge case #11: invalidation is Fiber-local; other // Fibers' cached pulse instances (if any) are untouched. $this->invalidateCurrentFiberPulseInstance($fiber, $id); } public function instance(string $id, object $instance): void { $this->assertNotCompiled(); $this->instances[$id] = $instance; $this->definitions[$id] = new ServiceDefinition( abstract: $id, concrete: $instance, shared: true, tags: [], ); $this->invalidatePulseInstances($id); } public function make(string $id, array $parameters = []): mixed { // 0. Pulse-scoped definitions for the CURRENT Fiber (Shape C). // Per CORE-02 make() precedence step 1: the current Fiber's pulse // bindings shadow EVERYTHING ELSE (singletons, global definitions). // Shape C: the value IS the instance. All non-Closure values // (objects, strings, scalars, null, arrays) are returned as-is and // cached per-Fiber. Closures should never reach here (pulse() // rejects them); an Invalid Pulse state fails closed and never // falls through to global definitions. /** @var \Fiber|null $pulseFiber */ $pulseFiber = \Fiber::getCurrent(); if ($pulseFiber !== null && isset($this->pulseDefinitions[$pulseFiber][$id])) { // Cached resolved pulse instance — short-circuit if (isset($this->pulseInstances[$pulseFiber][$id])) { return $this->pulseInstances[$pulseFiber][$id]; } $pulseConcrete = $this->pulseDefinitions[$pulseFiber][$id]->concrete; // Shape C: the value IS the instance. Return it directly and cache it. // This covers objects, strings, scalars, null, arrays — all literal values. // Closures should never reach here (pulse() rejects them), but fail closed // if somehow one does. if ($pulseConcrete instanceof \Closure) { // This should never happen — pulse() rejects Closures. Fail closed. throw new ContainerException( "Invalid Pulse state: Closure found in pulse definitions for [$id]. " . 'This indicates a contract violation — pulse() should have rejected this value.' ); } // All non-Closure values are bound literally — cache and return. if (!isset($this->pulseInstances[$pulseFiber])) { $this->pulseInstances[$pulseFiber] = []; } $this->pulseInstances[$pulseFiber][$id] = $pulseConcrete; return $pulseConcrete; } // 1. Resolved-instance cache (worker-scoped). if (array_key_exists($id, $this->instances)) { return $this->instances[$id]; } // 1b. Pulse-scoped cache (one instance per Fiber/Pulse). // Uses WeakMap keyed on the Fiber object — auto-evicts on Fiber GC. // Outside any Fiber, pulse-scoped bindings are transient (no cache). $definition = $this->definitions[$id] ?? null; if ($definition !== null && $definition->pulseScoped) { /** @var \Fiber|null $fiber */ $fiber = \Fiber::getCurrent(); if ($fiber !== null && isset($this->pulseInstances[$fiber][$id])) { return $this->pulseInstances[$fiber][$id]; } } // 2. Resolve the concrete binding (fall back to $id when unbound, supporting // make(SomeClass::class) without an explicit bind() — see blueprint). // PHPStan strict rule: `?->concrete ?? $id` is redundant (`?->` already // handles null receiver; `??` is then equivalent). Use an explicit // null check so the access is plain `->`, which PHPStan accepts. $concrete = $definition !== null ? $definition->concrete : $id; // 3. Reject unresolvable ids before pushing onto the stack. If there is no // explicit definition AND $id is not a class-string, the caller asked for // something that cannot be constructed — throw NotFoundException rather // than returning the bare string (the old `default => $concrete` arm in // build() did that, violating the PSR-11 contract). if ($definition === null && !(is_string($concrete) && class_exists($concrete))) { throw new NotFoundException( "No service registered for id [$id] and [$id] is not an instantiable class." ); } // 4. Cycle detection — keyed on the concrete class being built. Autowire // recurses by calling make() with class-string FQCNs (e.g. make(B::class)), // so keying on concrete catches cycles entered via autowire even when the // user's binding id differs from the FQCN. The chain returned on the // exception preserves the binding ids for diagnostics. // After the step-3 guard, $concrete is guaranteed to be either a class-string // (the autowire path) or an object (Closure or pre-built instance) — never // a primitive scalar. The is_object check narrows mixed to object for // spl_object_hash(). if (is_string($concrete)) { $resolutionKey = $concrete; } elseif (is_object($concrete)) { $resolutionKey = spl_object_hash($concrete); } else { // Unreachable after the step-3 guard: if $definition is non-null, // $concrete came from $definition->concrete (Closure|object|class-string // per ServiceDefinition docblock); if $definition is null, step 3 // required $concrete to be a class-string. Throwing here is a defensive // invariant — if it ever fires, a new ServiceDefinition concrete type // was introduced without updating this dispatch. throw new \LogicException( 'Unreachable: $concrete is neither string nor object after step-3 guard. ' . 'Got type: ' . get_debug_type($concrete) ); } // Per-Fiber cycle detection (ADR-017 Fiber safety fix), via WeakMap. // Outside any Fiber, fall back to the dedicated main-context state — // WeakMap cannot be keyed on "no Fiber". /** @var \Fiber|null $cycleFiber */ $cycleFiber = \Fiber::getCurrent(); if ($cycleFiber !== null) { if (!isset($this->fiberResolving[$cycleFiber])) { $this->fiberResolving[$cycleFiber] = ['resolving' => [], 'chain' => []]; } $resolving = &$this->fiberResolving[$cycleFiber]['resolving']; $resolvingChain = &$this->fiberResolving[$cycleFiber]['chain']; } else { $resolving = &$this->mainResolving; $resolvingChain = &$this->mainResolvingChain; } if (isset($resolving[$resolutionKey])) { $chain = array_map( static fn(array $pair) => $pair[0], $resolvingChain, ); $chain[] = $id; throw CircularDependencyException::fromChain($chain); } // 5. Push onto both the cycle-detection set and the chain. $resolving[$resolutionKey] = true; $resolvingChain[] = [$id, $concrete]; try { // 6. Build by concrete type. $object = $this->build($concrete, $parameters); } finally { // 7. Always pop, even on exception — no state leak (Security Property #3). unset($resolving[$resolutionKey]); array_pop($resolvingChain); } // 8. Cache shared singletons (worker-scoped). if ($definition !== null && $definition->shared) { $this->instances[$id] = $object; } // 8b. Cache pulse-scoped instances (per-Fiber, via WeakMap). if ($definition !== null && $definition->pulseScoped) { /** @var \Fiber|null $fiber */ $fiber = \Fiber::getCurrent(); if ($fiber !== null) { // Initialize the inner array on first write to this Fiber — // avoids "cannot assign offset to null" and the mixed-type // inference ambiguity on chained WeakMap[$fiber][$id] = $obj. if (!isset($this->pulseInstances[$fiber])) { $this->pulseInstances[$fiber] = []; } $this->pulseInstances[$fiber][$id] = $object; } } return $object; } /** * PSR-11 get(): throws NotFoundException on unknown ids. * * SECURITY: has() returns true for any autoloadable class (via class_exists()), * so get() CAN autowire arbitrary class-strings that haven't been explicitly * bound. This is the PSR-11 "auto-resolution" behavior — it's intentional for * developer convenience (controllers, listeners, etc.) but callers MUST NOT * pass untrusted user input as $id to get()/has()/make(), as this would allow * instantiation of arbitrary autoloadable classes. * * @param string $id Service identifier or class-string. MUST NOT be user input. * @return mixed The resolved service instance. * @throws NotFoundException If $id is not bound, not pre-instantiated, and not an autoloadable class. */ public function get(string $id): mixed { if (!$this->has($id)) { throw new NotFoundException("No service registered for id [$id]."); } return $this->make($id); } /** * PSR-11 has(): true if the id is bound, pre-instantiated, or * refers to an autoloadable class (the latter supports autowiring * of unregistered class-strings via make()). */ public function has(string $id): bool { return array_key_exists($id, $this->instances) || array_key_exists($id, $this->definitions) || class_exists($id); } public function addCompilerPass(CompilerPassInterface $pass): void { $this->assertNotCompiled(); $this->compilerPasses[] = $pass; } public function compile(): void { // Idempotent: a second call is a no-op. if ($this->compiled) { return; } foreach ($this->compilerPasses as $pass) { $pass->process($this); } $this->compiled = true; } // ----- ContainerBuilderInterface ----- public function getDefinitions(): array { return $this->definitions; } public function getDefinition(string $id): ServiceDefinition { if (!array_key_exists($id, $this->definitions)) { throw new NotFoundException("No definition registered for id [$id]."); } return $this->definitions[$id]; } public function hasDefinition(string $id): bool { return array_key_exists($id, $this->definitions); } // ----- Internals ----- /** * Guard: mutation methods must not run after compile(). */ private function assertNotCompiled(): void { if ($this->compiled) { throw new \LogicException( 'Cannot modify the container after it has been compiled.' ); } } /** * Purge any pulse-scoped cached instance for $id across every live Fiber. * * bind()/pulse() already invalidate $instances[$id] on rebind; this is * the equivalent for $pulseInstances. WeakMap does not support a direct * nested unset() — `unset($this->pulseInstances[$fiber][$id])` silently * no-ops (PHP raises "Indirect modification of overloaded element of * WeakMap has no effect" and leaves the entry in place) — so each * Fiber's inner array must be read out, modified, and written back. */ private function invalidatePulseInstances(string $id): void { /** @var \Fiber $fiber */ foreach ($this->pulseInstances as $fiber => $cached) { if (array_key_exists($id, $cached)) { unset($cached[$id]); $this->pulseInstances[$fiber] = $cached; } } } /** * Purge the cached pulse instance for $id in the CURRENT Fiber only. * * Called by {@see pulse()} when a new value is bound for the current * Pulse — the previously-cached object (if any) is stale and must not * be returned by the next make() in this Fiber. * * Per CORE-02 edge case #11: invalidation is Fiber-local. Other Fibers' * cached pulse instances (if any) are NOT touched — they hold their OWN * pulse values, which are independent by the Fiber-isolation invariant. * * WeakMap does not support a direct nested unset() — * `unset($this->pulseInstances[$fiber][$id])` silently no-ops (PHP raises * "Indirect modification of overloaded element of WeakMap has no effect" * and leaves the entry in place) — so the inner array must be read out, * modified, and written back. Mirrors invalidatePulseInstances() but * scoped to a single Fiber. */ /** * @param \Fiber $fiber */ private function invalidateCurrentFiberPulseInstance(\Fiber $fiber, string $id): void { if (!isset($this->pulseInstances[$fiber])) { return; } $cached = $this->pulseInstances[$fiber]; if (array_key_exists($id, $cached)) { unset($cached[$id]); $this->pulseInstances[$fiber] = $cached; } } /** * Construct an instance from the concrete binding. * * Dispatches on the concrete's runtime type via a match expression: * - Closure -> invoke with ($container, $parameters) * - object (non-Closure) -> return as-is (from instance() or bind($id, $obj)) * - class-string -> autowire via reflection * - interface-string -> throw NotFoundException (must be bound to concrete) * - any other scalar -> return as-is (primitive bindings from explicit bind()) * * Note: the scalar arm is reachable ONLY via an explicit `bind('key', $scalar)` * call — make() rejects unbound non-class strings before reaching build(), so * this default arm cannot accidentally return an unbound id as a string. * * @throws NotFoundException If a class-string concrete does not exist. * @param array $parameters */ private function build(mixed $concrete, array $parameters): mixed { return match (true) { $concrete instanceof \Closure => $concrete($this, $parameters), is_object($concrete) => $concrete, is_string($concrete) && class_exists($concrete) => $this->autowire($concrete, $parameters), is_string($concrete) && interface_exists($concrete) => throw new NotFoundException("Interface [$concrete] cannot be resolved directly; bind to a concrete implementation."), default => $concrete, // primitive value from explicit bind('key', $scalar) }; } /** * Reflective autowiring. * * For each constructor parameter (in declaration order): * 1. If $parameters contains the parameter name -> use the supplied value. * 2. Else if the parameter has a default -> use the default. * 3. Else if the parameter has a class-string type hint -> recurse into make(). * 4. Else -> throw NotFoundException (cannot resolve primitive without default). * * Union types, intersection types, and `readonly` properties on the * constructor are handled implicitly by ReflectionParameter: only * {@see \ReflectionNamedType} with isBuiltin() = false is recursed; * everything else falls through to the default-or-throw path. * @param array $parameters */ private function autowire(string $class, array $parameters): object { /** @var class-string $class */ $reflection = new \ReflectionClass($class); if (!$reflection->isInstantiable()) { throw new NotFoundException( "Class [$class] is not instantiable (abstract, interface, or trait)." ); } $constructor = $reflection->getConstructor(); if ($constructor === null) { return new $class(); } $arguments = []; foreach ($constructor->getParameters() as $parameter) { $name = $parameter->getName(); // 1. Caller-supplied override (by name, then by position). if (array_key_exists($name, $parameters)) { $arguments[] = $parameters[$name]; continue; } $position = $parameter->getPosition(); if (array_key_exists($position, $parameters)) { $arguments[] = $parameters[$position]; continue; } // 2. Default value. if ($parameter->isDefaultValueAvailable()) { $arguments[] = $parameter->getDefaultValue(); continue; } // 3. Class-string type hint -> recurse (cycle detection applies). $type = $parameter->getType(); if ($type instanceof \ReflectionNamedType && !$type->isBuiltin()) { $className = $type->getName(); if (class_exists($className) || interface_exists($className)) { $arguments[] = $this->make($className); continue; } } // 4. Unresolvable primitive. throw new NotFoundException( "Cannot resolve parameter [\$$name] of [$class]: no type hint, " . "no default value, and no caller-supplied value." ); } return $reflection->newInstanceArgs($arguments); } }