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:

  1. Event wrapper (src/wrapper/) — abstracts the non-blocking I/O loop. IEventWrapper is 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-selects SwooleWrapper (the one concrete adapter in src/wrapper/swoole/); when adding a new backend, drop it under src/wrapper/<name>/ and extend the factory's detection block. TestEventWrapper (src/wrapper/test/) drives services offline in tests.

  2. Protocol (src/protocol/) — frames bytes into Request objects and serialises Response objects. BaseProtocol buffers partial reads per connection until a frame is complete; FestiJsonProtocol (the default returned by AbstractService::getDefaultProtocol) uses JSON terminated by ^. Override setProtocol() on the service to swap in a different on-the-wire format.

  3. Service (src/service/) — AbstractService (src/service/AbstractService.php) wires wrapper events (onAccept, onRead, onWrite, onClose, onError) into a request lifecycle. execRequest() resolves the session (via ISessionStorage), then walks each registered Extension (IExtension::onRequest) before falling back to the subclass's own onRequest. The first to return true wins. 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. The Extension trait + IExtensionProvider interface 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; in onInit() 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 via ParameterDecorator / ObjectParameterDecorator (src/util/decorator/) — scalar/array params go through ParameterDecorator, typed object params get hydrated by ObjectParameterDecorator. Plugins implementing IAsyncPlugin (AbstractAsyncPlugin) get the active IConnection, Session, and IService injected before each call. Instantiation is delegated to IPluginFactory (default PluginFactory) — replace it for DI.
  • Events — both IEventWrapper and IService extend \IEventDispatcher (from core). The service publishes ServiceEvents (e.g. EVENT_ON_BEFORE_CLOSE_CONNECTION) for extensions and external listeners. Wrap external listeners as closures, not bound methods — AbstractService::initListeners holds $instance by reference because the loop callbacks are wired before the subclass onInit.
  • Logger (src/logger/, src/service/LoggerHelper.php) — LoggerHelper is a thin facade around an injected ILogger. If null is 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 lineLimit and absoluteLineLimit)
  • 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 through TestEventWrapper without a real socket. Use this for unit tests of services, extensions, and protocols.
  • Integration tests extend FestiAsyncTestCase (tests/util/FestiAsyncTestCase.php) — uses Thread.php (a pcntl/fork helper) to spawn tests/server/PingPongDaemon.php and send real TCP requests. Needs a loaded I/O extension (Swoole today, since it's the only wired backend) and a free DAEMON_PORT (20000). Also requires screen and ps on the PATH — the test base shells out to screen -d -m to 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 by phpunit.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, runs phpunit against the host's vendor/. The compose file does not mount SSH; run composer install on the host first (SSH keys live there) or use the shell service.
  • shell (profile dev) — same image, interactive bash, mounts ~/.ssh:/root/.ssh:ro so composer install can authenticate against gitlab.varteq.com if 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.