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
39 changes: 35 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,13 @@ while (true) {

`ReconnectingTransport` is opt-in; `new Session()` alone still uses a plain `UnixSocketTransport` with no automatic recovery.

**v1 limitations**
When the transport implements {@see \Bk203\Vici\Transport\ReconnectableTransportInterface} (including `ReconnectingTransport`), `Session` automatically replays daemon-side `EVENT_REGISTER` calls after each reconnect.

- Retry covers one transport I/O call. Multi-packet commands (`streamedRequest()`, `EventListener::listen()`) can still fail mid-operation; catch `ConnectionException` and restart the command or listener loop.
- Daemon-side `EVENT_REGISTER` state is not replayed after reconnect. Re-register events or wait for a future Session-level restore helper.
**Limitations**

- `request()` / `requireSuccess()` retry once on `ConnectionException`. Multi-packet commands (`streamedRequest()`, `EventListener::listen()`) do not auto-resume mid-operation; catch `ConnectionException` and restart the command or listener loop.
- `TimeoutException` is not retried (slow charon is not treated as a dead socket).
- Custom transports can implement `ReconnectableTransportInterface` and receive the same restore hook via `setOnReconnect()`.

## Common workflows

Expand Down Expand Up @@ -190,13 +192,42 @@ All exceptions extend `Bk203\Vici\Exception\ViciException`:

| Exception | Thrown when |
| ---------------------------- | ----------- |
| `ConnectionException` | Underlying socket cannot connect, closes mid-stream, or `stream_select()` fails |
| `ConnectionException` | Underlying socket cannot connect, closes mid-stream, or `stream_select()` fails. Exposes `->context` (`ConnectionFailureContext`) and `->getDetailedMessage()` with stream metadata, endpoint, partial I/O progress, and PHP error text |
| `TimeoutException` | Read/connect timeout elapses |
| `ProtocolException` | Framing or message-encoding violation on the wire |
| `CommandUnknownException` | Server replies with `CMD_UNKNOWN` |
| `CommandException` | Command completes with `success = no`; exposes `->command` and `->response` |
| `EventRegistrationException` | Server replies with `EVENT_UNKNOWN` to `EVENT_REGISTER` / `EVENT_UNREGISTER` |

For long-lived loops, log the detailed form when a connection fails:

```php
use Bk203\Vici\Exception\ConnectionException;

try {
$session->version();
} catch (ConnectionException $e) {
error_log($e->getDetailedMessage());
// Inspect $e->context?->endpoint for "socket file exists" vs stale fd
// Inspect $e->context?->streamMeta['eof'] and $e->context?->phpError
throw $e;
}
```

## Known issues

### Stacked `initiate` commands with short client timeouts can lock the VICI socket

`initiate` can run for a long time on the charon side while IKE negotiation retries play out. That sequence has its own timeout (the `timeout` field in the command message, in milliseconds), independent of the transport read timeout on your `Session`.

If the transport read timeout is shorter than that whole charon-side sequence, the client raises `TimeoutException` before charon sends `CMD_RESPONSE`. Catching that exception and immediately sending another `initiate` — or any other command — on the same socket leaves charon still busy with the earlier command. Repeating this pattern desynchronizes the VICI control channel over time: the socket stops responding, every caller cascades into `TimeoutException`, and the lock affects **all** clients on that socket, including `swanctl` and other tools.

**Mitigations**

- Set transport read timeouts well above the `initiate` message `timeout`, or omit a read timeout for long-running control commands.
- Do not retry `initiate` on the same `Session` after a client-side timeout; treat a wedged socket as requiring a new connection or a charon restart.
- Keep at most one in-flight `initiate` per connection; wait for charon to finish (success, failure, or its own timeout) before trying again.

## Architecture

- `Bk203\Vici\Transport\TransportInterface` — 32-bit length-prefixed framing (max 512 KiB), implemented by `UnixSocketTransport`, `TcpSocketTransport`, and `StreamTransport`.
Expand Down
16 changes: 16 additions & 0 deletions src/Exception/ConnectionException.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,20 @@
*/
final class ConnectionException extends ViciException
{
public function __construct(
string $message,
public readonly ?ConnectionFailureContext $context = null,
?\Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}

public function getDetailedMessage(): string
{
if ($this->context === null || $this->context->format() === '') {
return $this->getMessage();
}

return $this->getMessage() . ' | ' . $this->context->format();
}
}
68 changes: 68 additions & 0 deletions src/Exception/ConnectionFailureContext.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php

declare(strict_types=1);

namespace Bk203\Vici\Exception;

/**
* Snapshot of transport state captured when a {@see ConnectionException} is raised.
*/
final class ConnectionFailureContext
{
/**
* @param array<string, mixed>|null $streamMeta
*/
public function __construct(
public readonly ?string $operation = null,
public readonly ?string $endpoint = null,
public readonly ?array $streamMeta = null,
public readonly ?int $expectedBytes = null,
public readonly ?int $receivedBytes = null,
public readonly ?int $errno = null,
public readonly ?string $phpError = null,
) {
}

public function format(): string
{
$parts = [];

if ($this->operation !== null) {
$parts[] = 'operation=' . $this->operation;
}
if ($this->endpoint !== null) {
$parts[] = 'endpoint=' . $this->endpoint;
}
if ($this->expectedBytes !== null) {
$parts[] = 'expected_bytes=' . $this->expectedBytes;
}
if ($this->receivedBytes !== null) {
$parts[] = 'received_bytes=' . $this->receivedBytes;
}
if ($this->streamMeta !== null) {
if (\array_key_exists('eof', $this->streamMeta)) {
$parts[] = 'stream_eof=' . ($this->streamMeta['eof'] ? '1' : '0');
}
if (\array_key_exists('timed_out', $this->streamMeta)) {
$parts[] = 'stream_timed_out=' . ($this->streamMeta['timed_out'] ? '1' : '0');
}
if (\array_key_exists('unread_bytes', $this->streamMeta)) {
$parts[] = 'stream_unread_bytes=' . $this->streamMeta['unread_bytes'];
}
if (\array_key_exists('blocked', $this->streamMeta)) {
$parts[] = 'stream_blocked=' . ($this->streamMeta['blocked'] ? '1' : '0');
}
if (\array_key_exists('stream_type', $this->streamMeta)) {
$parts[] = 'stream_type=' . $this->streamMeta['stream_type'];
}
}
if ($this->errno !== null) {
$parts[] = 'errno=' . $this->errno;
}
if ($this->phpError !== null) {
$parts[] = 'php_error=' . $this->phpError;
}

return implode(' ', $parts);
}
}
153 changes: 121 additions & 32 deletions src/Session.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@

use Bk203\Vici\Exception\CommandException;
use Bk203\Vici\Exception\CommandUnknownException;
use Bk203\Vici\Exception\ConnectionException;
use Bk203\Vici\Exception\EventRegistrationException;
use Bk203\Vici\Exception\ProtocolException;
use Bk203\Vici\Message\MessageDecoder;
use Bk203\Vici\Message\MessageEncoder;
use Bk203\Vici\Protocol\Packet;
use Bk203\Vici\Protocol\PacketCodec;
use Bk203\Vici\Protocol\PacketType;
use Bk203\Vici\Transport\ReconnectableTransportInterface;
use Bk203\Vici\Transport\ReconnectingTransport;
use Bk203\Vici\Transport\TransportInterface;
use Bk203\Vici\Transport\UnixSocketTransport;
use Generator;
Expand Down Expand Up @@ -49,6 +52,10 @@ public function __construct(
$this->packetCodec = new PacketCodec();
$this->messageEncoder = new MessageEncoder();
$this->messageDecoder = new MessageDecoder();

if ($this->transport instanceof ReconnectableTransportInterface) {
$this->transport->setOnReconnect($this->restoreDaemonState(...));
}
}

public function transport(): TransportInterface
Expand Down Expand Up @@ -96,20 +103,7 @@ public function registerEvent(string $event): void
return;
}

$this->writePacket(Packet::eventRegister($event));

$reply = $this->readUntilControlPacket([
PacketType::EVENT_CONFIRM,
PacketType::EVENT_UNKNOWN,
]);

if ($reply->type === PacketType::EVENT_UNKNOWN) {
throw new EventRegistrationException(
\sprintf('VICI daemon does not know event "%s".', $event),
$event,
);
}

$this->registerEventOnDaemon($event);
$this->registrationRefcount[$event] = 1;
}

Expand Down Expand Up @@ -142,22 +136,13 @@ public function unregisterEvent(string $event): void
*/
public function request(string $command, array $message = []): array
{
$payload = $this->messageEncoder->encode($message);
$this->writePacket(Packet::cmdRequest($command, $payload));

$reply = $this->readUntilControlPacket([
PacketType::CMD_RESPONSE,
PacketType::CMD_UNKNOWN,
]);
try {
return $this->executeRequest($command, $message);
} catch (ConnectionException) {
$this->recoverViaTransportReconnect();

if ($reply->type === PacketType::CMD_UNKNOWN) {
throw new CommandUnknownException(\sprintf(
'VICI daemon does not implement command "%s".',
$command,
));
return $this->executeRequest($command, $message);
}

return $this->messageDecoder->decode($reply->payload);
}

/**
Expand Down Expand Up @@ -241,10 +226,21 @@ public function streamedRequest(string $command, string $streamEvent, array $mes
));
}
} finally {
if (!$commandCompleted) {
$this->drainStreamRemainder($streamEvent);
try {
if (!$commandCompleted) {
$this->drainStreamRemainder($streamEvent);
}
$this->unregisterEvent($streamEvent);
} catch (ConnectionException) {
try {
$this->recoverViaTransportReconnect();
if (!$commandCompleted) {
$this->drainStreamRemainder($streamEvent);
}
$this->unregisterEvent($streamEvent);
} catch (ConnectionException) {
}
}
$this->unregisterEvent($streamEvent);
}
}

Expand Down Expand Up @@ -353,14 +349,107 @@ private function dispatchEvent(string $event, array $message): void
}
}

/**
* @param array<array-key, mixed> $message
* @return array<string, mixed>
*/
private function executeRequest(string $command, array $message): array
{
$payload = $this->messageEncoder->encode($message);
$this->writePacket(Packet::cmdRequest($command, $payload));

$reply = $this->readUntilControlPacket([
PacketType::CMD_RESPONSE,
PacketType::CMD_UNKNOWN,
]);

if ($reply->type === PacketType::CMD_UNKNOWN) {
throw new CommandUnknownException(\sprintf(
'VICI daemon does not implement command "%s".',
$command,
));
}

return $this->messageDecoder->decode($reply->payload);
}

private function registerEventOnDaemon(string $event): void
{
$this->writePacket(Packet::eventRegister($event));

$reply = $this->readUntilControlPacket([
PacketType::EVENT_CONFIRM,
PacketType::EVENT_UNKNOWN,
]);

if ($reply->type === PacketType::EVENT_UNKNOWN) {
throw new EventRegistrationException(
\sprintf('VICI daemon does not know event "%s".', $event),
$event,
);
}
}

private function restoreDaemonState(): void
{
foreach ($this->registrationRefcount as $event => $count) {
if ($count < 1) {
continue;
}
$this->registerEventOnDaemon($event);
}
}

private function recoverViaTransportReconnect(): void
{
if (!$this->transport instanceof ReconnectableTransportInterface) {
throw new ConnectionException('VICI transport does not support reconnection.');
}

$this->transport->reconnect();
}

private function usesSessionIoRecovery(): bool
{
return $this->transport instanceof ReconnectableTransportInterface
&& !$this->transport instanceof ReconnectingTransport;
}

private function writePacket(Packet $packet): void
{
$this->transport->send($this->packetCodec->encode($packet));
$bytes = $this->packetCodec->encode($packet);

try {
$this->transport->send($bytes);
} catch (ConnectionException $e) {
if (!$this->usesSessionIoRecovery()) {
throw $e;
}

$this->recoverViaTransportReconnect();
$this->transport->send($bytes);
}
}

private function readPacket(): Packet
{
try {
return $this->decodeReceivedPacket();
} catch (ConnectionException $e) {
if (!$this->usesSessionIoRecovery()) {
throw $e;
}

$this->recoverViaTransportReconnect();

return $this->decodeReceivedPacket();
}
}

private function decodeReceivedPacket(): Packet
{
$bytes = $this->transport->receive();

return $this->packetCodec->decode($bytes);
}

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

declare(strict_types=1);

namespace Bk203\Vici\Transport;

/**
* Transport that can re-establish a VICI connection after the socket is closed
* or recreated. Implementations may invoke an optional callback after each
* successful reconnect so higher layers can restore daemon-side session state.
*/
interface ReconnectableTransportInterface extends TransportInterface
{
public function reconnect(): void;

/**
* @param callable(): void|null $callback
*/
public function setOnReconnect(?callable $callback): void;
}
Loading
Loading