AGENTS.md
This file provides guidance to AI coding assistants (Claude Code, Codex, etc.) when working with code in this repository. CLAUDE.md is a symlink to this file.
Project
festi-team/festi-framework-async — the Festi Async Framework, a PHP 8 async framework for building long-running TCP daemon services. Servers expose a JSON wire protocol over a non-blocking event loop and dispatch requests either through a custom onRequest or through the JSON-RPC RpcExtension. The I/O backend is pluggable via the IEventWrapper interface — Swoole is the shipped adapter; libevent, libev, and EventEvent are supported by the same contract (phan stubs and the project installer reflect this; only Swoole has a concrete adapter in src/wrapper/swoole/ today).
Common commands
All test, lint, and analysis commands assume composer install has been run. The repo is a library that depends on sibling packages (festi-framework-core, -cacher, -database, -serialization) pulled from https://packages.festi.io/; core resolves to ../core at runtime via CORE_ROOT in autoload.php:5.
# Install / refresh
composer install
git submodule update --init --recursive
# Run the full test suite (must run from tests/, bootstrap relies on relative paths)
cd tests && ../vendor/bin/phpunit --configuration ./phpunit.xml
# Run one test file
cd tests && ../vendor/bin/phpunit --configuration ./phpunit.xml ./Service/DefaultServiceTest.php
# Run a single test method
cd tests && ../vendor/bin/phpunit --configuration ./phpunit.xml --filter testRpcMethod ./Extension/RpcExtensionTest.php
# Run one suite (Integration | Service | EventWrapper | Extension)
cd tests && ../vendor/bin/phpunit --configuration ./phpunit.xml --testsuite Extension
# Coverage (matches CI)
cd tests && XDEBUG_MODE=coverage ../vendor/bin/phpunit --coverage-text --colors=never
# Code style (Festi ruleset)
./vendor/bin/phpcs --standard=./.phan/ruleset.xml -d memory_limit=1024M --extensions=php ./src/
# Static analysis
./vendor/bin/phan
phpunit.xml is configured with stopOnError|Failure|Incomplete|Skipped="true" — the first failure halts the run. Integration tests in tests/Integration/ spawn a real daemon process via tests/util/Thread.php and connect over TCP (DAEMON_HOST:DAEMON_PORT from tests/bootstrap.php), so Swoole must be loaded in the PHP CLI.
Architecture
The framework is a stack of three concerns, each behind a single-method interface so individual layers are swappable in tests:
-
Event wrapper (
src/wrapper/) — abstracts the non-blocking I/O loop.IEventWrapperis the only place the loop, timers, and socket I/O are touched, and is intentionally backend-agnostic: the project supports Swoole, libevent (event), libev, and EventEvent as I/O drivers.WrapperFactory::create()(src/wrapper/WrapperFactory.php) currently only auto-selectsSwooleWrapper(the one concrete adapter insrc/wrapper/swoole/); when adding a new backend, drop it undersrc/wrapper/<name>/and extend the factory's detection block.TestEventWrapper(src/wrapper/test/) drives services offline in tests. -
Protocol (
src/protocol/) — frames bytes intoRequestobjects and serialisesResponseobjects.BaseProtocolbuffers partial reads per connection until a frame is complete;FestiJsonProtocol(the default returned byAbstractService::getDefaultProtocol) uses JSON terminated by^. OverridesetProtocol()on the service to swap in a different on-the-wire format. -
Service (
src/service/) —AbstractService(src/service/AbstractService.php) wires wrapper events (onAccept,onRead,onWrite,onClose,onError) into a request lifecycle.execRequest()resolves the session (viaISessionStorage), then walks each registered Extension (IExtension::onRequest) before falling back to the subclass's ownonRequest. The first to returntruewins.PersistService(src/service/PersistService.php) is the long-lived variant; non-persistent services close after each response.
Cross-cutting pieces:
- Extensions (
src/service/extension/) are the composition mechanism for adding request-handling features. TheExtensiontrait +IExtensionProviderinterface let a service register N extensions; each gets a chance at every request. - RPC (
src/service/rpc/) is the canonical extension.RpcExtension::addComponent(ClassName::class)registers a plugin class; inonInit()reflection scans methods for the#[RpcMethod]PHP 8 attribute (src/service/rpc/RpcMethod.php) and indexes them by name (or attribute alias). Parameter type hints drive auto-deserialisation viaParameterDecorator/ObjectParameterDecorator(src/util/decorator/) — scalar/array params go throughParameterDecorator, typed object params get hydrated byObjectParameterDecorator. Plugins implementingIAsyncPlugin(AbstractAsyncPlugin) get the activeIConnection,Session, andIServiceinjected before each call. Instantiation is delegated toIPluginFactory(defaultPluginFactory) — replace it for DI. - Events — both
IEventWrapperandIServiceextend\IEventDispatcher(fromcore). The service publishesServiceEvents (e.g.EVENT_ON_BEFORE_CLOSE_CONNECTION) for extensions and external listeners. Wrap external listeners as closures, not bound methods —AbstractService::initListenersholds$instanceby reference because the loop callbacks are wired before the subclassonInit. - Logger (
src/logger/,src/service/LoggerHelper.php) —LoggerHelperis a thin facade around an injectedILogger. Ifnullis passed to the service, log calls are no-ops (test-friendly).
Bootstrapping a service
autoload.php (autoload.php:18-52) loads core (the framework's foundation living at ../core relative to this repo) and require_onces every async file. Don't add require_once for new files inside src/ — extend the existing list in autoload.php if you add a class that isn't reachable through PSR-4 yet, but prefer letting Composer's PSR-4 (core\async\ → src/, core\logger\ → src/logger/) do the work.
A working server looks like tests/server/PingPongDaemon.php — build a WrapperConfig, hand it to WrapperFactory::create(), instantiate a *Service with "tcp://host:port" and the wrapper, then start(). The installer at tools/main.php + tools/dist/ scaffolds this skeleton for a new project (bash tools/install.sh).
Code style
./.phan/ruleset.xml (the phpcs standard) enforces:
- 120-char line limit (both
lineLimitandabsoluteLineLimit) - Unix line endings, no short open tags, no tab indentation
- BSD/Allman opening braces for functions, PEAR control-structure signatures, PEAR class declarations
- No global functions (
Squiz.Functions.GlobalFunction)
phan targets PHP 8.0 with allow_missing_properties=true and null_casts_as_any_type=true (.phan/config.php:46-58) — be lenient with type narrowing but explicit on parameter/return types in new code (the codebase is fully typed in newer files like RpcExtension.php).
Testing conventions
- Offline tests extend
OfflineFestiTestCase(tests/util/OfflineFestiTestCase.php) — drives the service throughTestEventWrapperwithout a real socket. Use this for unit tests of services, extensions, and protocols. - Integration tests extend
FestiAsyncTestCase(tests/util/FestiAsyncTestCase.php) — usesThread.php(a pcntl/fork helper) to spawntests/server/PingPongDaemon.phpand send real TCP requests. Needs a loaded I/O extension (Swoole today, since it's the only wired backend) and a freeDAEMON_PORT(20000). Also requiresscreenandpson the PATH — the test base shells out toscreen -d -mto background the daemon. - New tests must be placed under the suite directory matching their nature (
Integration/,Service/,EventWrapper/,Extension/); only those four directories are picked up byphpunit.xml. - Per the global guidelines: test names describe scenarios (
testRpcMethod,testUndefinedRpcMethod), not method names.
Docker
The repo ships a Dockerfile (PHP 8.4 CLI + Swoole + pcntl/pdo/intl/sockets + screen/procps/openssh-client) and docker-compose.yml. Two services:
test— mounts.to/app, runsphpunitagainst the host'svendor/. The compose file does not mount SSH; runcomposer installon the host first (SSH keys live there) or use theshellservice.shell(profiledev) — same image, interactive bash, mounts~/.ssh:/root/.ssh:rosocomposer installcan authenticate againstgitlab.varteq.comif you need to run it inside the container.
docker compose build
docker compose run --rm test
docker compose --profile dev run --rm shell
Patterns observed in real consumer services
Real services built on this framework (see php_insanerally_server for a worked example) consistently follow these patterns. Suggest them when scaffolding new services and recognise them when reading existing code:
Project layout
bin/
├── server.sh # boot script, runs main.php
├── main.php # WrapperFactory + WrapperConfig + new MyService(...)->start()
├── common.php # loads config.php (+ optional local.php), defines constants, vendor/autoload.php
└── config.php # DB creds, ports, log paths
src/
├── MyService.php # extends PersistService, registers extensions in onInit, timers in onStart
├── plugin/ # one class per logical RPC namespace (UserPlugin, RoomPlugin, ...)
├── facade/ # business logic, called by plugins (interface + impl pair: IUserFacade + UserFacade)
├── storage/ # repositories (Festi DAOs)
├── domain/ # entities and EventBus
└── libs/
├── DefaultPlugin.php # project-wide AbstractAsyncPlugin subclass with session helpers
├── AppInjectionModule.php # DI bindings (festi-team/festi-framework-di)
└── vo/ # value objects auto-hydrated by ObjectParameterDecorator
Custom plugin base
Project-wide DefaultPlugin extends AbstractAsyncPlugin adds session helpers used by every plugin:
class DefaultPlugin extends AbstractAsyncPlugin
{
protected function getUserID(): int
{
$id = $this->session->get('id_user');
if (!$id) throw new PermissionException();
return $id;
}
protected function getToken(): string { /* … */ }
protected function getService(): IServiceApp
{
assert($this->service instanceof IServiceApp);
return $this->service; // narrows IService to the app-specific interface
}
}
DI integration
Services pair with festi-team/festi-framework-di. The convention is a static getInjector() on the service that lazily constructs an Injector from an AppInjectionModule:
public static function getInjector(): Injector
{
if (!isset(static::$injector)) {
static::$injector = new Injector(new AppInjectionModule(static::$instance));
}
return static::$injector;
}
Plugins reach into it from their getFacade() methods rather than constructor-injecting (because RpcExtension's default PluginFactory instantiates plugins with no arguments — swap it via setCreateFactory(IPluginFactory) if you want real constructor injection).
Connection ↔ user mapping
Persistent services routinely need user-keyed lookups (to push messages, route timers, etc.). Maintain three maps on the service:
private array $_usersIDsToConnections = []; // userId => connectionId
private array $_connectionsToUserIDs = []; // connectionId => userId
private array $_userIDsToRoomIDs = []; // userId => roomId
Set them when authentication completes inside the plugin ($this->getService()->setConnectionByUserID(...)), and tear them down in the service's overridden onClose.
Server → client push
PersistService exposes the protocol back to you, so plugins can push notifications to a specific user:
public function callRPC(int $idUser, string $method, array $params): bool
{
$connection = $this->getConnectionByUserID($idUser);
$payload = ['jsonrpc' => '2.0', 'method' => $method, 'params' => $params];
return $this->response(new Response($payload), $connection);
}
Note the missing id field — without it the message is a JSON-RPC notification, so clients shouldn't expect a reply.
onRequest in RPC-only services
When everything routes through RpcExtension, the service's onRequest typically just returns false so the extension handles it. Don't delete the override — AbstractService::onRequest is abstract.
RpcMethod attribute forms
#[RpcMethod] // exposed as method name verbatim
public function add(int $a, int $b): int
#[RpcMethod(name: 'math.sub')] // explicit alias
public function subtract(int $a, int $b): int
The named form is essential for namespaced JSON-RPC method names (user.auth, room.join).
Testing real services
Consumers expose OfflineFestiTestCase to their own tests via composer:
"autoload-dev": {
"psr-4": {
"": ["tests", "vendor/festi-team/festi-framework-async/tests/util"]
}
}
Then a project-wide test base extends it and overrides createService():
class MyServiceTestCase extends OfflineFestiTestCase
{
protected function createService(): IService
{
return new MyService(
'tcp://0.0.0.0:'.DAEMON_PORT,
new TestEventWrapper(),
new Logger(FS_LOGS)
);
}
public function rpc(string $method, array $params = []): array
{
return $this->request([
'jsonrpc' => '2.0',
'method' => $method,
'params' => $params,
]);
}
}
Tests then assert against result / error keys directly.
Reference consumer
gitlab.varteq.com:EvolvexGames/php_insanerally_server is the canonical consumer to study when sanity-checking framework changes — it exercises RPC, DI, persistent connections, server-push, timers, sessions, and the offline test harness. If you're proposing a framework-side change that could affect API surface, eyeball that repo for breakage first.
Git
develop is the working branch and the PR base. Never commit/push without an explicit request. Submodules are required for CI (.gitlab-ci.yml:2) — if git status shows submodule drift, re-run git submodule update --init --recursive before touching anything.