Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .horde.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ dependencies:
horde/translation: ^3
horde/url: ^3
horde/util: ^3
horde/oauth: ^4
horde/view: ^3
php81_bc/strftime: ^0.7
ext:
Expand All @@ -108,9 +109,14 @@ dependencies:
sockets: '*'
dev:
composer:
horde/activesync: ^3
horde/kolab_server: ^3
horde/kolab_session: ^3
horde/ldap: ^3
horde/mongo: ^2
horde/routes: ^3
horde/test: ^3
horde/tree: ^3
horde/routes: ^3
horde/vfs: ^3
autoload:
classmap:
Expand Down
176 changes: 176 additions & 0 deletions doc/COMPILING_DI.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
# Compiling a Dependency Injection Binding Map

Horde's Injector resolves dependencies at runtime through autowiring and
factory/implementation bindings registered during bootstrap. This works
correctly but means every request repeats the same reflection and binder
lookups. A **compiled binding map** captures the result of those lookups as a
plain PHP array that opcache can keep in shared memory, eliminating the
repeated work on subsequent requests.

Building a complete map is a progressive process: different pages exercise
different code paths and register different bindings. You run profiling across
representative requests, merge the results and then ship the final map as a
static config file.

## Prerequisites

The `horde/event-dispatcher` package must be installed:

```
composer require horde/event-dispatcher
```

It is listed as a suggestion in `horde/injector` and is not required at
runtime. The profiling code path is guarded — if the package is missing, a
warning is logged and the application continues normally.

## Step 1: Enable profiling

Set the `HORDE_INJECTOR_PROFILE` environment variable before a request:

```bash
# Apache — add to virtualhost or .htaccess
SetEnv HORDE_INJECTOR_PROFILE 1

# nginx — add to location block
fastcgi_param HORDE_INJECTOR_PROFILE 1;

# CLI
HORDE_INJECTOR_PROFILE=1 php horde/index.php
```

By default the binding map is written to
`sys_get_temp_dir()/horde_injector_bindings.php`. Override the path with a
second variable:

```bash
HORDE_INJECTOR_PROFILE=1 \
HORDE_INJECTOR_PROFILE_PATH=/var/cache/horde/bindings.php \
php horde/index.php
```

## Step 2: Exercise the application

Each request dumps the bindings that were registered during that request.
To build a complete map you need to hit every code path that registers
bindings:

1. **Log in** to Horde (creates session, auth prefs bindings).
2. **Visit each application** — at minimum load the main page of every
installed app (IMP, Kronolith, Turba, etc.).
3. **Trigger background tasks** — run any CLI cron jobs or AJAX endpoints
that register additional factories.
4. **Exercise administrative pages** — configuration, user management and
permission screens often bind factories that normal pages do not.

Each request overwrites the output file. To accumulate bindings across
requests, copy or merge files between runs (see Step 3).

## Step 3: Merge multiple runs

Each dump is a valid PHP file that returns an associative array:

```php
<?php
declare(strict_types=1);
return [
'Horde_Cache' => ['Horde_Core_Factory_Cache', 'create'],
'Horde_Db_Adapter' => ['Horde_Core_Factory_Db', 'create'],
'Horde_Group' => 'Horde_Group_Sql',
// ...
];
```

To combine multiple dumps into a single map:

```php
<?php
// merge_bindings.php
$merged = [];
foreach (glob('/var/cache/horde/bindings_*.php') as $file) {
$merged = array_merge($merged, require $file);
}

$writer = new \Horde\Injector\BindingMapWriter();
$writer->write('/var/cache/horde/bindings_merged.php', $merged);
```

Later runs add new entries without losing earlier ones.

## Step 4: Load the compiled map

Pass the binding map to the Injector constructor:

```php
$bindings = require '/var/cache/horde/bindings_merged.php';
$injector = new Horde\Injector\Injector(new Horde\Injector\TopLevel(), $bindings);
```

Or load it after construction:

```php
$injector->loadBindings(require '/var/cache/horde/bindings_merged.php');
```

Bindings loaded this way are additive — they do not remove existing bindings,
and runtime `bindFactory()`/`bindImplementation()` calls still override them.

## Step 5: Disable profiling

Remove the environment variables once you have a satisfactory map. With
profiling disabled the Injector's event dispatcher slot remains `null` and
there is zero runtime overhead — the dispatch call sites are guarded by a
null check that the branch predictor eliminates.

## What gets captured (and what does not)

The binding map captures two types of bindings:

- **Implementation bindings** — `'InterfaceName' => 'ConcreteClass'`
- **Factory bindings** — `'InterfaceName' => ['FactoryClass', 'method']`

Closure bindings cannot be serialized to a PHP array file. They are reported
separately in the Horde log at DEBUG level:

```
Injector profiling: 3 uncacheable bindings: Horde\Util\Variables, ...
```

Review these after profiling. If a Closure binding is performance-critical,
consider converting it to a factory class so it can be included in the
compiled map.

## Binding map format reference

The array format is intentionally simple for opcache efficiency:

| Value type | Meaning | Example |
|---|---|---|
| `string` | Implementation binding | `'Horde_Cache' => 'Horde_Cache_Storage_File'` |
| `[string, string]` | Factory binding | `'Horde_Db_Adapter' => ['Horde_Core_Factory_Db', 'create']` |

This is the same format accepted by `Injector::loadBindings()` and produced
by `BindingMapWriter::write()`.

## Troubleshooting

**"Injector profiling failed to initialize"** (logged at WARN level)
The EventDispatcher could not be resolved. Verify that `horde/event-dispatcher`
is installed and its factory is registered in the Injector.

**Output file is not created**
Check that the output directory is writable by the web server process. If
using the default path, verify `sys_get_temp_dir()` returns a writable
directory.

**Missing bindings after loading the map**
The map only contains bindings that were registered during profiled requests.
Exercise additional code paths and merge the results. Some bindings are
registered conditionally (e.g., based on configuration or installed apps) and
will only appear when those conditions are met.

**Application behaves differently with the compiled map**
The compiled map provides the same bindings that would have been registered
at runtime. If behavior differs, a Closure binding that performs
request-specific logic may have been replaced by a static Implementation
binding. Check the uncacheable list in the profiling log.
9 changes: 9 additions & 0 deletions js/prototype.js
Original file line number Diff line number Diff line change
Expand Up @@ -7152,6 +7152,15 @@ Form.EventObserver = Class.create(Abstract.EventObserver, {
event.memo = memo;

element.dispatchEvent(event);

// Bridge: also fire a native CustomEvent so that vanilla
// addEventListener(eventName, ...) listeners can receive it.
element.dispatchEvent(new CustomEvent(eventName, {
bubbles: bubble,
cancelable: true,
detail: memo
}));

return event;
}

Expand Down
33 changes: 31 additions & 2 deletions lib/Horde/Registry.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@
use Horde\Core\Factory\DriverRepositoryFactory;
use Horde\Core\Factory\EventDispatcherFactory;
use Horde\Core\Factory\HttpClientFactory;
use Horde\Core\Factory\LoggerFactory;
use Horde\Core\Factory\SimpleCacheFactory;
use Horde\Core\Factory\TinymceFactory;
use Horde\Core\Factory\TinymcePageBinderFactory;
use Horde\Core\Horde;
use Horde\Editor\Tinymce;
use Horde\Log\Logger as HordeLogger;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\EventDispatcher\ListenerProviderInterface;
use Psr\Http\Client\ClientInterface as PsrHttpClientInterface;
Expand Down Expand Up @@ -467,14 +469,16 @@ public function __construct($session_flags = 0, array $args = [])
'Horde_Template' => 'Horde_Core_Factory_Template',
'Horde_Timezone' => 'Horde_Core_Factory_Timezone',
'Horde_Token' => 'Horde_Core_Factory_Token',
Horde\Token\Token::class => Horde\Core\Factory\TokenServiceFactory::class,
'Horde_Variables' => 'Horde_Core_Factory_Variables',
'Horde_View' => 'Horde_Core_Factory_View',
'Horde_View_Base' => 'Horde_Core_Factory_View',
'Horde_Weather' => 'Horde_Core_Factory_Weather',
'Net_DNS2_Resolver' => 'Horde_Core_Factory_Dns',
'Text_LanguageDetect' => 'Horde_Core_Factory_LanguageDetect',
Horde\Core\Middleware\AuthHttpBasic::class => Horde\Core\Factory\AuthHttpBasicFactory::class,
Horde\Log\Logger::class => Horde\Core\Factory\LoggerFactory::class,
HordeLogger::class => LoggerFactory::class,
PsrLoggerInterface::class => LoggerFactory::class,
'Horde\\Horde\\Service\\JwtService' => 'Horde\\Horde\\Factory\\JwtServiceFactory',
'Horde\\Horde\\Service\\AuthenticationService' => 'Horde\\Horde\\Factory\\AuthenticationServiceFactory',
'Horde\\Core\\Config\\ConfigLoader' => 'Horde\\Core\\Factory\\ConfigLoaderFactory',
Expand All @@ -500,7 +504,6 @@ public function __construct($session_flags = 0, array $args = [])
/* Define implementations. */
$implementations = [
'Horde_Controller_ResponseWriter' => 'Horde_Controller_ResponseWriter_Web',
PsrLoggerInterface::class => Horde\Log\Logger::class,
RequestFactoryInterface::class => Horde\Http\RequestFactory::class,
];

Expand Down Expand Up @@ -551,6 +554,32 @@ function () {
set_time_limit($conf['max_exec_time']);
}

/* Optional injector profiling — activated by env var. */
if (getenv('HORDE_INJECTOR_PROFILE')) {
try {
$dispatcher = $injector->getInstance(
EventDispatcherInterface::class
);
$provider = $injector->getInstance(
ListenerProviderInterface::class
);
$collector = new \Horde\Injector\BindingMapCollector();
$provider->addListener($collector);
$injector->setEventDispatcher($dispatcher);

$dumpPath = getenv('HORDE_INJECTOR_PROFILE_PATH')
?: sys_get_temp_dir() . '/horde_injector_bindings.php';
Horde_Shutdown::add(
new \Horde\Core\InjectorProfilingShutdownTask($injector, $dumpPath)
);
} catch (Throwable $e) {
Horde::log(
'Injector profiling failed to initialize: ' . $e->getMessage(),
Horde_Log::WARN
);
}
}

/* The basic framework is up and loaded, so set the init flag. */
$this->hordeInit = true;

Expand Down
36 changes: 24 additions & 12 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/13.0/phpunit.xsd" bootstrap="test/bootstrap.php" cacheDirectory=".phpunit.cache/code-coverage" executionOrder="depends,defects" beStrictAboutOutputDuringTests="true" failOnRisky="true" failOnWarning="true">
<testsuites>
<testsuite name="horde/core">
<directory>test</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory suffix=".php">lib</directory>
<directory suffix=".php">src</directory>
</include>
</source>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.0/phpunit.xsd"
bootstrap="vendor/autoload.php"
cacheDirectory=".phpunit.cache"
executionOrder="depends,defects"
requireCoverageMetadata="false"
beStrictAboutOutputDuringTests="true"
failOnRisky="true"
failOnWarning="true"
colors="true">
<testsuites>
<testsuite name="unit">
<directory>test/Unit</directory>
</testsuite>
<testsuite name="integration">
<directory>test/Integration</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory suffix=".php">lib</directory>
<directory suffix=".php">src</directory>
</include>
</source>
</phpunit>
3 changes: 2 additions & 1 deletion src/Factory/EventDispatcherFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
use Psr\EventDispatcher\ListenerProviderInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Throwable;

/**
* Factory for PSR-14 EventDispatcher and ListenerProvider
Expand Down Expand Up @@ -58,7 +59,7 @@ public function create(Injector $injector): EventDispatcher

try {
$logger = $injector->getInstance(LoggerInterface::class);
} catch (\Throwable) {
} catch (Throwable) {
$logger = new NullLogger();
}

Expand Down
49 changes: 49 additions & 0 deletions src/Factory/OAuthTokenServiceFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

/**
* Copyright 2026 The Horde Project (http://www.horde.org/)
*
* See the enclosed file LICENSE for license information (LGPL). If you
* did not receive this file, see http://www.horde.org/licenses/lgpl21.
*
* @category Horde
* @package Core
* @author Ralf Lang <ralf.lang@ralf-lang.de>
* @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
*/

namespace Horde\Core\Factory;

use Horde\Core\Config\ConfigLoader;
use Horde\Core\Service\NullOAuthTokenService;
use Horde\Core\Service\OAuthTokenService;
use Horde_Injector;

/**
* Factory for OAuthTokenService.
*
* Returns the configured token service implementation. Defaults to
* NullOAuthTokenService when OAuth is not configured.
*
* @category Horde
* @package Core
* @author Ralf Lang <ralf.lang@ralf-lang.de>
* @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
*/
class OAuthTokenServiceFactory
{
public function create(Horde_Injector $injector): OAuthTokenService
{
$loader = $injector->getInstance(ConfigLoader::class);
$state = $loader->load('horde');

$driver = $state->get('oauth.token_driver', 'null');

return match (strtolower($driver)) {
'null', '' => new NullOAuthTokenService(),
default => new NullOAuthTokenService(),
};
}
}
Loading
Loading