diff --git a/.gitattributes b/.gitattributes index 7d503af..b8ac0d9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,3 @@ /.gitattributes export-ignore /.gitignore export-ignore -/phpunit.xml.dist export-ignore -/fixtures export-ignore -/tests export-ignore +/proofs/ export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7d05e8..2f3eecb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,72 +1,14 @@ name: CI -on: [push] +on: [push, pull_request] jobs: - phpunit: - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-latest, macOS-latest] - php-version: ['8.2', '8.3'] - dependencies: ['lowest', 'highest'] - name: 'PHPUnit' - steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php-version }} - extensions: mbstring, intl - coverage: xdebug - - name: Composer - uses: "ramsey/composer-install@v2" - with: - dependency-versions: ${{ matrix.dependencies }} - - name: PHPUnit - run: vendor/bin/phpunit --coverage-clover=coverage.clover --stop-on-error - env: - CI: 'github' - - uses: codecov/codecov-action@v1 - with: - token: ${{ secrets.CODECOV_TOKEN }} + blackbox: + uses: innmind/github-workflows/.github/workflows/black-box-matrix.yml@main + coverage: + uses: innmind/github-workflows/.github/workflows/coverage-matrix.yml@main + secrets: inherit psalm: - runs-on: ubuntu-latest - strategy: - matrix: - php-version: ['8.2', '8.3'] - dependencies: ['lowest', 'highest'] - name: 'Psalm' - steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php-version }} - extensions: mbstring, intl - - name: Composer - uses: "ramsey/composer-install@v2" - with: - dependency-versions: ${{ matrix.dependencies }} - - name: Psalm - run: vendor/bin/psalm --shepherd + uses: innmind/github-workflows/.github/workflows/psalm-matrix.yml@main cs: - runs-on: ubuntu-latest - strategy: - matrix: - php-version: ['8.2'] - name: 'CS' - steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php-version }} - extensions: mbstring, intl - - name: Composer - uses: "ramsey/composer-install@v2" - - name: CS - run: vendor/bin/php-cs-fixer fix --diff --dry-run + uses: innmind/github-workflows/.github/workflows/cs.yml@main diff --git a/.github/workflows/extensive.yml b/.github/workflows/extensive.yml new file mode 100644 index 0000000..257f139 --- /dev/null +++ b/.github/workflows/extensive.yml @@ -0,0 +1,12 @@ +name: Extensive CI + +on: + push: + tags: + - '*' + paths: + - '.github/workflows/extensive.yml' + +jobs: + blackbox: + uses: innmind/github-workflows/.github/workflows/extensive.yml@main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b25ad8a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,11 @@ +name: Create release + +on: + push: + tags: + - '*' + +jobs: + release: + uses: innmind/github-workflows/.github/workflows/release.yml@main + secrets: inherit diff --git a/.gitignore b/.gitignore index 3285dca..ff72e2d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,2 @@ /composer.lock /vendor -.phpunit.result.cache -.phpunit.cache diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 1431d66..87727dc 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -1,6 +1,7 @@ listen(Name::of('a'))(0, static function($message, $continuation, $counter): void { - if ($counter === 42) { - return $continuation->stop($counter); +/** + * @psalm-immutable + * @implements Monoid + */ +final class Addition implements Monoid +{ + public function identity(): int + { + return 0; + } + + public function combine(mixed $a, mixed $b): int + { + return $a + $b; } +} - return $continuation->respond($counter + 1, $message); -})->match( - static fn($counter) => $counter, - static fn() => throw new \RuntimeException('Unable to start the server'), -); +$ipc = IPC::build(Factory::build()); +$counter = $ipc + ->serve(Name::of('a')) + ->sink(new Addition) + ->monitor(static fn(int $counter, Server\Continuation $continuation) => match ($counter) { + 42 => $continuation->finish(), + default => $continuation, + }) + ->with( + static fn(Message $message, Continuation $continuation, int $counter) => $continuation + ->respond($message) + ->carryWith($counter + 1), + ) + ->unwrap(); // $counter will always be 42 in this case ``` ```php # Process B use Innmind\IPC\{ - Factory as IPC, + IPC, + Process, Process\Name, - Message\Generic as Message, + Message, }; use Innmind\OperatingSystem\Factory; -use Innmind\Immutable\Sequence; +use Innmind\MediaType\{ + MediaType, + TopLevel, +}; +use Innmind\Immutable\{ + Str, + Sequence, +}; $ipc = IPC::build(Factory::build()); $server = Name::of('a'); -$ipc - ->wait(Name::of('a')) - ->flatMap(fn($process) => $process->send(Sequence::of( - Message::of('text/plain', 'hello world'), - ))) - ->flatMap(fn($process) => $process->wait()) +$response = $ipc + ->connectTo($server) + ->flatMap( + static fn(Process $process) => $process + ->send(Sequence::of( + Message::of( + MediaType::from(TopLevel::text, 'plain'), + Str::of('hello world'), + ), + )) + ->map(static fn() => $process), + ) + ->flatMap(fn(Process $process) => $process->wait()) ->match( - static fn($message) => print('server responded '.$message->content()->toString()), - static fn() => print('no response from the server'), + static fn(Message $message) => 'server responded '.$message->content()->toString(), + static fn() => 'no response from the server', ); +print($message); ``` The above example will result in the output `server responded hello world` in the process `B`. diff --git a/blackbox.php b/blackbox.php new file mode 100644 index 0000000..14809a3 --- /dev/null +++ b/blackbox.php @@ -0,0 +1,28 @@ +map(static fn($app) => match (\getenv('BLACKBOX_ENV')) { + 'coverage' => $app + ->codeCoverage( + CodeCoverage::of( + __DIR__.'/src/', + __DIR__.'/proofs/', + ) + ->dumpTo('coverage.clover') + ->enableWhen(true), + ) + ->scenariiPerProof(1), + 'extensive' => $app->scenariiPerProof(1_000), + default => $app, + }) + ->tryToProve(Load::everythingIn(__DIR__.'/proofs/')) + ->exit(); diff --git a/composer.json b/composer.json index d8dcde3..cfc652c 100644 --- a/composer.json +++ b/composer.json @@ -15,28 +15,17 @@ "issues": "http://github.com/Innmind/IPC/issues" }, "require": { - "php": "~8.2", - "innmind/immutable": "~4.15|~5.0", - "innmind/operating-system": "~5.0", - "innmind/url": "~4.0", - "innmind/media-type": "~2.0", - "innmind/socket": "~6.0", - "innmind/server-control": "~5.0" + "php": "~8.4", + "innmind/foundation": "~2.1" }, "autoload": { "psr-4": { "Innmind\\IPC\\": "src/" } }, - "autoload-dev": { - "psr-4": { - "Tests\\Innmind\\IPC\\": "tests/" - } - }, "require-dev": { - "phpunit/phpunit": "~10.2", - "innmind/static-analysis": "^1.2.1", - "innmind/black-box": "~5.5", + "innmind/static-analysis": "~1.3", + "innmind/black-box": "~6.5", "innmind/coding-standard": "~2.0" } } diff --git a/fixtures/client.php b/fixtures/client.php index 99016e1..8173143 100644 --- a/fixtures/client.php +++ b/fixtures/client.php @@ -1,47 +1,46 @@ wait(Name::of('server'))->match( - static fn($process) => $process, - static fn() => null, -); -$process->send(Sequence::of(new Message\Generic( - MediaType::of('text/plain'), - Str::of('hello world') -))); -$_ = $process - ->wait() +$os = OperatingSystem::new(); +$message = IPC::of( + $os, + $os->status()->tmp()->resolve(Path::of('innnmind/ipc/')), +) + ->connectTo(Process\Name::of('server')) ->flatMap( - static fn($message) => $process - ->send(Sequence::of(new Message\Generic( - MediaType::of('text/plain'), - Str::of('stop') + static fn($process) => $process + ->send(Sequence::of(Message::of( + MediaType::from(TopLevel::text, 'plain'), + Str::of('hello world'), ))) - ->map(static fn() => $message), + ->map(static fn() => $process), ) ->flatMap( - static fn($message) => $process - ->wait() // wait for server termination - ->otherwise(static fn() => Maybe::just($message)), + static fn($process) => $process + ->wait() + ->flatMap( + static fn($message) => $process + ->close() + ->map(static fn() => $message), + ), ) - ->match( - static fn($message) => print($message->content()->toString()), - static fn() => null, - ); + ->unwrap(); +echo $message->content()->toString(); diff --git a/fixtures/close-client.php b/fixtures/close-client.php new file mode 100644 index 0000000..5daddf6 --- /dev/null +++ b/fixtures/close-client.php @@ -0,0 +1,20 @@ +status()->tmp()->resolve(Path::of('innnmind/ipc/')), +) + ->connectTo(Process\Name::of('server')) + ->flatMap(static fn($process) => $process->close()) + ->unwrap(); diff --git a/fixtures/eternal-server.php b/fixtures/eternal-server.php deleted file mode 100644 index 0b1ff87..0000000 --- a/fixtures/eternal-server.php +++ /dev/null @@ -1,17 +0,0 @@ -listen(Name::of('server'))(null, static function($message, $continuation) { - return $continuation; -}); diff --git a/fixtures/long-client-multi-message.php b/fixtures/long-client-multi-message.php deleted file mode 100644 index 1f1f190..0000000 --- a/fixtures/long-client-multi-message.php +++ /dev/null @@ -1,43 +0,0 @@ -wait(Name::of('server'))->match( - static fn($process) => $process, - static fn() => null, -); -$_ = $process - ->send(Sequence::of( - new Message\Generic( - MediaType::of('text/plain'), - Str::of('hello world'), - ), - new Message\Generic( - MediaType::of('text/plain'), - Str::of('second'), - ), - new Message\Generic( - MediaType::of('text/plain'), - Str::of('third'), - ), - )) - ->flatMap(static fn($process) => $process->wait()) - ->match( - static fn() => null, - static fn() => null, - ); diff --git a/fixtures/long-client.php b/fixtures/long-client.php deleted file mode 100644 index 05daa81..0000000 --- a/fixtures/long-client.php +++ /dev/null @@ -1,33 +0,0 @@ -wait(Name::of('server'))->match( - static fn($process) => $process, - static fn() => null, -); -$_ = $process - ->send(Sequence::of(new Message\Generic( - MediaType::of('text/plain'), - Str::of('hello world') - ))) - ->flatMap(static fn($process) => $process->wait()) - ->match( - static fn() => null, - static fn() => null, - ); diff --git a/fixtures/self-closing-client.php b/fixtures/self-closing-client.php deleted file mode 100644 index ce0402e..0000000 --- a/fixtures/self-closing-client.php +++ /dev/null @@ -1,33 +0,0 @@ -wait(Name::of('server'))->match( - static fn($process) => $process, - static fn() => null, -); -$_ = $process - ->send(Sequence::of(new Message\Generic( - MediaType::of('text/plain'), - Str::of('hello world') - ))) - ->flatMap(static fn($process) => $process->close()) - ->match( - static fn() => null, - static fn() => null, - ); diff --git a/fixtures/server.php b/fixtures/server.php index 033c0cb..0c26273 100644 --- a/fixtures/server.php +++ b/fixtures/server.php @@ -1,21 +1,31 @@ listen(Name::of('server'))(null, static function($message, $continuation, $carry) { - if ($message->content()->equals(Str::of('stop'))) { - return $continuation->stop($carry); - } +use Innmind\IPC\{ + IPC, + Process, + Message, +}; +use Innmind\OperatingSystem\OperatingSystem; +use Innmind\Url\Path; +use Innmind\MediaType\{ + MediaType, + TopLevel, +}; - return $continuation->respond($carry, $message); -}); +echo 'starting'; +$os = OperatingSystem::new(); +$_ = IPC::of( + $os, + $os->status()->tmp()->resolve(Path::of('innnmind/ipc/')), +) + ->serve(Process\Name::of('server')) + ->with(static function($message, $continuation) { + return $continuation->respond(Message::of( + MediaType::from(TopLevel::text, 'plain'), + $message->content()->prepend('ack from server : '), + )); + }) + ->unwrap(); diff --git a/phpunit.xml.dist b/phpunit.xml.dist deleted file mode 100644 index 37a893d..0000000 --- a/phpunit.xml.dist +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - ./tests - - - - - . - - - ./tests - ./vendor - ./fixtures - - - diff --git a/proofs/client.php b/proofs/client.php new file mode 100644 index 0000000..442caed --- /dev/null +++ b/proofs/client.php @@ -0,0 +1,216 @@ +control() + ->processes() + ->execute( + Command::foreground('php') + ->withArgument('fixtures/server.php') + ->withEnvironment('TMPDIR', $os->status()->tmp()->toString()) + ->withEnvironment('PATH', $_SERVER['PATH']) + ->withWorkingDirectory(Path::of(__DIR__.'/../')), + ) + ->unwrap(); + // to make sure the server is started + $_ = $process + ->output() + ->take(1) + ->memoize() + ->toList(); + \sleep(1); + + yield test( + 'Client connection timeout', + static function($assert) use ($os) { + $result = IPC::of( + $os, + $os->status()->tmp()->resolve(Path::of('innnmind/ipc/')), + ) + ->connectTo( + Process\Name::of('unknown'), + Period::second(1), + ) + ->match( + static fn() => null, + static fn($e) => $e->getMessage(), + ); + + $assert->same('Timeout', $result); + }, + ); + + yield test( + 'Client wait timeout', + static function($assert) use ($os) { + $process = IPC::of( + $os, + $os->status()->tmp()->resolve(Path::of('innnmind/ipc/')), + ) + ->connectTo( + Process\Name::of('server'), + Period::second(1), + ) + ->unwrap(); + + $assert->false($process->wait(Period::millisecond(500))->match( + static fn() => true, + static fn() => false, + )); + + $assert->true($process->close()->match( + static fn() => true, + static fn() => false, + )); + }, + ); + + yield proof( + 'Client wait for message', + given( + Set::strings()->between(1, 10), + ), + static function($assert, $message) use ($os) { + $process = IPC::of( + $os, + $os->status()->tmp()->resolve(Path::of('innnmind/ipc/')), + ) + ->connectTo( + Process\Name::of('server'), + Period::second(1), + ) + ->unwrap(); + + $assert->same( + SideEffect::identity, + $process + ->send(Sequence::of(Message::of( + MediaType::from(TopLevel::text, 'plain'), + Str::of($message), + ))) + ->match( + static fn($value) => $value, + static fn() => null, + ), + ); + $assert->same( + 'ack from server : '.$message, + $process + ->wait() + ->match( + static fn($message) => $message->content()->toString(), + static fn() => null, + ), + ); + + $assert->true($process->close()->match( + static fn() => true, + static fn() => false, + )); + }, + ); + + yield proof( + 'Client listening for signals doesnt change behaviour', + given( + Set::strings()->between(1, 10), + ), + static function($assert, $message) use ($os) { + $process = IPC::of( + $os, + $os->status()->tmp()->resolve(Path::of('innnmind/ipc/')), + ) + ->connectTo( + Process\Name::of('server'), + Period::second(1), + ) + ->unwrap(); + + $assert->same( + SideEffect::identity, + $process->listenSignals()->match( + static fn($sideEffect) => $sideEffect, + static fn() => null, + ), + ); + $assert->same( + SideEffect::identity, + $process + ->send(Sequence::of(Message::of( + MediaType::from(TopLevel::text, 'plain'), + Str::of($message), + ))) + ->match( + static fn($value) => $value, + static fn() => null, + ), + ); + $assert->same( + 'ack from server : '.$message, + $process + ->wait() + ->match( + static fn($message) => $message->content()->toString(), + static fn() => null, + ), + ); + + $assert->true($process->close()->match( + static fn() => true, + static fn() => false, + )); + }, + ); + + yield test( + 'Processes', + static function($assert) use ($os) { + $processes = IPC::of( + $os, + $os->status()->tmp()->resolve(Path::of('innnmind/ipc/')), + ) + ->processes() + ->map(static fn($name) => $name->toString()) + ->toList(); + + $assert->same( + ['server'], + $processes, + ); + }, + ); + + $_ = $process->pid()->match( + static fn($pid) => $os + ->control() + ->processes() + ->kill($pid, Signal::kill) + ->unwrap(), + static fn() => null, + ); +}; diff --git a/proofs/message.php b/proofs/message.php new file mode 100644 index 0000000..9ea6829 --- /dev/null +++ b/proofs/message.php @@ -0,0 +1,56 @@ +map(Str::of(...)), + ), + static function($assert, $mediaTypeA, $mediaTypeB, $content) { + $assert->true( + Message::of($mediaTypeA, $content)->equals( + Message::of($mediaTypeA, $content), + ), + ); + $assert->false( + Message::of($mediaTypeA, $content)->equals( + Message::of($mediaTypeB, $content), + ), + ); + }, + ); + + yield proof( + 'Message::equals()', + given( + MediaType::any(), + Set::strings()->map(Str::of(...)), + Set::strings()->map(Str::of(...)), + )->filter(static fn($_, $a, $b) => !$a->equals($b)), + static function($assert, $mediaType, $a, $b) { + $assert->true( + Message::of($mediaType, $a)->equals( + Message::of($mediaType, $a), + ), + ); + $assert->true( + Message::of($mediaType, $b)->equals( + Message::of($mediaType, $b), + ), + ); + $assert->false( + Message::of($mediaType, $a)->equals( + Message::of($mediaType, $b), + ), + ); + }, + ); +}; diff --git a/proofs/server.php b/proofs/server.php new file mode 100644 index 0000000..f5b59a8 --- /dev/null +++ b/proofs/server.php @@ -0,0 +1,169 @@ +control() + ->processes() + ->execute( + Command::foreground('sleep 2 && php fixtures/client.php') + ->withArgument('fixtures/client.php') + ->withEnvironment('TMPDIR', $os->status()->tmp()->toString()) + ->withEnvironment('PATH', $_SERVER['PATH']) + ->withWorkingDirectory(Path::of(__DIR__.'/../')), + ) + ->unwrap(); + + $result = IPC::of( + $os, + $os->status()->tmp()->resolve(Path::of('innnmind/ipc/')), + ) + ->serve(Process\Name::of('server')) + ->monitor( + static fn($result, $continuation) => $continuation->finish(), + ) + ->with( + static fn($message, $continuation) => $continuation + ->respond(Message::of( + MediaType::from(TopLevel::text, 'plain'), + Str::of('some output'), + )) + ->finish(), + ) + ->match( + static fn($result) => $result, + static fn() => null, + ); + + $assert->same(SideEffect::identity, $result); + $assert->same( + 'some output', + $process + ->output() + ->map(static fn($chunk) => $chunk->data()) + ->fold(Concat::monoid) + ->toString(), + ); + }, + ); + + yield test( + 'Server properly handle a client that close the connection without sending messages', + static function($assert) use ($os) { + $process = $os + ->control() + ->processes() + ->execute( + Command::foreground('sleep 2 && php fixtures/close-client.php') + ->withArgument('fixtures/client.php') + ->withEnvironment('TMPDIR', $os->status()->tmp()->toString()) + ->withEnvironment('PATH', $_SERVER['PATH']) + ->withWorkingDirectory(Path::of(__DIR__.'/../')), + ) + ->unwrap(); + + $result = IPC::of( + $os, + $os->status()->tmp()->resolve(Path::of('innnmind/ipc/')), + ) + ->serve(Process\Name::of('server')) + ->monitor( + static fn($result, $continuation) => $continuation->finish(), + ) + ->with(static fn($message, $continuation) => $continuation) + ->match( + static fn($result) => $result, + static fn() => null, + ); + + $assert->same(SideEffect::identity, $result); + $assert->same( + '', + $process + ->output() + ->map(static fn($chunk) => $chunk->data()) + ->fold(Concat::monoid) + ->toString(), + ); + }, + ); + + yield proof( + 'Server wait for client', + given( + Set::strings()->between(1, 10), + ), + static function($assert, $response) use ($os) { + $process = $os + ->control() + ->processes() + ->execute( + Command::foreground('sleep 2 && php fixtures/client.php') + ->withArgument('fixtures/client.php') + ->withEnvironment('TMPDIR', $os->status()->tmp()->toString()) + ->withEnvironment('PATH', $_SERVER['PATH']) + ->withWorkingDirectory(Path::of(__DIR__.'/../')), + ) + ->unwrap(); + + $output = IPC::of( + $os, + $os->status()->tmp()->resolve(Path::of('innnmind/ipc/')), + ) + ->serve(Process\Name::of('server')) + ->sink(Concat::monoid) + ->monitor( + static fn($result, $continuation) => $continuation + ->carryWith($result) + ->finish(), + ) + ->with( + static fn($message, $continuation) => $continuation + ->carryWith($message->content()) + ->respond(Message::of( + MediaType::from(TopLevel::text, 'plain'), + Str::of($response), + )) + ->finish(), + ) + ->match( + static fn($output) => $output->toString(), + static fn() => null, + ); + + $assert->same('hello world', $output); + $assert->same( + $response, + $process + ->output() + ->map(static fn($chunk) => $chunk->data()) + ->fold(Concat::monoid) + ->toString(), + ); + }, + ); +}; diff --git a/psalm.xml b/psalm.xml index 510148d..151f7b6 100644 --- a/psalm.xml +++ b/psalm.xml @@ -10,8 +10,12 @@ > + + + + diff --git a/src/Abort.php b/src/Abort.php new file mode 100644 index 0000000..cfea633 --- /dev/null +++ b/src/Abort.php @@ -0,0 +1,38 @@ +value = true; + } + + /** + * @internal + */ + public static function disabled(): self + { + return new self; + } + + public function enabled(): bool + { + return $this->value; + } +} diff --git a/src/Client.php b/src/Client.php deleted file mode 100644 index 6552116..0000000 --- a/src/Client.php +++ /dev/null @@ -1,29 +0,0 @@ - Returns nothing when it fails to send the message - */ - public function send(Message $message): Maybe; - - /** - * @return Maybe - */ - public function read(): Maybe; - - /** - * Close the underlying connection - * - * @return Maybe Returns nothing when it fails to close properly - */ - public function close(): Maybe; -} diff --git a/src/Client/Unix.php b/src/Client/Unix.php deleted file mode 100644 index 988b3bb..0000000 --- a/src/Client/Unix.php +++ /dev/null @@ -1,56 +0,0 @@ -connection = $connection; - $this->protocol = $protocol; - } - - #[\Override] - public function send(Message $message): Maybe - { - if ($this->connection->closed()) { - /** @var Maybe */ - return Maybe::nothing(); - } - - /** @var Maybe */ - return $this - ->connection - ->write($this->protocol->encode($message)) - ->maybe() - ->map(fn() => $this); - } - - #[\Override] - public function read(): Maybe - { - /** @var Maybe */ - return $this - ->protocol - ->decode($this->connection) - ->map(fn($message) => [$this, $message]); - } - - #[\Override] - public function close(): Maybe - { - return $this->connection->close()->maybe(); - } -} diff --git a/src/Continuation.php b/src/Continuation.php index 7ac3e6a..06631f3 100644 --- a/src/Continuation.php +++ b/src/Continuation.php @@ -3,47 +3,42 @@ namespace Innmind\IPC; +use Innmind\IPC\Continuation\Next; +use Innmind\Immutable\Sequence; + /** - * @template T * @psalm-immutable + * @template T */ final class Continuation { - private Client $client; - /** @var T */ - private mixed $carry; - private bool $closed; - private ?Message $response; - private bool $stop; - /** + * @param Sequence $messages * @param T $carry */ private function __construct( - Client $client, - mixed $carry, - bool $closed = false, - ?Message $response = null, - bool $stop = false, + private Next $next, + private Sequence $messages, + private mixed $carry, ) { - $this->client = $client; - $this->carry = $carry; - $this->closed = $closed; - $this->response = $response; - $this->stop = $stop; } /** * @internal + * @psalm-pure * @template A * * @param A $carry * * @return self */ - public static function start(Client $client, mixed $carry): self + public static function new(mixed $carry): self { - return new self($client, $carry); + return new self( + Next::continue, + Sequence::of(), + $carry, + ); } /** @@ -51,83 +46,62 @@ public static function start(Client $client, mixed $carry): self * * @return self */ - public function continue(mixed $carry): self + public function carryWith(mixed $carry): self { - return new self($this->client, $carry); + return new self( + $this->next, + $this->messages, + $carry, + ); } /** - * This will send the given message to the client - * - * @param T $carry + * @param Message|Sequence $messages * * @return self */ - public function respond(mixed $carry, Message $message): self + public function respond(Message|Sequence $messages): self { - return new self($this->client, $carry, response: $message); - } + if ($messages instanceof Message) { + $messages = Sequence::of($messages); + } - /** - * The client will be closed and then garbage collected - * - * @param T $carry - * - * @return self - */ - public function close(mixed $carry): self - { - return new self($this->client, $carry, closed: true); + return new self( + $this->next, + $messages->prepend($this->messages), // to keep the user provided lazyness + $this->carry, + ); } /** - * The server will be gracefully shutdown - * - * @param T $carry - * * @return self */ - public function stop(mixed $carry): self + public function finish(): self { - return new self($this->client, $carry, stop: true); + return new self( + Next::finish, + $this->messages, + $this->carry, + ); } /** * @internal - * @template A - * @template B - * @template C - * @template D + * @template R * - * @param callable(Client, Message, T): A $onResponse - * @param callable(Client, T): B $onClose - * @param callable(Client, T): C $onStop - * @param callable(Client, T): D $onContinue + * @param callable(T, Sequence): R $continue + * @param callable(T, Sequence): R $finish * - * @return A|B|C|D + * @return R */ public function match( - callable $onResponse, - callable $onClose, - callable $onStop, - callable $onContinue, + callable $continue, + callable $finish, ): mixed { - if ($this->response instanceof Message) { - /** @psalm-suppress ImpureFunctionCall */ - return $onResponse($this->client, $this->response, $this->carry); - } - - if ($this->closed) { - /** @psalm-suppress ImpureFunctionCall */ - return $onClose($this->client, $this->carry); - } - - if ($this->stop) { - /** @psalm-suppress ImpureFunctionCall */ - return $onStop($this->client, $this->carry); - } - /** @psalm-suppress ImpureFunctionCall */ - return $onContinue($this->client, $this->carry); + return match ($this->next) { + Next::continue => $continue($this->carry, $this->messages), + Next::finish => $finish($this->carry, $this->messages), + }; } } diff --git a/src/Continuation/Next.php b/src/Continuation/Next.php new file mode 100644 index 0000000..4d3a508 --- /dev/null +++ b/src/Continuation/Next.php @@ -0,0 +1,14 @@ + + */ + public function with(mixed $value): self + { + return new self($value); + } + + /** + * @return T + */ + public function unwrap(): mixed + { + return $this->value; + } +} diff --git a/src/Exception/DomainException.php b/src/Exception/DomainException.php deleted file mode 100644 index ec9c06e..0000000 --- a/src/Exception/DomainException.php +++ /dev/null @@ -1,8 +0,0 @@ -status()->tmp()->resolve(Path::of('innmind/ipc/')); - $heartbeat ??= new ElapsedPeriod(1000); // default to 1 second - - return new IPC\Unix( - $os->sockets(), - $os->filesystem()->mount($sockets), - $os->clock(), - $os->process(), - new Protocol\Binary, - $sockets, - $heartbeat, - ); - } -} diff --git a/src/IPC.php b/src/IPC.php index 0cc0ef8..9a4d5bf 100644 --- a/src/IPC.php +++ b/src/IPC.php @@ -3,28 +3,146 @@ namespace Innmind\IPC; -use Innmind\TimeContinuum\ElapsedPeriod; +use Innmind\OperatingSystem\OperatingSystem; +use Innmind\Filesystem\{ + Adapter, + Name as FileName, + Recover, +}; +use Innmind\IO\Sockets\Unix\Address; +use Innmind\Time\Period; +use Innmind\Url\Path; use Innmind\Immutable\{ - Set, - Maybe, + Attempt, + Sequence, + SideEffect, }; -interface IPC +final class IPC { + private function __construct( + private OperatingSystem $os, + private Adapter $filesystem, + private Protocol $protocol, + private Path $path, + private Period $heartbeat, + ) { + } + + public static function of( + OperatingSystem $os, + ?Path $path = null, + ?Period $heartbeat = null, + ): self { + $path ??= $os->status()->tmp(); + + return new self( + $os, + $os + ->filesystem() + ->mount($path) + ->recover(Recover::mount(...)) + ->unwrap(), + Protocol::binary(), + $path, + $heartbeat ?? Period::second(1), + ); + } + + /** + * @return Sequence + */ + public function processes(): Sequence + { + return $this + ->filesystem + ->root() + ->all() + ->map( + static fn($file) => $file + ->name() + ->str() + ->dropEnd(5) + ->toString(), + ) + ->flatMap( + static fn($file) => Process\Name::attempt($file) + ->maybe() + ->toSequence(), + ); + } + /** - * @return Set All processes waiting for messages + * @return Attempt */ - public function processes(): Set; + public function connectTo( + Process\Name $name, + ?Period $timeout = null, + ): Attempt { + $file = FileName::of($name->toString().'.sock'); + $name = $this->addressOf($name); + $start = $this->os->clock()->now(); + $timeout = $timeout?->asElapsedPeriod(); + + while (!$this->filesystem->contains($file)) { + $halted = $this + ->os + ->process() + ->halt($this->heartbeat) + ->match( + static fn($sideEffect) => $sideEffect, + static fn($e) => $e, + ); + + if ($halted !== SideEffect::identity) { + return Attempt::error($halted); + } + + if (\is_null($timeout)) { + continue; + } + + $elapsed = $this + ->os + ->clock() + ->now() + ->elapsedSince($start); + + if ($elapsed->longerThan($timeout)) { + return Attempt::error(new \RuntimeException('Timeout')); + } + } + + return Process::of( + $this->os, + $this->protocol, + $name, + $this->heartbeat, + ); + } /** - * @return Maybe + * @psalm-mutation-free + * + * @return Server */ - public function get(Process\Name $name): Maybe; - public function exist(Process\Name $name): bool; + public function serve(Process\Name $name): Server + { + return Server::of( + $this->os, + $this->protocol, + $this->addressOf($name), + $this->heartbeat, + ); + } /** - * @return Maybe + * @psalm-mutation-free */ - public function wait(Process\Name $name, ?ElapsedPeriod $timeout = null): Maybe; - public function listen(Process\Name $self, ?ElapsedPeriod $timeout = null): Server; + private function addressOf(Process\Name $name): Address + { + return Address::of( + $this->path->resolve(Path::of($name->toString())), + ); + } } diff --git a/src/IPC/Unix.php b/src/IPC/Unix.php deleted file mode 100644 index 93bc6b9..0000000 --- a/src/IPC/Unix.php +++ /dev/null @@ -1,145 +0,0 @@ -directory()) { - throw new LogicException("Path must be a directory, got '{$path->toString()}'"); - } - - $this->sockets = $sockets; - $this->filesystem = $filesystem; - $this->clock = $clock; - $this->process = $process; - $this->protocol = $protocol; - $this->path = $path; - $this->heartbeat = $heartbeat; - } - - #[\Override] - public function processes(): Set - { - return $this - ->filesystem - ->root() - ->all() - ->map(static fn($file) => Process\Name::maybe($file->name()->toString())->match( - static fn($name) => $name, - static fn() => null, - )) - ->keep(Instance::of(Process\Name::class)) - ->toSet(); - } - - #[\Override] - public function get(Process\Name $name): Maybe - { - if (!$this->exist($name)) { - /** @var Maybe */ - return Maybe::nothing(); - } - - return Process\Unix::of( - $this->sockets, - $this->protocol, - $this->clock, - $this->addressOf($name->toString()), - $name, - $this->heartbeat, - ); - } - - #[\Override] - public function exist(Process\Name $name): bool - { - return $this->filesystem->contains(FileName::of("{$name->toString()}.sock")); - } - - #[\Override] - public function wait(Process\Name $name, ?ElapsedPeriod $timeout = null): Maybe - { - $start = $this->clock->now(); - - while (!$this->exist($name)) { - if ( - $timeout instanceof ElapsedPeriod && - $this->clock->now()->elapsedSince($start)->longerThan($timeout) - ) { - /** @var Maybe */ - return Maybe::nothing(); - } - - $this->process->halt(new Millisecond($this->heartbeat->milliseconds())); - } - - return $this->get($name); - } - - #[\Override] - public function listen(Process\Name $self, ?ElapsedPeriod $timeout = null): Server - { - return new Server\Unix( - $this->sockets, - $this->protocol, - $this->clock, - $this->process->signals(), - $this->addressOf($self->toString()), - $this->heartbeat, - $timeout, - ); - } - - private function addressOf(string $name): Address - { - return new Address( - $this->path->resolve(Path::of($name)), - ); - } -} diff --git a/src/Message.php b/src/Message.php index 7de043a..76ca9ae 100644 --- a/src/Message.php +++ b/src/Message.php @@ -3,15 +3,116 @@ namespace Innmind\IPC; +use Innmind\IPC\Message\{ + Implementation, + Protocol, + Generic, +}; use Innmind\MediaType\MediaType; use Innmind\Immutable\Str; /** * @psalm-immutable */ -interface Message +final class Message { - public function mediaType(): MediaType; - public function content(): Str; - public function equals(self $message): bool; + private function __construct( + private Implementation $implementation, + ) { + } + + /** + * @psalm-pure + */ + public static function of(MediaType $mediaType, Str $content): self + { + return new self(new Generic($mediaType, $content)); + } + + /** + * @internal + * @psalm-pure + */ + public static function connectionStart(): self + { + return new self(Protocol::connectionStart); + } + + /** + * @internal + * @psalm-pure + */ + public static function connectionStartOk(): self + { + return new self(Protocol::connectionStartOk); + } + + /** + * @internal + * @psalm-pure + */ + public static function connectionClose(): self + { + return new self(Protocol::connectionClose); + } + + /** + * @internal + * @psalm-pure + */ + public static function connectionCloseOk(): self + { + return new self(Protocol::connectionCloseOk); + } + + /** + * @internal + * @psalm-pure + */ + public static function heartbeat(): self + { + return new self(Protocol::heartbeat); + } + + /** + * @internal + * @psalm-pure + */ + public static function ack(): self + { + return new self(Protocol::ack); + } + + public function mediaType(): MediaType + { + return $this->implementation->mediaType(); + } + + public function content(): Str + { + return $this->implementation->content(); + } + + public function equals(self $message): bool + { + $self = $this->implementation; + $other = $message->implementation; + + if ($self instanceof Protocol && $other instanceof Protocol) { + return $self === $other; + } + + if ($other instanceof Protocol) { + // This is to avoid reading the message content when checking if the + // message is a protocol one. If it's a protocol one $self must be + // correctly decoded as Message\Protocol by the protocol decoder. + return false; + } + + if ($self->mediaType()->toString() !== $other->mediaType()->toString()) { + return false; + } + + return $self->content()->equals($other->content()); + } } diff --git a/src/Message/ConnectionClose.php b/src/Message/ConnectionClose.php deleted file mode 100644 index 9dfd104..0000000 --- a/src/Message/ConnectionClose.php +++ /dev/null @@ -1,42 +0,0 @@ -mediaType = new MediaType('text', 'plain'); - $this->content = Str::of('innmind/ipc:connection.close'); - } - - #[\Override] - public function mediaType(): MediaType - { - return $this->mediaType; - } - - #[\Override] - public function content(): Str - { - return $this->content; - } - - #[\Override] - public function equals(Message $message): bool - { - return $this->mediaType->toString() === $message->mediaType()->toString() && - $this->content->toString() === $message->content()->toString(); - } -} diff --git a/src/Message/ConnectionCloseOk.php b/src/Message/ConnectionCloseOk.php deleted file mode 100644 index 60fc566..0000000 --- a/src/Message/ConnectionCloseOk.php +++ /dev/null @@ -1,42 +0,0 @@ -mediaType = new MediaType('text', 'plain'); - $this->content = Str::of('innmind/ipc:connection.close-ok'); - } - - #[\Override] - public function mediaType(): MediaType - { - return $this->mediaType; - } - - #[\Override] - public function content(): Str - { - return $this->content; - } - - #[\Override] - public function equals(Message $message): bool - { - return $this->mediaType->toString() === $message->mediaType()->toString() && - $this->content->toString() === $message->content()->toString(); - } -} diff --git a/src/Message/ConnectionStart.php b/src/Message/ConnectionStart.php deleted file mode 100644 index 03d12a0..0000000 --- a/src/Message/ConnectionStart.php +++ /dev/null @@ -1,42 +0,0 @@ -mediaType = new MediaType('text', 'plain'); - $this->content = Str::of('innmind/ipc:connection.start'); - } - - #[\Override] - public function mediaType(): MediaType - { - return $this->mediaType; - } - - #[\Override] - public function content(): Str - { - return $this->content; - } - - #[\Override] - public function equals(Message $message): bool - { - return $this->mediaType->toString() === $message->mediaType()->toString() && - $this->content->toString() === $message->content()->toString(); - } -} diff --git a/src/Message/ConnectionStartOk.php b/src/Message/ConnectionStartOk.php deleted file mode 100644 index e709a88..0000000 --- a/src/Message/ConnectionStartOk.php +++ /dev/null @@ -1,42 +0,0 @@ -mediaType = new MediaType('text', 'plain'); - $this->content = Str::of('innmind/ipc:connection.start-ok'); - } - - #[\Override] - public function mediaType(): MediaType - { - return $this->mediaType; - } - - #[\Override] - public function content(): Str - { - return $this->content; - } - - #[\Override] - public function equals(Message $message): bool - { - return $this->mediaType->toString() === $message->mediaType()->toString() && - $this->content->toString() === $message->content()->toString(); - } -} diff --git a/src/Message/Generic.php b/src/Message/Generic.php index 7063007..34b4461 100644 --- a/src/Message/Generic.php +++ b/src/Message/Generic.php @@ -3,30 +3,19 @@ namespace Innmind\IPC\Message; -use Innmind\IPC\Message; use Innmind\MediaType\MediaType; use Innmind\Immutable\Str; /** + * @internal * @psalm-immutable */ -final class Generic implements Message +final class Generic implements Implementation { - private MediaType $mediaType; - private Str $content; - - public function __construct(MediaType $mediaType, Str $content) - { - $this->mediaType = $mediaType; - $this->content = $content; - } - - public static function of(string $mediaType, string $content): self - { - return new self( - MediaType::of($mediaType), - Str::of($content), - ); + public function __construct( + private MediaType $mediaType, + private Str $content, + ) { } #[\Override] @@ -40,11 +29,4 @@ public function content(): Str { return $this->content; } - - #[\Override] - public function equals(Message $message): bool - { - return $this->mediaType->toString() === $message->mediaType()->toString() && - $this->content->toString() === $message->content()->toString(); - } } diff --git a/src/Message/Heartbeat.php b/src/Message/Heartbeat.php deleted file mode 100644 index bec5435..0000000 --- a/src/Message/Heartbeat.php +++ /dev/null @@ -1,42 +0,0 @@ -mediaType = new MediaType('text', 'plain'); - $this->content = Str::of('innmind/ipc:heartbeat'); - } - - #[\Override] - public function mediaType(): MediaType - { - return $this->mediaType; - } - - #[\Override] - public function content(): Str - { - return $this->content; - } - - #[\Override] - public function equals(Message $message): bool - { - return $this->mediaType->toString() === $message->mediaType()->toString() && - $this->content->toString() === $message->content()->toString(); - } -} diff --git a/src/Message/Implementation.php b/src/Message/Implementation.php new file mode 100644 index 0000000..07dd4d3 --- /dev/null +++ b/src/Message/Implementation.php @@ -0,0 +1,17 @@ +mediaType = new MediaType('text', 'plain'); - $this->content = Str::of('innmind/ipc:message.received'); - } - - #[\Override] - public function mediaType(): MediaType - { - return $this->mediaType; - } - - #[\Override] - public function content(): Str - { - return $this->content; - } - - #[\Override] - public function equals(Message $message): bool - { - return $this->mediaType->toString() === $message->mediaType()->toString() && - $this->content->toString() === $message->content()->toString(); - } -} diff --git a/src/Message/Protocol.php b/src/Message/Protocol.php new file mode 100644 index 0000000..d521e4a --- /dev/null +++ b/src/Message/Protocol.php @@ -0,0 +1,73 @@ +content()->equals($content)) { + return $case; + } + } + + return null; + } + + #[\Override] + public function mediaType(): MediaType + { + return MediaType::from( + TopLevel::text, + 'plain', + ); + } + + #[\Override] + public function content(): Str + { + return Str::of(match ($this) { + self::connectionStart => 'innmind/ipc:connection.start', + self::connectionStartOk => 'innmind/ipc:connection.start-ok', + self::connectionClose => 'innmind/ipc:connection.close', + self::connectionCloseOk => 'innmind/ipc:connection.close-ok', + self::heartbeat => 'innmind/ipc:heartbeat', + self::ack => 'innmind/ipc:ack', + }); + } + + public function message(): Message + { + return match ($this) { + self::connectionStart => Message::connectionStart(), + self::connectionStartOk => Message::connectionStartOk(), + self::connectionClose => Message::connectionClose(), + self::connectionCloseOk => Message::connectionCloseOk(), + self::heartbeat => Message::heartbeat(), + self::ack => Message::ack(), + }; + } +} diff --git a/src/Monoid.php b/src/Monoid.php new file mode 100644 index 0000000..7ee8c43 --- /dev/null +++ b/src/Monoid.php @@ -0,0 +1,31 @@ + + */ +enum Monoid implements Monoid_ +{ + case sideEffect; + + #[\Override] + public function identity(): SideEffect + { + return SideEffect::identity; + } + + #[\Override] + public function combine(mixed $a, mixed $b): SideEffect + { + return SideEffect::identity; + } +} diff --git a/src/Pipe.php b/src/Pipe.php new file mode 100644 index 0000000..a922dff --- /dev/null +++ b/src/Pipe.php @@ -0,0 +1,140 @@ + + */ + public function wait(?Period $timeout = null): Attempt + { + $abort = $this->abort; + $start = $this->clock->now(); + // It's safe to unwrap as it's an internal message that never fails + $heartbeat = $this + ->protocol + ->encode(Message::heartbeat()) + ->unwrap(); + + do { + $result = $this + ->socket + ->toEncoding(Str\Encoding::ascii) + ->heartbeatWith(static fn() => Sequence::of($heartbeat)) + ->abortWhen(function() use ($abort, $start, $timeout) { + if ($abort->enabled()) { + return true; + } + + if (\is_null($timeout)) { + return false; + } + + return $this + ->clock + ->now() + ->elapsedSince($start) + ->longerThan($timeout->asElapsedPeriod()); + }) + ->frames($this->protocol->frame()) + ->one() + ->match( + static fn($message) => $message, + static fn($e) => $e, + ); + + if ($result instanceof \Throwable) { + return Attempt::error($result); + } + } while ($result->equals(Message::heartbeat())); + + if ($result->equals(Message::connectionClose())) { + return $this + ->protocol + ->encode(Message::connectionCloseOk()) + ->map(Sequence::of(...)) + ->flatMap($this->socket->sink(...)) + ->flatMap(fn() => $this->socket->close()) + ->flatMap(static fn() => Attempt::error(new ConnectionProperlyClosed)); + } + + return Attempt::result($result); + } + + /** + * @param Sequence $messages + * + * @return Attempt + */ + public function send(Sequence $messages): Attempt + { + $socket = $this->socket->abortWhen($this->abort->enabled(...)); + + return $messages + ->sink(SideEffect::identity) + ->attempt( + fn($_, $message) => $this + ->protocol + ->encode($message) + ->map(Sequence::of(...)) + ->flatMap($socket->sink(...)) + ->flatMap(fn() => $this->wait()) + ->flatMap(static fn($message) => match ($message->equals(Message::ack())) { + true => Attempt::result(SideEffect::identity), + false => Attempt::error(new \RuntimeException('Was expecting a message acknowledgement')), + }), + ); + } + + /** + * @return Attempt + */ + public function signal(Message $message): Attempt + { + $socket = $this->socket->abortWhen($this->abort->enabled(...)); + + return $this + ->protocol + ->encode($message) + ->map(Sequence::of(...)) + ->flatMap($socket->sink(...)); + } +} diff --git a/src/Process.php b/src/Process.php index e1b0d11..d2e8c70 100644 --- a/src/Process.php +++ b/src/Process.php @@ -3,32 +3,140 @@ namespace Innmind\IPC; -use Innmind\TimeContinuum\ElapsedPeriod; +use Innmind\OperatingSystem\OperatingSystem; +use Innmind\IO\Sockets\{ + Clients\Client, + Unix\Address, +}; +use Innmind\Signals\Signal; +use Innmind\Time\Period; use Innmind\Immutable\{ - Maybe, - SideEffect, Sequence, + Attempt, + SideEffect, }; -interface Process +final class Process { - public function name(): Process\Name; + private function __construct( + private OperatingSystem $os, + private Client $socket, + private Pipe $pipe, + private Abort $abort, + ) { + } + + /** + * @internal + * + * @return Attempt + */ + public static function of( + OperatingSystem $os, + Protocol $protocol, + Address $address, + Period $timeout, + ): Attempt { + $abort = Abort::disabled(); + + return $os + ->sockets() + ->connectTo($address) + ->map(static fn($client) => $client->timeoutAfter($timeout)) + ->map(static fn($client) => new self( + $os, + $client, + Pipe::of( + $client, + $protocol, + $os->clock(), + $abort, + ), + $abort, + )) + ->flatMap( + static fn($self) => $self + ->wait() + ->flatMap(static fn($message) => match ($message->equals(Message::connectionStart())) { + true => Attempt::result($self), + false => Attempt::error(new \RuntimeException('Connection handshake failure')), + }), + ) + ->flatMap( + static fn($self) => $self + ->pipe + ->signal(Message::connectionStartOk()) + ->map(static fn() => $self), + ); + } /** * @param Sequence $messages * - * @return Maybe Returns nothing when messages can't be sent + * @return Attempt + */ + #[\NoDiscard] + public function send(Sequence $messages): Attempt + { + return $this->pipe->send($messages); + } + + /** + * @return Attempt */ - public function send(Sequence $messages): Maybe; + #[\NoDiscard] + public function wait(?Period $timeout = null): Attempt + { + return $this->pipe->wait($timeout); + } /** - * @return Maybe + * @return Attempt */ - public function wait(?ElapsedPeriod $timeout = null): Maybe; + #[\NoDiscard] + public function listenSignals(): Attempt + { + return $this + ->os + ->process() + ->signals() + ->listen(Signal::terminate, $this->abort); + } /** - * @return Maybe Returns nothing when couldn't close the connection properly + * @return Attempt */ - public function close(): Maybe; - public function closed(): bool; + #[\NoDiscard] + public function close(): Attempt + { + return $this + ->pipe + ->signal(Message::connectionClose()) + ->flatMap(fn() => $this->pipe->wait()) + ->flatMap(static fn($message) => match ($message->equals(Message::connectionCloseOk())) { + true => Attempt::result(SideEffect::identity), + false => Attempt::error(new \RuntimeException('Connection handshake failure')), + }) + ->eitherWay( + fn() => $this->socket->close(), + fn($e) => $this + ->socket + ->close() + ->flatMap(static fn() => Attempt::error($e)), + ) + ->eitherWay( + fn($value) => $this + ->os + ->process() + ->signals() + ->remove($this->abort) + ->map(static fn() => $value), + fn($e) => $this + ->os + ->process() + ->signals() + ->remove($this->abort) + ->flatMap(static fn() => Attempt::error($e)), + ); + } } diff --git a/src/Process/Name.php b/src/Process/Name.php index e1ba950..6c6f020 100644 --- a/src/Process/Name.php +++ b/src/Process/Name.php @@ -3,45 +3,53 @@ namespace Innmind\IPC\Process; -use Innmind\IPC\Exception\DomainException; use Innmind\Immutable\{ Str, - Maybe, + Attempt, }; +/** + * @psalm-immutable + */ final class Name { - private string $value; - - private function __construct(string $value) + /** + * @param non-empty-string $value + */ + private function __construct(private string $value) { - $this->value = $value; } /** + * @psalm-pure + * * @param literal-string $value * - * @throws DomainException + * @throws \DomainException */ public static function of(string $value): self { - return self::maybe($value)->match( - static fn($self) => $self, - static fn() => throw new DomainException($value), - ); + return self::attempt($value)->unwrap(); } /** - * @return Maybe + * @psalm-pure + * + * @return Attempt */ - public static function maybe(string $value): Maybe + public static function attempt(string $value): Attempt { - return Maybe::just($value) - ->map(Str::of(...)) - ->filter(static fn($value) => $value->matches('~^[a-zA-Z0-9-_]+$~')) - ->map(static fn($value) => new self($value->toString())); + if (!Str::of($value)->matches('~^[a-zA-Z0-9-_]+$~')) { + return Attempt::error(new \DomainException($value)); + } + + /** @psalm-suppress ArgumentTypeCoercion */ + return Attempt::result(new self($value)); } + /** + * @return non-empty-string + */ public function toString(): string { return $this->value; diff --git a/src/Process/Unix.php b/src/Process/Unix.php deleted file mode 100644 index b6014ad..0000000 --- a/src/Process/Unix.php +++ /dev/null @@ -1,264 +0,0 @@ -socket = $socket; - $this->watch = $watch; - $this->protocol = $protocol; - $this->clock = $clock; - $this->name = $name; - $this->lastReceivedData = $clock->now(); - } - - /** - * @return Maybe - */ - public static function of( - Sockets $sockets, - Protocol $protocol, - Clock $clock, - Address $address, - Name $name, - ElapsedPeriod $watchTimeout, - ): Maybe { - /** - * @psalm-suppress PossiblyInvalidArgument - * @var Maybe - */ - return $sockets - ->connectTo($address) - ->map(static fn($socket) => new self( - $socket->unwrap(), - $sockets->watch($watchTimeout)->forRead($socket->unwrap()), - $protocol, - $clock, - $name, - )) - ->flatMap(static fn($self) => $self->open()); - } - - #[\Override] - public function name(): Name - { - return $this->name; - } - - #[\Override] - public function send(Sequence $messages): Maybe - { - /** @var Maybe */ - return $messages->reduce( - Maybe::just($this), - self::maybeSendMessage(...), - ); - } - - #[\Override] - public function wait(?ElapsedPeriod $timeout = null): Maybe - { - do { - if ($this->closed()) { - /** @var Maybe */ - return $this - ->cut() - ->filter(static fn() => false); // never return anything - } - - /** @var Set */ - $toRead = ($this->watch)()->match( - static fn($ready) => $ready->toRead(), - static fn() => Set::of(), - ); - - $receivedData = $toRead->contains($this->socket); - - if (!$receivedData) { - $stop = $this - ->sendMessage(new Heartbeat) - ->filter(static fn($self) => !$self->timedout($timeout)) - ->match( - static fn() => false, - static fn() => true, - ); - - if ($stop) { - /** @var Maybe */ - return Maybe::nothing(); - } - } - } while (!$receivedData); - - $this->lastReceivedData = $this->clock->now(); - - return $this - ->protocol - ->decode($this->socket) - ->flatMap(function($message) use ($timeout) { - if ($message->equals(new Heartbeat)) { - return $this->wait($timeout); - } - - return Maybe::just($message); - }) - ->flatMap(function($message) { - if ($message->equals(new ConnectionClose)) { - /** @var Maybe */ - return $this - ->sendMessage(new ConnectionCloseOk) - ->flatMap(static fn($self) => $self->cut()) - ->filter(static fn() => false); // never return anything - } - - return Maybe::just($message); - }); - } - - #[\Override] - public function close(): Maybe - { - if ($this->closed()) { - return Maybe::just(new SideEffect); - } - - return $this - ->sendMessage(new ConnectionClose) - ->flatMap( - static fn($self) => $self - ->wait() - ->filter(static fn($message) => $message->equals(new ConnectionCloseOk)) - ->map(static fn() => $self), - ) - ->flatMap(static fn($self) => $self->cut()) - ->otherwise( - fn() => $this - ->cut() - ->filter(static fn() => false), // never return anything - ); - } - - #[\Override] - public function closed(): bool - { - return $this->closed || $this->socket->closed(); - } - - /** - * @return Maybe - */ - private function open(): Maybe - { - /** @var Maybe */ - return $this - ->wait() - ->filter(static fn($message) => $message->equals(new ConnectionStart)) - ->flatMap(fn() => $this->sendMessage(new ConnectionStartOk)); - } - - /** - * @return Maybe - */ - private function cut(): Maybe - { - $this->closed = true; - - return $this->socket->close()->maybe(); - } - - private function timedout(?ElapsedPeriod $timeout = null): bool - { - if ($timeout === null) { - return false; - } - - $iteration = $this->clock->now()->elapsedSince($this->lastReceivedData); - - return $iteration->longerThan($timeout); - } - - /** - * @return Maybe - */ - private function sendMessage(Message $message): Maybe - { - if ($this->closed()) { - /** @var Maybe */ - return Maybe::nothing(); - } - - /** @var Maybe */ - return $this - ->socket - ->write($this->protocol->encode($message)) - ->maybe() - ->map(fn() => $this); - } - - /** - * @param Maybe $maybe - * - * @return Maybe - */ - private static function maybeSendMessage(Maybe $maybe, Message $message): Maybe - { - return $maybe->flatMap( - static fn($self) => $self - ->sendMessage($message) - ->flatMap( - static fn($self) => $self - ->wait() // for message acknowledgement - ->filter(static fn($message) => $message->equals(new MessageReceived)) - ->map(static fn() => $self), - ), - ); - } -} diff --git a/src/Protocol.php b/src/Protocol.php index f45c425..52796bd 100644 --- a/src/Protocol.php +++ b/src/Protocol.php @@ -3,18 +3,138 @@ namespace Innmind\IPC; -use Innmind\Stream\Readable; +use Innmind\IO\Frame; +use Innmind\MediaType\MediaType; use Innmind\Immutable\{ Str, - Maybe, + Attempt, }; -interface Protocol +/** + * @internal + * @psalm-immutable + */ +final class Protocol { - public function encode(Message $message): Str; + /** + * @param Frame $frame + */ + private function __construct( + private Frame $frame, + ) { + } + + /** + * @internal + * @psalm-pure + */ + public static function binary(): self + { + $frame = Frame::chunk(2) + ->strict() + ->map(static function($length) { + /** + * @psalm-suppress PossiblyInvalidArrayAccess Todo apply a predicate + * @var int<1, max> $mediaTypeLength + */ + [, $mediaTypeLength] = \unpack('n', $length->toString()); + + return $mediaTypeLength; + }) + ->flatMap( + static fn($mediaTypeLength) => Frame::chunk($mediaTypeLength)->strict(), + ) + ->map(static fn($mediaType) => $mediaType->toString()) + ->flatMap( + static fn($mediaType) => Frame::chunk(4) + ->strict() + ->map(static function($length): int { + /** + * @psalm-suppress PossiblyInvalidArrayAccess Todo apply a predicate + * @var int<0, max> $contentLength + */ + [, $contentLength] = \unpack('N', $length->toString()); + + return $contentLength; + }) + ->flatMap(static fn($contentLength) => match ($contentLength) { + 0 => Frame::just([$mediaType, Str::of('')]), + default => Frame::chunk($contentLength) + ->strict() + ->map(static fn($content) => [ + $mediaType, + $content, + ]), + }), + ) + ->flatMap( + // verify the message end boundary is correct + static fn($parsed) => Frame::chunk(1) + ->strict() + ->map(static function($end): mixed { + /** @psalm-suppress PossiblyInvalidArrayAccess Todo apply a predicate */ + [, $end] = \unpack('C', $end->toString()); + + return $end; + }) + ->filter(static fn($end) => $end === self::end()) + ->map(static fn() => $parsed), + ) + ->flatMap( + static fn($parsed) => match ($protocol = Message\Protocol::parse($parsed[1])) { + null => Frame::maybe( + MediaType::maybe($parsed[0])->map( + static fn($mediaType) => Message::of( + $mediaType, + $parsed[1], + ), + ), + ), + default => Frame::just($protocol->message()), + }, + ); + + return new self($frame); + } + + /** + * @return Attempt + */ + public function encode(Message $message): Attempt + { + $content = $message->content()->toEncoding(Str\Encoding::ascii); + $mediaType = Str::of($message->mediaType()->toString())->toEncoding(Str\Encoding::ascii); + $length = $content->length(); + + if ($length > 4_294_967_295) { // unsigned long integer + return Attempt::error(new \RuntimeException(\sprintf( + 'Message being sent is too long (length %s)', + $length, + ))); + } + + return Attempt::result(Str::of('%s%s%s%s%s', Str\Encoding::ascii)->sprintf( + \pack('n', $mediaType->length()), + $mediaType->toString(), + \pack('N', $content->length()), + $content->toString(), + \pack('C', self::end()), + )); + } + + /** + * @return Frame + */ + public function frame(): Frame + { + return $this->frame; + } /** - * @return Maybe + * @psalm-pure */ - public function decode(Readable $stream): Maybe; + private static function end(): int + { + return 0xCE; + } } diff --git a/src/Protocol/Binary.php b/src/Protocol/Binary.php deleted file mode 100644 index 7124475..0000000 --- a/src/Protocol/Binary.php +++ /dev/null @@ -1,102 +0,0 @@ -content()->toEncoding(Str\Encoding::ascii); - $mediaType = Str::of($message->mediaType()->toString())->toEncoding(Str\Encoding::ascii); - - if ($content->length() > 4_294_967_295) { // unsigned long integer - throw new MessageContentTooLong((string) $content->length()); - } - - return Str::of('%s%s%s%s%s', Str\Encoding::ascii)->sprintf( - \pack('n', $mediaType->length()), - $mediaType->toString(), - \pack('N', $content->length()), - $content->toString(), - \pack('C', $this->end()), - ); - } - - #[\Override] - public function decode(Readable $stream): Maybe - { - /** @var Maybe */ - return $stream - ->read(2) - ->filter(static fn($length) => !$length->empty()) - ->map(static function($length): int { - /** - * @psalm-suppress PossiblyInvalidArrayAccess Todo apply a predicate - * @var positive-int $mediaTypeLength - */ - [, $mediaTypeLength] = \unpack('n', $length->toString()); - - return $mediaTypeLength; - }) - ->flatMap(static fn($mediaTypeLength) => $stream->read($mediaTypeLength)) - ->map(static fn($mediaType) => $mediaType->toString()) - ->flatMap(static function($mediaType) use ($stream) { - return $stream - ->read(4) - ->map(static function($length): int { - /** - * @psalm-suppress PossiblyInvalidArrayAccess Todo apply a predicate - * @var positive-int $contentLength - */ - [, $contentLength] = \unpack('N', $length->toString()); - - return $contentLength; - }) - ->flatMap(static fn($contentLength) => $stream->read($contentLength)->map( - static fn($content) => [$mediaType, $contentLength, $content], - )); - }) - ->flatMap( - // verify the message end boundary is correct - fn($parsed) => $stream - ->read(1) - ->map(static function($end): mixed { - /** @psalm-suppress PossiblyInvalidArrayAccess Todo apply a predicate */ - [, $end] = \unpack('C', $end->toString()); - - return $end; - }) - ->filter(fn($end) => $end === $this->end()) - ->map(static fn() => $parsed), - ) - // verify the read content is of the length specified - ->filter(static fn($parsed) => $parsed[1] === $parsed[2]->toEncoding(Str\Encoding::ascii)->length()) - ->flatMap( - static fn($parsed) => MediaType::maybe($parsed[0])->map( - static fn($mediaType) => new Message\Generic( - $mediaType, - $parsed[2], - ), - ), - ); - } - - private function end(): int - { - return 0xCE; - } -} diff --git a/src/Server.php b/src/Server.php index 649f3c0..25b1e22 100644 --- a/src/Server.php +++ b/src/Server.php @@ -3,17 +3,128 @@ namespace Innmind\IPC; -use Innmind\Immutable\Either; +use Innmind\Async\Scheduler; +use Innmind\OperatingSystem\OperatingSystem; +use Innmind\IO\Sockets\Unix\Address; +use Innmind\Time\Period; +use Innmind\Immutable\{ + Attempt, + SideEffect, + Monoid, +}; -interface Server +/** + * @template T + */ +final class Server { /** - * @template C + * @psalm-mutation-free * - * @param C $carry - * @param callable(Message, Continuation, C): Continuation $listen + * @param Monoid $monoid + * @param \Closure(T, Server\Continuation): Server\Continuation $monitor + */ + private function __construct( + private OperatingSystem $os, + private Protocol $protocol, + private Address $address, + private Period $timeout, + private Monoid $monoid, + private \Closure $monitor, + ) { + } + + /** + * @internal + * @psalm-pure + * + * @return self + */ + public static function of( + OperatingSystem $os, + Protocol $protocol, + Address $address, + Period $timeout, + ): self { + return new self( + $os, + $protocol, + $address, + $timeout, + namespace\Monoid::sideEffect, + self::defaultMonitor(namespace\Monoid::sideEffect), + ); + } + + /** + * @psalm-mutation-free + * @template U + * + * @param Monoid $monoid + * + * @return self + */ + public function sink(Monoid $monoid): self + { + return new self( + $this->os, + $this->protocol, + $this->address, + $this->timeout, + $monoid, + self::defaultMonitor($monoid), + ); + } + + /** + * @psalm-mutation-free + * + * @param callable(T, Server\Continuation): Server\Continuation $monitor + * + * @return self + */ + public function monitor(callable $monitor): self + { + return new self( + $this->os, + $this->protocol, + $this->address, + $this->timeout, + $this->monoid, + \Closure::fromCallable($monitor), + ); + } + + /** + * @param callable(Message, Continuation, T): Continuation $listen + * + * @return Attempt + */ + #[\NoDiscard] + public function with(callable $listen): Attempt + { + return Scheduler::of($this->os) + ->sink(Attempt::result($this->monoid->identity())) + ->with(Server\Instance::of( + $this->protocol, + $this->address, + $this->timeout, + $this->monoid, + $this->monitor, + \Closure::fromCallable($listen), + )); + } + + /** + * @psalm-pure + * @template A + * + * @param Monoid $monoid * - * @return Either + * @return \Closure(A, Server\Continuation): Server\Continuation */ - public function __invoke(mixed $carry, callable $listen): Either; + private static function defaultMonitor(Monoid $monoid): \Closure + { + return static fn($_, Server\Continuation $continuation) => $continuation; + } } diff --git a/src/Server/Client.php b/src/Server/Client.php new file mode 100644 index 0000000..52a2468 --- /dev/null +++ b/src/Server/Client.php @@ -0,0 +1,199 @@ + $monoid + * @param \Closure(Message, Continuation, T): Continuation $listen + */ + private function __construct( + private Socket $client, + private Protocol $protocol, + private Monoid $monoid, + private \Closure $listen, + private Abort $abort, + ) { + } + + /** + * @return Attempt + */ + public function __invoke(OperatingSystem $os): Attempt + { + $pipe = Pipe::of( + $this->client, + $this->protocol, + $os->clock(), + $this->abort, + ); + $identity = $this->monoid->identity(); + $listen = $this->listen; + + $handshaked = $os + ->process() + ->signals() + ->listen(Signal::terminate, $this->abort) + ->flatMap(static fn() => $pipe->signal( + Message::connectionStart(), + )) + ->flatMap(static fn() => $pipe->wait()) + ->flatMap(static fn($message) => match ($message->equals(Message::connectionStartOk())) { + true => Attempt::result($identity), + false => Attempt::error(new \RuntimeException('Connection handshake failure')), + }); + + // Use an infinite sequence to iteractively wait for a message to arrive + // If the received one is a heartbeat we return a side effect, meaning + // we restart the loop. Otherwise we send an acknowledgement to the + // client before handling the message. + // Since we heartbeat when waiting for a message the ->one() call will + // always return a message unless there's a network error. This means + // that by default ->one() will wait forever. + // And since we use the sink pattern on the sequence, everything will + // stop as soon any part of the system returns an error. + return Sequence::lazy(static function() use ($handshaked, $identity) { + yield $handshaked; + + while (true) { + yield $identity; + } + }) + ->sink($identity) + ->attempt( + static fn($identity, $val) => match (true) { + $val instanceof Attempt => $val, + default => $pipe + ->wait() + ->flatMap( + static fn($message) => $pipe + ->signal(Message::ack()) + ->flatMap( + /** @psalm-suppress MixedArgument Don't know why it loses the type */ + static fn() => self::handle( + $listen, + $pipe, + $identity, + $message, + ), + ), + ) + ->mapError(static fn($e) => match (true) { + $e instanceof ConnectionProperlyClosed => $e->with($identity), + default => $e, + }), + }, + ) + ->recover( + fn($e) => match (true) { + $e instanceof Stop, + $e instanceof ConnectionProperlyClosed => $this + ->client + ->close() + ->match( // make sure to keep the user provided value + static fn() => Attempt::result($e->unwrap()), + static fn() => Attempt::result($e->unwrap()), + ), + default => $this + ->client + ->close() + ->match( // make sure to return the original error + static fn() => Attempt::error($e), + static fn() => Attempt::error($e), + ), + }, + ) + ->eitherWay( + fn($value) => $os + ->process() + ->signals() + ->remove($this->abort) + ->map(static fn(): mixed => $value), + fn($e) => $os + ->process() + ->signals() + ->remove($this->abort) + ->flatMap(static fn() => Attempt::error($e)), + ); + } + + /** + * @internal + * @template A + * + * @param Monoid $monoid + * @param \Closure(Message, Continuation, A): Continuation $listen + * + * @return self + */ + public static function of( + Socket $client, + Protocol $protocol, + Monoid $monoid, + \Closure $listen, + ): self { + return new self( + $client, + $protocol, + $monoid, + $listen, + Abort::disabled(), + ); + } + + /** + * @template A + * + * @param \Closure(Message, Continuation, A): Continuation $listen + * @param A $identity + * + * @return Attempt + */ + private static function handle( + \Closure $listen, + Pipe $pipe, + mixed $identity, + Message $message, + ): Attempt { + return $listen($message, Continuation::new($identity), $identity)->match( + static fn($carry, $messages) => $pipe + ->send($messages) + ->mapError(static fn($e) => match (true) { + $e instanceof ConnectionProperlyClosed => new Stop($carry), + default => $e, + }) + ->map(static fn(): mixed => $carry), + static fn($carry, $messages) => $pipe + ->send($messages) + ->mapError(static fn($e) => match (true) { + $e instanceof ConnectionProperlyClosed => new Stop($carry), + default => $e, + }) + ->flatMap(static fn() => Attempt::error(new Stop($carry))), + ); + } +} diff --git a/src/Server/Client/Stop.php b/src/Server/Client/Stop.php new file mode 100644 index 0000000..ed667e6 --- /dev/null +++ b/src/Server/Client/Stop.php @@ -0,0 +1,19 @@ +value; + } +} diff --git a/src/Server/ClientLifecycle.php b/src/Server/ClientLifecycle.php deleted file mode 100644 index 4c39378..0000000 --- a/src/Server/ClientLifecycle.php +++ /dev/null @@ -1,164 +0,0 @@ -clock = $clock; - $this->heartbeat = $heartbeat; - $this->client = $client; - $this->lastHeartbeat = $lastHeartbeat; - $this->state = $state; - } - - /** - * @return Maybe - */ - public static function of( - Client $client, - Clock $clock, - ElapsedPeriod $heartbeat, - ): Maybe { - return $client - ->send(new ConnectionStart) - ->map(static fn($client) => new self( - $clock, - $heartbeat, - $client, - $clock->now(), - State::pendingStartOk, - )); - } - - /** - * @template C - * - * @param callable(Message, Continuation, C): Continuation $notify - * @param C $carry - * - * @return Either> Left side of Either means the notify asked the server to stop - */ - public function notify(callable $notify, mixed $carry): Either - { - return $this - ->client - ->read() - ->either() - ->flatMap(fn($tuple) => $this->state->actUpon( - $tuple[0], - $tuple[1], - $notify, - $carry, - )) - ->map( - fn($either) => $either - ->map($this->update(...)) - ->leftMap($this->update(...)), - ); - } - - public function heartbeat(): self - { - $trigger = $this - ->clock - ->now() - ->elapsedSince($this->lastHeartbeat) - ->longerThan($this->heartbeat); - - if ($trigger) { - // do nothing when failling to send the message as it happens when - // the client has been forced closed (for example with a `kill -9` - // on the client process) - $client = $this->client->send(new Heartbeat)->match( - static fn($client) => $client, - fn() => $this->client, - ); - - return new self( - $this->clock, - $this->heartbeat, - $client, - $this->lastHeartbeat, - $this->state, - ); - } - - return $this; - } - - /** - * @return Maybe - */ - public function shutdown(): Maybe - { - return match ($this->state) { - State::pendingCloseOk => Maybe::just($this), - default => $this - ->client - ->send(new ConnectionClose) - ->map($this->pendingCloseOk(...)), - }; - } - - private function pendingCloseOk(Client $client): self - { - return new self( - $this->clock, - $this->heartbeat, - $client, - $this->lastHeartbeat, - State::pendingCloseOk, - ); - } - - /** - * @template C - * - * @param array{Client, State, C} $tuple - * - * @return array{self, C} - */ - private function update(array $tuple): array - { - return [new self( - $this->clock, - $this->heartbeat, - $tuple[0], - $this->clock->now(), - $tuple[1], - ), $tuple[2]]; - } -} diff --git a/src/Server/ClientLifecycle/State.php b/src/Server/ClientLifecycle/State.php deleted file mode 100644 index be74bc4..0000000 --- a/src/Server/ClientLifecycle/State.php +++ /dev/null @@ -1,165 +0,0 @@ -, C): Continuation $notify - * @param C $carry - * - * @return Either> Inner left side of Either means stop the server - */ - public function actUpon( - Client $client, - Message $message, - callable $notify, - mixed $carry, - ): Either { - /** @var Either> */ - return match ($this) { - self::pendingStartOk => Either::right(Either::right([ - $client, - $this->ackStartOk($message), - $carry, - ])), - self::awaitingMessage => $this->handleMessage( - $client, - $message, - $notify, - $carry, - ), - self::pendingCloseOk => $this->ackCloseOk($client, $message, $carry), - }; - } - - private function ackStartOk(Message $message): self - { - if ($message->equals(new ConnectionStartOk)) { - return self::awaitingMessage; - } - - return $this; - } - - /** - * @template C - * - * @param callable(Message, Continuation, C): Continuation $notify - * @param C $carry - * - * @return Either> - */ - private function handleMessage( - Client $client, - Message $message, - callable $notify, - mixed $carry, - ): Either { - if ($message->equals(new ConnectionClose)) { - /** @var Either> */ - return $client - ->send(new ConnectionCloseOk) - ->flatMap(static fn($client) => $client->close()) - ->either() - ->leftMap(static fn() => $carry) - ->flatMap(static fn() => Either::left($carry)); - } - - if ( - $message->equals(new ConnectionStart) || - $message->equals(new ConnectionStartOk) || - $message->equals(new ConnectionClose) || - $message->equals(new ConnectionCloseOk) || - $message->equals(new MessageReceived) || - $message->equals(new Heartbeat) - ) { - // never notify with a protocol message - /** @var Either> */ - return Either::right(Either::right([$client, $this, $carry])); - } - - /** @var Either> */ - return $client - ->send(new MessageReceived) - ->either() - ->leftMap(static fn() => $carry) - ->map(static fn($client) => $notify( - $message, - Continuation::start($client, $carry), - $carry, - )) - ->flatMap($this->determineNextState(...)); - } - - /** - * @template C - * - * @param C $carry - * - * @return Either> - */ - private function ackCloseOk( - Client $client, - Message $message, - mixed $carry, - ): Either { - if ($message->equals(new ConnectionCloseOk)) { - /** @var Either> */ - return $client - ->close() - ->either() - ->leftMap(static fn() => $carry) - ->flatMap(static fn() => Either::left($carry)); - } - - /** @var Either> */ - return Either::right(Either::right([$client, $this, $carry])); - } - - /** - * @template C - * - * @param Continuation $continuation - * - * @return Either> - */ - private function determineNextState(Continuation $continuation): Either - { - /** @var Either> */ - return $continuation->match( - fn($client, $message, $carry) => $client - ->send($message) - ->either() - ->map(fn($client) => Either::right([$client, $this, $carry])) - ->leftMap(static fn(): mixed => $carry), - static fn($client, $carry) => $client - ->send(new ConnectionClose) - ->either() - ->map(static fn($client) => Either::right([$client, self::pendingCloseOk, $carry])) - ->leftMap(static fn(): mixed => $carry), - fn($client, $carry) => Either::right(Either::left([$client, $this, $carry])), - fn($client, $carry) => Either::right(Either::right([$client, $this, $carry])), - ); - } -} diff --git a/src/Server/Connections.php b/src/Server/Connections.php deleted file mode 100644 index 27f0172..0000000 --- a/src/Server/Connections.php +++ /dev/null @@ -1,176 +0,0 @@ - */ - private Map $connections; - - /** - * @param Map $connections - */ - private function __construct( - Server $server, - Watch $watch, - Map $connections, - ) { - $this->server = $server; - $this->watch = $watch; - $this->connections = $connections; - } - - public static function start( - Watch $watch, - Server $server, - ): self { - /** @var Map */ - $connections = Map::of(); - - return new self( - $server, - $watch->forRead($server), - $connections, - ); - } - - /** - * @return Maybe - */ - public function watch(): Maybe - { - /** @psalm-suppress ArgumentTypeCoercion */ - return ($this->watch)()->map(fn($ready) => new Connections\Active( - $ready->toRead()->find(fn($socket) => $socket === $this->server), - $ready->toRead()->remove($this->server), - )); - } - - public function add( - Connection $connection, - ClientLifecycle $client, - ): self { - return new self( - $this->server, - $this->watch->forRead($connection), - ($this->connections)($connection, $client), - ); - } - - /** - * @param callable(Connection, ClientLifecycle): ClientLifecycle $map - */ - public function map(callable $map): self - { - return new self( - $this->server, - $this->watch, - $this->connections->map($map), - ); - } - - /** - * @param callable(Connection, ClientLifecycle): Map $map - */ - public function flatMap(callable $map): self - { - return new self( - $this->server, - $this->watch, - $this->connections->flatMap($map), - ); - } - - /** - * @template C - * - * @param callable(Message, Continuation, C): Continuation $listen - * @param C $carry - * - * @return Either Left side means the connections must be shutdown - */ - public function notify( - Connection $connection, - callable $listen, - mixed $carry, - ): Either { - return $this - ->connections - ->get($connection) - ->either() - ->flatMap(static fn($client) => $client->notify($listen, $carry)) - ->match( - fn($either) => $either - ->map(fn($tuple) => [new self( - $this->server, - $this->watch, - ($this->connections)($connection, $tuple[0]), - ), $tuple[1]]) - ->leftMap(fn($tuple) => [new self( - $this->server, - $this->watch, - ($this->connections)($connection, $tuple[0]), - ), $tuple[1]]), - fn($carry) => Either::right([new self( - $this->server, - $this->watch->unwatch($connection), - $this->connections->remove($connection), - ), $carry]), - ); - } - - /** - * @return Maybe - */ - public function close(): Maybe - { - return $this - ->connections - ->reduce( - Maybe::just(new SideEffect), - static fn(Maybe $maybe, $connection) => $maybe->flatMap( - static fn(): Maybe => $connection - ->close() - ->maybe(), - ), - ) - ->otherwise(fn() => $this->server->close()->maybe()) // force close the server even if a client could not be closed - ->flatMap(fn() => $this->server->close()->maybe()); - } - - /** - * @return Maybe Returns nothing when it was terminated - */ - public function terminate(): Maybe - { - if ($this->connections->empty()) { - /** @var Maybe */ - return $this - ->server - ->close() - ->maybe() - ->filter(static fn() => false); // in all cases the connections are no longer usable - } - - return Maybe::just($this); - } -} diff --git a/src/Server/Connections/Active.php b/src/Server/Connections/Active.php deleted file mode 100644 index e82f914..0000000 --- a/src/Server/Connections/Active.php +++ /dev/null @@ -1,55 +0,0 @@ - */ - private Maybe $server; - /** @var Set */ - private Set $clients; - - /** - * @param Maybe $server - * @param Set $clients - */ - public function __construct(Maybe $server, Set $clients) - { - $this->server = $server; - $this->clients = $clients; - } - - public static function none(): self - { - /** @var Maybe */ - $server = Maybe::nothing(); - - return new self($server, Set::of()); - } - - /** - * @return Maybe - */ - public function server(): Maybe - { - return $this->server; - } - - /** - * @return Set - */ - public function clients(): Set - { - return $this->clients; - } -} diff --git a/src/Server/Continuation.php b/src/Server/Continuation.php new file mode 100644 index 0000000..477c3aa --- /dev/null +++ b/src/Server/Continuation.php @@ -0,0 +1,77 @@ + + */ + public static function new(mixed $carry): self + { + return new self( + Next::continue, + $carry, + ); + } + + /** + * @param T $carry + * + * @return self + */ + public function carryWith(mixed $carry): self + { + return new self($this->next, $carry); + } + + /** + * @return self + */ + public function finish(): self + { + return new self(Next::finish, $this->carry); + } + + /** + * @internal + * @template R + * + * @param callable(T): R $continue + * @param callable(T): R $finish + * + * @return R + */ + public function match( + callable $continue, + callable $finish, + ): mixed { + /** @psalm-suppress ImpureFunctionCall */ + return match ($this->next) { + Next::continue => $continue($this->carry), + Next::finish => $finish($this->carry), + }; + } +} diff --git a/src/Server/Continuation/Next.php b/src/Server/Continuation/Next.php new file mode 100644 index 0000000..2311c81 --- /dev/null +++ b/src/Server/Continuation/Next.php @@ -0,0 +1,14 @@ + $monoid + * @param \Closure(T, Continuation): Continuation $monitor + * @param \Closure(Message, Continuation_, T): Continuation_ $listen + */ + private function __construct( + private Server|Unstarted $server, + private Protocol $protocol, + private Abort $abort, + private Period $timeout, + private Monoid $monoid, + private \Closure $monitor, + private \Closure $listen, + ) { + } + + /** + * @param Attempt $carry + * @param Scope\Continuation> $continuation + * + * @return Scope\Continuation> + */ + public function __invoke( + Attempt $carry, + OperatingSystem $os, + Scope\Continuation $continuation, + ): Scope\Continuation { + if ($this->server instanceof Unstarted) { + return ($this->server)($os) + ->map(function($server) { + $this->server = $server; + + return $server; + }) + ->map( + fn($server) => $os + ->process() + ->signals() + ->listen(Signal::terminate, $this->abort) + ->map(static fn() => $server), + ) + ->match( + static fn() => $continuation, + static fn($e) => $continuation + ->carryWith(Attempt::error($e)) + ->finish(), + ); + } + + if ($this->abort->enabled()) { + return $continuation + ->carryWith($this->uninstall( + $os, + Attempt::error(new \RuntimeException('Server signaled to terminate')), + )) + ->terminate(); + } + + /** @var Sequence */ + $all = Sequence::of(); + /** @var Sequence> */ + $results = $continuation->results(); + /** @var Attempt */ + $carry = $results + ->prepend(Sequence::of($carry)) + ->sink($all) + ->attempt(static fn($all, $result) => $result->map($all)) + ->map(fn($results) => $results->fold($this->monoid)); + + if ($continuation->results()->empty()) { + /** @psalm-suppress MixedArgument Don't know why it loses the type */ + return $carry->match( + fn($carry) => $this->listen($carry, $continuation), + fn() => $continuation + ->carryWith($this->uninstall($os, $carry)) + ->terminate(), + ); + } + + /** @psalm-suppress MixedArgument Don't know why it loses the type */ + return $carry->match( + fn($carry) => ($this->monitor)($carry, Continuation::new($carry))->match( + fn($carry) => $this->listen( + $carry, + $continuation, + ), + fn($carry) => $continuation + ->carryWith($this->uninstall( + $os, + Attempt::result($carry), + )) + ->finish(), + ), + fn() => $continuation + ->carryWith($this->uninstall($os, $carry)) + ->terminate(), + ); + } + + /** + * @internal + * @template A + * + * @param Monoid $monoid + * @param \Closure(A, Continuation): Continuation $monitor + * @param \Closure(Message, Continuation_, A): Continuation_ $listen + * + * @return self + */ + public static function of( + Protocol $protocol, + Address $address, + Period $timeout, + Monoid $monoid, + \Closure $monitor, + \Closure $listen, + ): self { + return new self( + Unstarted::of( + $address, + $timeout, + ), + $protocol, + Abort::disabled(), + $timeout, + $monoid, + $monitor, + $listen, + ); + } + + /** + * @param T $carry + * @param Scope\Continuation> $continuation + * + * @return Scope\Continuation> + */ + private function listen( + mixed $carry, + Scope\Continuation $continuation, + ): Scope\Continuation { + if ($this->server instanceof Unstarted) { + return $continuation + ->carryWith(Attempt::error(new \LogicException('Unstarted server'))) + ->terminate(); + } + + return $this + ->server + ->accept() + ->map(fn($socket) => Client::of( + $socket->timeoutAfter($this->timeout), + $this->protocol, + $this->monoid, + $this->listen, + )) + ->map(Sequence::of(...)) + ->match( + static fn($clients) => $continuation + ->carryWith(Attempt::result($carry)) + ->schedule($clients), + static fn() => $continuation // restart the loop when no new client within the timeout + ->carryWith(Attempt::result($carry)), + ); + } + + /** + * @param Attempt $carry + * + * @return Attempt + */ + private function uninstall( + OperatingSystem $os, + Attempt $carry, + ): Attempt { + return $os + ->process() + ->signals() + ->remove($this->abort) + ->eitherWay( + static fn() => $carry, + static fn() => $carry, // silently fail if it fails to remove the listener + ); + } +} diff --git a/src/Server/UnableToStart.php b/src/Server/UnableToStart.php deleted file mode 100644 index 431016c..0000000 --- a/src/Server/UnableToStart.php +++ /dev/null @@ -1,11 +0,0 @@ -sockets = $sockets; - $this->protocol = $protocol; - $this->clock = $clock; - $this->signals = $signals; - $this->address = $address; - $this->heartbeat = $heartbeat; - $this->timeout = $timeout; - } - - /** - * @template C - * - * @param C $carry - * @param callable(Message, Continuation, C): Continuation $listen - * - * @return Either - */ - #[\Override] - public function __invoke(mixed $carry, callable $listen): Either - { - $iteration = $this - ->sockets - ->open($this->address) - ->map(fn($server) => Connections::start( - $this->sockets->watch($this->heartbeat), - $server->unwrap(), - )) - ->map(fn($connections) => Unix\Iteration::first( - $this->protocol, - $this->clock, - $connections, - $this->heartbeat, - $this->timeout, - $carry, - )) - ->match( - static fn($iteration) => $iteration, - static fn() => null, - ); - - if (\is_null($iteration)) { - return Either::left(new UnableToStart); - } - - $shutdown = static function() use (&$iteration): void { - /** @var Unix\Iteration $iteration */ - $iteration->startShutdown(); - }; - $this->registerSignals($shutdown); - - // we use a while loop instead of recursion to avoid too deep call stacks - // for long running servers - do { - try { - /** - * @psalm-suppress MixedMethodCall Due to the reference above for the shutdown - * @var Unix\Iteration|C - */ - $iteration = $iteration->next($listen)->match( - static fn(mixed $iteration): mixed => $iteration, - static fn(mixed $carry): mixed => $carry, - ); - } catch (\Throwable $e) { - $this->unregisterSignals($shutdown); - - throw $e; - } - } while ($iteration instanceof Unix\Iteration); - - $this->unregisterSignals($shutdown); - - return Either::right($iteration); - } - - /** - * @param callable(Signal, Info): void $shutdown - */ - private function registerSignals(callable $shutdown): void - { - $this->signals->listen(Signal::hangup, $shutdown); - $this->signals->listen(Signal::interrupt, $shutdown); - $this->signals->listen(Signal::abort, $shutdown); - $this->signals->listen(Signal::terminate, $shutdown); - $this->signals->listen(Signal::terminalStop, $shutdown); - $this->signals->listen(Signal::alarm, $shutdown); - } - - /** - * @param callable(Signal, Info): void $shutdown - */ - private function unregisterSignals(callable $shutdown): void - { - $this->signals->remove($shutdown); - } -} diff --git a/src/Server/Unix/Iteration.php b/src/Server/Unix/Iteration.php deleted file mode 100644 index 74deabc..0000000 --- a/src/Server/Unix/Iteration.php +++ /dev/null @@ -1,251 +0,0 @@ -protocol = $protocol; - $this->clock = $clock; - $this->connections = $connections; - $this->heartbeat = $heartbeat; - $this->timeout = $timeout; - $this->lastActivity = $lastActivity; - $this->state = $state; - $this->carry = $carry; - } - - /** - * @template T - * - * @param T $carry - */ - public static function first( - Protocol $protocol, - Clock $clock, - Connections $connections, - ElapsedPeriod $heartbeat, - ?ElapsedPeriod $timeout, - mixed $carry, - ): self { - return new self( - $protocol, - $clock, - $connections, - $heartbeat, - $timeout, - $clock->now(), - State::awaitingConnection, - $carry, - ); - } - - /** - * @param callable(Message, Continuation, C): Continuation $listen - * - * @return Either - */ - public function next(callable $listen): Either - { - return $this - ->state - ->watch($this->connections) - ->either() - ->flatMap(fn($active) => $this->act($active, $listen)); - } - - /** - * This method mutates the object instead of returning a new one because it - * is called when the process is signaled and the callback cannot mutate the - * variable `$iteration` in `Server\Unix` - */ - public function startShutdown(): void - { - $this->connections = $this->state->shutdown($this->connections); - $this->lastActivity = $this->clock->now(); - $this->state = State::shuttingDown; - } - - /** - * @param callable(Message, Continuation, C): Continuation $listen - * - * @return Either - */ - private function act(Active $active, callable $listen): Either - { - $connections = $this->state->acceptConnection( - $active->server(), - $this->connections, - $this->protocol, - $this->clock, - $this->heartbeat, - ); - $connections = $this->heartbeat($connections, $active->clients()); - - return $this - ->notify($active->clients(), $connections, $listen) - ->leftMap($this->shutdown(...)) - ->flatMap($this->monitorTimeout(...)) - ->map($this->monitorTermination(...)) - ->match( - fn($connections) => $connections->map(fn($tuple) => new self( - $this->protocol, - $this->clock, - $tuple[0], - $this->heartbeat, - $this->timeout, - match ($active->clients()->empty()) { - true => $this->lastActivity, - false => $this->clock->now(), - }, - $this->state, - $tuple[1], - )), - static fn($shuttingDown) => Either::right($shuttingDown), - ); - } - - /** - * @param Set $active - */ - private function heartbeat( - Connections $connections, - Set $active, - ): Connections { - // send heartbeat message for clients not found in the active sockets - return $connections->map( - static fn($connection, $client) => $active - ->find(static fn($active) => $active === $connection) - ->match( - static fn() => $client, - static fn() => $client->heartbeat(), - ), - ); - } - - /** - * @param Set $active - * @param callable(Message, Continuation, C): Continuation $listen - * - * @return Either Left side means the connections must be shutdown - */ - private function notify( - Set $active, - Connections $connections, - callable $listen, - ): Either { - /** - * Errors are due to the weak typing of the tuples - * @psalm-suppress MixedArgumentTypeCoercion - * @psalm-suppress MixedInferredReturnType - * @psalm-suppress MixedReturnStatement - * @psalm-suppress MixedMethodCall - * @var Either - */ - return $active->reduce( - Either::right([$connections, $this->carry]), - static fn(Either $either, $connection): Either => $either->flatMap( - static fn(array $tuple): Either => $tuple[0]->notify( - $connection, - $listen, - $tuple[1], - ), - ), - ); - } - - /** - * @param array{Connections, C} $tuple - */ - private function shutdown(array $tuple): self - { - return new self( - $this->protocol, - $this->clock, - $this->state->shutdown($tuple[0]), - $this->heartbeat, - $this->timeout, - $this->clock->now(), - State::shuttingDown, - $tuple[1], - ); - } - - /** - * @param array{Connections, C} $tuple - * - * @return Either - */ - private function monitorTimeout(array $tuple): Either - { - if (!$this->timeout) { - return Either::right($tuple); - } - - $iterationDuration = $this->clock->now()->elapsedSince($this->lastActivity); - - return match ($iterationDuration->longerThan($this->timeout)) { - true => Either::left($this->shutdown($tuple)), - false => Either::right($tuple), - }; - } - - /** - * @param array{Connections, C} $tuple - * - * @return Either - */ - private function monitorTermination(array $tuple): Either - { - return $this - ->state - ->terminate($tuple[0]) - ->either() - ->map(static fn($connections) => [$connections, $tuple[1]]) - ->leftMap(static fn() => $tuple[1]); - } -} diff --git a/src/Server/Unix/State.php b/src/Server/Unix/State.php deleted file mode 100644 index 7eca1b3..0000000 --- a/src/Server/Unix/State.php +++ /dev/null @@ -1,113 +0,0 @@ - - */ - public function watch(Connections $connections): Maybe - { - // When awaiting connections if we encounter an error when watch the - // sockets we return an empty Active as it may only be a hiccup or it - // happens when the process is signaled and we enter in shutting down - // mode. This allows to properly shutdown the sockets. - // However when shutting down if we encounter an error we directly close - // all the sockets (connections and server). We do this because it's - // either the process is crashing or an error due to a signal that mess - // with the function that watch the sockets so we simply give up and let - // the server stop - - /** @var Maybe */ - return match ($this) { - self::shuttingDown => $connections - ->watch() - ->otherwise( - static fn() => $connections - ->close() - ->filter(static fn() => false), // force to return nothing - ), - self::awaitingConnection => $connections - ->watch() - ->otherwise(static fn() => Maybe::just(Active::none())), - }; - } - - /** - * @param Maybe $server - */ - public function acceptConnection( - Maybe $server, - Connections $connections, - Protocol $protocol, - Clock $clock, - ElapsedPeriod $heartbeat, - ): Connections { - return match ($this) { - self::shuttingDown => $connections, - self::awaitingConnection => $server - ->flatMap(static fn($server) => $server->accept()) - ->flatMap( - static fn($connection) => ClientLifecycle::of( - new Client\Unix($connection, $protocol), - $clock, - $heartbeat, - )->map(static fn($lifecycle) => $connections->add( - $connection, - $lifecycle, - )), - ) - ->match( - static fn($connections) => $connections, - static fn() => $connections, - ), - }; - } - - public function shutdown(Connections $connections): Connections - { - /** @psalm-suppress InvalidArgument Due to the empty map */ - return match ($this) { - self::shuttingDown => $connections, - self::awaitingConnection => $connections->flatMap( - static fn($connection, $client) => $client->shutdown()->match( - static fn($client) => Map::of([$connection, $client]), // pendingCloseOk - static fn() => Map::of(), // can't shutdown properly, discard - ), - ), - }; - } - - /** - * @return Maybe - */ - public function terminate(Connections $connections): Maybe - { - return match ($this) { - self::awaitingConnection => Maybe::just($connections), - self::shuttingDown => $connections->terminate(), - }; - } -} diff --git a/src/Server/Unstarted.php b/src/Server/Unstarted.php new file mode 100644 index 0000000..f7c669e --- /dev/null +++ b/src/Server/Unstarted.php @@ -0,0 +1,45 @@ + + */ + public function __invoke(OperatingSystem $os): Attempt + { + return $os + ->sockets() + ->takeOver($this->address) + ->map(fn($server) => $server->timeoutAfter($this->timeout)); + } + + /** + * @internal + */ + public static function of( + Address $address, + Period $timeout, + ): self { + return new self($address, $timeout); + } +} diff --git a/tests/Client/UnixTest.php b/tests/Client/UnixTest.php deleted file mode 100644 index 1fb8d2c..0000000 --- a/tests/Client/UnixTest.php +++ /dev/null @@ -1,144 +0,0 @@ -assertInstanceOf( - Client::class, - new Unix( - $this->createMock(Connection::class), - $this->createMock(Protocol::class), - ), - ); - } - - public function testSend() - { - $client = new Unix( - $connection = $this->createMock(Connection::class), - $protocol = $this->createMock(Protocol::class), - ); - $message = $this->createMock(Message::class); - $connection - ->expects($this->once()) - ->method('write') - ->with(Str::of('watev')) - ->willReturn(Either::right($connection)); - $protocol - ->expects($this->once()) - ->method('encode') - ->with($message) - ->willReturn(Str::of('watev')); - - $this->assertSame($client, $client->send($message)->match( - static fn($client) => $client, - static fn() => null, - )); - } - - public function testClose() - { - $client = new Unix( - $connection = $this->createMock(Connection::class), - $this->createMock(Protocol::class), - ); - $connection - ->expects($this->once()) - ->method('close') - ->willReturn(Either::right($expected = new SideEffect)); - - $this->assertSame($expected, $client->close()->match( - static fn($sideEffect) => $sideEffect, - static fn() => null, - )); - } - - public function testDoesntSendOnceClosed() - { - $client = new Unix( - $connection = $this->createMock(Connection::class), - $this->createMock(Protocol::class), - ); - $connection - ->expects($this->once()) - ->method('closed') - ->willReturn(true); - $connection - ->expects($this->never()) - ->method('write'); - - $this->assertNull($client->send($this->createMock(Message::class))->match( - static fn($client) => $client, - static fn() => null, - )); - } - - public function testReturnNothingWhenCantSendMessageDueToSocketError() - { - $client = new Unix( - $connection = $this->createMock(Connection::class), - $protocol = $this->createMock(Protocol::class), - ); - $message = new Message\Generic( - MediaType::of('text/plain'), - Str::of('watev'), - ); - $protocol - ->expects($this->once()) - ->method('encode') - ->with($message) - ->willReturn(Str::of('watev')); - $connection - ->expects($this->once()) - ->method('write') - ->willReturn(Either::left(new FailedToWriteToStream)); - - $this->assertNull($client->send($message)->match( - static fn($client) => $client, - static fn() => null, - )); - } - - public function testReturnNothingWhenCantProperlyCloseDueToSocketError() - { - $client = new Unix( - $connection = $this->createMock(Connection::class), - $protocol = $this->createMock(Protocol::class), - ); - $protocol - ->expects($this->never()) - ->method('encode'); - $connection - ->expects($this->once()) - ->method('close') - ->willReturn(Either::left(new FailedToCloseStream)); - - $this->assertNull($client->close()->match( - static fn($sideEffect) => $sideEffect, - static fn() => null, - )); - } -} diff --git a/tests/FactoryTest.php b/tests/FactoryTest.php deleted file mode 100644 index 6e9298c..0000000 --- a/tests/FactoryTest.php +++ /dev/null @@ -1,26 +0,0 @@ -assertInstanceOf( - IPC::class, - Factory::build( - $this->createMock(OperatingSystem::class), - Path::of('/tmp/innmind/ipc/'), - ), - ); - } -} diff --git a/tests/FunctionalTest.php b/tests/FunctionalTest.php deleted file mode 100644 index b3719f7..0000000 --- a/tests/FunctionalTest.php +++ /dev/null @@ -1,69 +0,0 @@ -status()->tmp()->toString().'/innmind/ipc/server.sock'); - $processes = $os->control()->processes(); - $processes->execute( - Command::background('php') - ->withArgument('fixtures/server.php') - ->withEnvironment('TMPDIR', $os->status()->tmp()->toString()) - ->withEnvironment('PATH', $_SERVER['PATH']), - ); - $process = $processes->execute( - Command::foreground('php') - ->withArgument('fixtures/client.php') - ->withEnvironment('TMPDIR', $os->status()->tmp()->toString()) - ->withEnvironment('PATH', $_SERVER['PATH']), - ); - $process->wait(); - $output = $process->output()->toString(); - - $this->assertSame('hello world', $output); - } - - public function testKillServer() - { - if (\getenv('CI')) { - $this->markTestSkipped(); - } - - $os = Factory::build(); - @\unlink($os->status()->tmp()->toString().'/innmind/ipc/server.sock'); - $processes = $os->control()->processes(); - $server = $processes->execute( - Command::foreground('php') - ->withArgument('fixtures/eternal-server.php') - ->withEnvironment('TMPDIR', $os->status()->tmp()->toString()) - ->withEnvironment('PATH', $_SERVER['PATH']), - ); - $os->process()->halt(new Second(1)); - $processes->kill( - $server->pid()->match( - static fn($pid) => $pid, - static fn() => null, - ), - Signal::interrupt, - ); - $this->assertTrue($server->wait()->match( - static fn() => true, - static fn() => false, - )); - - $this->assertFalse(\file_exists($os->status()->tmp()->toString().'/innmind/ipc/server.sock')); - } -} diff --git a/tests/IPC/UnixTest.php b/tests/IPC/UnixTest.php deleted file mode 100644 index bc71424..0000000 --- a/tests/IPC/UnixTest.php +++ /dev/null @@ -1,341 +0,0 @@ -assertInstanceOf( - IPC::class, - new Unix( - $this->createMock(Sockets::class), - $this->createMock(Adapter::class), - $this->createMock(Clock::class), - $this->createMock(CurrentProcess::class), - $this->createMock(Protocol::class), - Path::of('/tmp/somewhere/'), - new Timeout(1000), - ), - ); - } - - public function testThrowIfThePathDoesntRepresentADirectory() - { - $this->expectException(LogicException::class); - $this->expectExceptionMessage("Path must be a directory, got '/tmp/somewhere'"); - - new Unix( - $this->createMock(Sockets::class), - $this->createMock(Adapter::class), - $this->createMock(Clock::class), - $this->createMock(CurrentProcess::class), - $this->createMock(Protocol::class), - Path::of('/tmp/somewhere'), - new Timeout(1000), - ); - } - - public function testProcesses() - { - $ipc = new Unix( - $sockets = $this->createMock(Sockets::class), - $filesystem = Adapter\InMemory::emulateFilesystem(), - $this->createMock(Clock::class), - $this->createMock(CurrentProcess::class), - $protocol = $this->createMock(Protocol::class), - Path::of('/tmp/'), - new Timeout(1000), - ); - $filesystem->add(File::named('foo', File\Content::none())); - $filesystem->add(File::named('bar', File\Content::none())); - - $processes = $ipc->processes(); - - $this->assertInstanceOf(Set::class, $processes); - $this->assertCount(2, $processes); - $processes = $processes->toList(); - - $foo = \current($processes); - $this->assertSame('foo', $foo->toString()); - \next($processes); - $bar = \current($processes); - $this->assertSame('bar', $bar->toString()); - } - - public function testReturnNothingWhenGettingUnknownProcess() - { - $ipc = new Unix( - $this->createMock(Sockets::class), - $filesystem = $this->createMock(Adapter::class), - $this->createMock(Clock::class), - $this->createMock(CurrentProcess::class), - $this->createMock(Protocol::class), - Path::of('/tmp/somewhere/'), - new Timeout(1000), - ); - $filesystem - ->expects($this->once()) - ->method('contains') - ->with(FileName::of('foo.sock')) - ->willReturn(false); - - $this->assertNull($ipc->get(Name::of('foo'))->match( - static fn($foo) => $foo, - static fn() => null, - )); - } - - public function testGetProcess() - { - $ipc = new Unix( - $sockets = $this->createMock(Sockets::class), - $filesystem = $this->createMock(Adapter::class), - $this->createMock(Clock::class), - $this->createMock(CurrentProcess::class), - $protocol = $this->createMock(Protocol::class), - Path::of('/tmp/'), - $heartbeat = new Timeout(1000), - ); - $filesystem - ->expects($this->once()) - ->method('contains') - ->with(FileName::of('foo.sock')) - ->willReturn(true); - $sockets - ->expects($this->once()) - ->method('connectTo') - ->willReturn(Maybe::just($client = $this->createMock(Client::class))->map( - static fn($client) => IOClient::of( - static fn() => null, - $client, - ), - )); - $sockets - ->expects($this->once()) - ->method('watch') - ->with($heartbeat) - ->willReturn(Select::timeoutAfter($heartbeat)); - $resource = \tmpfile(); - $client - ->expects($this->any()) - ->method('resource') - ->willReturn($resource); - $client - ->expects($this->any()) - ->method('write') - ->willReturn(Either::right($client)); - $protocol - ->expects($this->once()) - ->method('encode') - ->willReturn(Str::of('welcome')); - $protocol - ->expects($this->once()) - ->method('decode') - ->willReturn(Maybe::just(new ConnectionStart)); - - $foo = $ipc->get(Name::of('foo'))->match( - static fn($foo) => $foo, - static fn() => null, - ); - - $this->assertInstanceOf(Process\Unix::class, $foo); - $this->assertSame('foo', $foo->name()->toString()); - } - - public function testExist() - { - $ipc = new Unix( - $this->createMock(Sockets::class), - $filesystem = $this->createMock(Adapter::class), - $this->createMock(Clock::class), - $this->createMock(CurrentProcess::class), - $this->createMock(Protocol::class), - Path::of('/tmp/'), - new Timeout(1000), - ); - $filesystem - ->expects($this->exactly(2)) - ->method('contains') - ->with(FileName::of('foo.sock')) - ->will($this->onConsecutiveCalls(true, false)); - - $this->assertTrue($ipc->exist(Name::of('foo'))); - $this->assertFalse($ipc->exist(Name::of('foo'))); - } - - public function testListen() - { - $ipc = new Unix( - $sockets = $this->createMock(Sockets::class), - $filesystem = $this->createMock(Adapter::class), - $this->createMock(Clock::class), - $this->createMock(CurrentProcess::class), - $this->createMock(Protocol::class), - Path::of('/tmp/'), - new Timeout(1000), - ); - - $server = $ipc->listen( - Name::of('bar'), - $this->createMock(ElapsedPeriod::class), - ); - - $this->assertInstanceOf(Server\Unix::class, $server); - } - - public function testWait() - { - $ipc = new Unix( - $sockets = $this->createMock(Sockets::class), - $filesystem = $this->createMock(Adapter::class), - $this->createMock(Clock::class), - $process = $this->createMock(CurrentProcess::class), - $protocol = $this->createMock(Protocol::class), - Path::of('/tmp/'), - $timeout = new Timeout(1000), - ); - $filesystem - ->expects($this->exactly(4)) - ->method('contains') - ->with(FileName::of('foo.sock')) - ->will($this->onConsecutiveCalls(false, false, true, true)); - $process - ->expects($this->exactly(2)) - ->method('halt') - ->with(new Millisecond(1000)); - $sockets - ->expects($this->once()) - ->method('connectTo') - ->willReturn(Maybe::just($socket = $this->createMock(Client::class))->map( - static fn($client) => IOClient::of( - static fn() => null, - $client, - ), - )); - $sockets - ->expects($this->once()) - ->method('watch') - ->with($timeout) - ->willReturn(Select::timeoutAfter($timeout)); - $resource = \tmpfile(); - $socket - ->expects($this->any()) - ->method('resource') - ->willReturn($resource); - $protocol - ->expects($this->once()) - ->method('decode') - ->with($socket) - ->willReturn(Maybe::just(new ConnectionStart)); - $protocol - ->expects($this->once()) - ->method('encode') - ->with(new ConnectionStartOk) - ->willReturn(Str::of('start-ok')); - $socket - ->method('write') - ->with(Str::of('start-ok')) - ->willReturn(Either::right($socket)); - - $this->assertInstanceOf(Process::class, $ipc->wait(Name::of('foo'))->match( - static fn($process) => $process, - static fn() => null, - )); - } - - public function testStopWaitingWhenTimeoutExceeded() - { - $ipc = new Unix( - $this->createMock(Sockets::class), - $filesystem = $this->createMock(Adapter::class), - $clock = $this->createMock(Clock::class), - $process = $this->createMock(CurrentProcess::class), - $this->createMock(Protocol::class), - Path::of('/tmp/'), - new Timeout(1000), - ); - $timeout = $this->createMock(ElapsedPeriod::class); - $filesystem - ->expects($this->any()) - ->method('contains') - ->with(FileName::of('foo.sock')) - ->willReturn(false); - $process - ->expects($this->once()) - ->method('halt'); - $clock - ->expects($this->exactly(3)) - ->method('now') - ->will($this->onConsecutiveCalls( - $start = $this->createMock(PointInTime::class), - $firstIteration = $this->createMock(PointInTime::class), - $secondIteration = $this->createMock(PointInTime::class), - )); - $firstIteration - ->expects($this->once()) - ->method('elapsedSince') - ->with($start) - ->willReturn($duration = $this->createMock(ElapsedPeriod::class)); - $duration - ->expects($this->once()) - ->method('longerThan') - ->with($timeout) - ->willReturn(false); - $secondIteration - ->expects($this->once()) - ->method('elapsedSince') - ->with($start) - ->willReturn($duration = $this->createMock(ElapsedPeriod::class)); - $duration - ->expects($this->once()) - ->method('longerThan') - ->with($timeout) - ->willReturn(true); - - $this->assertNull($ipc->wait(Name::of('foo'), $timeout)->match( - static fn($process) => $process, - static fn() => null, - )); - } -} diff --git a/tests/Message/ConnectionCloseOkTest.php b/tests/Message/ConnectionCloseOkTest.php deleted file mode 100644 index 0bc60df..0000000 --- a/tests/Message/ConnectionCloseOkTest.php +++ /dev/null @@ -1,41 +0,0 @@ -assertInstanceOf(Message::class, $message); - $this->assertSame('text/plain', $message->mediaType()->toString()); - $this->assertSame('innmind/ipc:connection.close-ok', $message->content()->toString()); - } - - public function testEquals() - { - $message = new ConnectionCloseOk; - $same = new Generic( - MediaType::of('text/plain'), - Str::of('innmind/ipc:connection.close-ok'), - ); - $different = new Generic( - MediaType::of('text/plain'), - Str::of('foo'), - ); - - $this->assertTrue($message->equals($same)); - $this->assertFalse($message->equals($different)); - } -} diff --git a/tests/Message/ConnectionCloseTest.php b/tests/Message/ConnectionCloseTest.php deleted file mode 100644 index 909c6c1..0000000 --- a/tests/Message/ConnectionCloseTest.php +++ /dev/null @@ -1,41 +0,0 @@ -assertInstanceOf(Message::class, $message); - $this->assertSame('text/plain', $message->mediaType()->toString()); - $this->assertSame('innmind/ipc:connection.close', $message->content()->toString()); - } - - public function testEquals() - { - $message = new ConnectionClose; - $same = new Generic( - MediaType::of('text/plain'), - Str::of('innmind/ipc:connection.close'), - ); - $different = new Generic( - MediaType::of('text/plain'), - Str::of('foo'), - ); - - $this->assertTrue($message->equals($same)); - $this->assertFalse($message->equals($different)); - } -} diff --git a/tests/Message/ConnectionStartOkTest.php b/tests/Message/ConnectionStartOkTest.php deleted file mode 100644 index 7417611..0000000 --- a/tests/Message/ConnectionStartOkTest.php +++ /dev/null @@ -1,41 +0,0 @@ -assertInstanceOf(Message::class, $message); - $this->assertSame('text/plain', $message->mediaType()->toString()); - $this->assertSame('innmind/ipc:connection.start-ok', $message->content()->toString()); - } - - public function testEquals() - { - $message = new ConnectionStartOk; - $same = new Generic( - MediaType::of('text/plain'), - Str::of('innmind/ipc:connection.start-ok'), - ); - $different = new Generic( - MediaType::of('text/plain'), - Str::of('foo'), - ); - - $this->assertTrue($message->equals($same)); - $this->assertFalse($message->equals($different)); - } -} diff --git a/tests/Message/ConnectionStartTest.php b/tests/Message/ConnectionStartTest.php deleted file mode 100644 index 3ef12de..0000000 --- a/tests/Message/ConnectionStartTest.php +++ /dev/null @@ -1,41 +0,0 @@ -assertInstanceOf(Message::class, $message); - $this->assertSame('text/plain', $message->mediaType()->toString()); - $this->assertSame('innmind/ipc:connection.start', $message->content()->toString()); - } - - public function testEquals() - { - $message = new ConnectionStart; - $same = new Generic( - MediaType::of('text/plain'), - Str::of('innmind/ipc:connection.start'), - ); - $different = new Generic( - MediaType::of('text/plain'), - Str::of('foo'), - ); - - $this->assertTrue($message->equals($same)); - $this->assertFalse($message->equals($different)); - } -} diff --git a/tests/Message/GenericTest.php b/tests/Message/GenericTest.php deleted file mode 100644 index d0865f3..0000000 --- a/tests/Message/GenericTest.php +++ /dev/null @@ -1,57 +0,0 @@ -assertInstanceOf(Message::class, $message); - $this->assertSame($mediaType, $message->mediaType()); - $this->assertSame($content, $message->content()); - } - - public function testOf() - { - $message = Generic::of('text/plain', 'foo'); - - $this->assertInstanceOf(Generic::class, $message); - $this->assertTrue($message->equals(new Generic( - MediaType::of('text/plain'), - Str::of('foo'), - ))); - } - - public function testEquals() - { - $message = new Generic( - MediaType::of('text/plain'), - Str::of('watev'), - ); - $same = new Generic( - MediaType::of('text/plain'), - Str::of('watev'), - ); - $different = new Generic( - MediaType::of('text/plain'), - Str::of('foo'), - ); - - $this->assertTrue($message->equals($same)); - $this->assertFalse($message->equals($different)); - } -} diff --git a/tests/Message/HeartbeatTest.php b/tests/Message/HeartbeatTest.php deleted file mode 100644 index 8d88de8..0000000 --- a/tests/Message/HeartbeatTest.php +++ /dev/null @@ -1,41 +0,0 @@ -assertInstanceOf(Message::class, $message); - $this->assertSame('text/plain', $message->mediaType()->toString()); - $this->assertSame('innmind/ipc:heartbeat', $message->content()->toString()); - } - - public function testEquals() - { - $message = new Heartbeat; - $same = new Generic( - MediaType::of('text/plain'), - Str::of('innmind/ipc:heartbeat'), - ); - $different = new Generic( - MediaType::of('text/plain'), - Str::of('foo'), - ); - - $this->assertTrue($message->equals($same)); - $this->assertFalse($message->equals($different)); - } -} diff --git a/tests/Message/MessageReceivedTest.php b/tests/Message/MessageReceivedTest.php deleted file mode 100644 index 14bd88e..0000000 --- a/tests/Message/MessageReceivedTest.php +++ /dev/null @@ -1,41 +0,0 @@ -assertInstanceOf(Message::class, $message); - $this->assertSame('text/plain', $message->mediaType()->toString()); - $this->assertSame('innmind/ipc:message.received', $message->content()->toString()); - } - - public function testEquals() - { - $message = new MessageReceived; - $same = new Generic( - MediaType::of('text/plain'), - Str::of('innmind/ipc:message.received'), - ); - $different = new Generic( - MediaType::of('text/plain'), - Str::of('foo'), - ); - - $this->assertTrue($message->equals($same)); - $this->assertFalse($message->equals($different)); - } -} diff --git a/tests/Process/NameTest.php b/tests/Process/NameTest.php deleted file mode 100644 index 7ecd0c9..0000000 --- a/tests/Process/NameTest.php +++ /dev/null @@ -1,41 +0,0 @@ -forAll(Set\Elements::of( - 'fooBar', - 'foo_bar', - 'foo-bar', - '42foo', - )) - ->then(function(string $string): void { - $this->assertSame($string, Name::of($string)->toString()); - }); - } - - public function testThrowWhenContainsInvalidCharacter() - { - $this->expectException(DomainException::class); - $this->expectExceptionMessage('foo.bar'); - - Name::of('foo.bar'); - } -} diff --git a/tests/Process/UnixTest.php b/tests/Process/UnixTest.php deleted file mode 100644 index 5d89e2f..0000000 --- a/tests/Process/UnixTest.php +++ /dev/null @@ -1,969 +0,0 @@ -createMock(Sockets::class); - $protocol = $this->createMock(Protocol::class); - $address = Address::of('/tmp/foo'); - $sockets - ->expects($this->once()) - ->method('connectTo') - ->with($address) - ->willReturn(Maybe::just($socket = $this->createMock(Client::class))->map( - static fn($client) => IOClient::of( - static fn() => null, - $client, - ), - )); - $sockets - ->expects($this->once()) - ->method('watch') - ->with($timeout) - ->willReturn(Select::timeoutAfter($timeout)); - $resource = \tmpfile(); - $socket - ->expects($this->any()) - ->method('resource') - ->willReturn($resource); - $protocol - ->expects($this->once()) - ->method('decode') - ->with($socket) - ->willReturn(Maybe::just(new ConnectionStart)); - $protocol - ->expects($this->once()) - ->method('encode') - ->with(new ConnectionStartOk) - ->willReturn(Str::of('start-ok')); - $socket - ->method('write') - ->with(Str::of('start-ok')) - ->willReturn(Either::right($socket)); - - $process = Unix::of( - $sockets, - $protocol, - $this->createMock(Clock::class), - $address, - $name = Name::of('foo'), - $timeout, - )->match( - static fn($process) => $process, - static fn() => null, - ); - - $this->assertInstanceOf(Process::class, $process); - $this->assertSame($name, $process->name()); - } - - public function testReturnNothingWhenFailedConnectionStart() - { - $timeout = new Timeout(1000); - $sockets = $this->createMock(Sockets::class); - $protocol = $this->createMock(Protocol::class); - $address = Address::of('/tmp/foo'); - $sockets - ->expects($this->once()) - ->method('connectTo') - ->with($address) - ->willReturn(Maybe::just($socket = $this->createMock(Client::class))->map( - static fn($client) => IOClient::of( - static fn() => null, - $client, - ), - )); - $sockets - ->expects($this->once()) - ->method('watch') - ->with($timeout) - ->willReturn(Select::timeoutAfter($timeout)); - $resource = \tmpfile(); - $socket - ->expects($this->any()) - ->method('resource') - ->willReturn($resource); - $protocol - ->expects($this->once()) - ->method('decode') - ->with($socket) - ->willReturn(Maybe::just($this->createMock(Message::class))); - $socket - ->method('write') - ->with(Str::of('start-ok')) - ->willReturn(Either::right($socket)); - - $process = Unix::of( - $sockets, - $protocol, - $this->createMock(Clock::class), - $address, - Name::of('foo'), - $timeout, - )->match( - static fn($process) => $process, - static fn() => null, - ); - - $this->assertNull($process); - } - - public function testSend() - { - $message = $this->createMock(Message::class); - - $timeout = new Timeout(1000); - $sockets = $this->createMock(Sockets::class); - $protocol = $this->createMock(Protocol::class); - $address = Address::of('/tmp/foo'); - $sockets - ->expects($this->once()) - ->method('connectTo') - ->with($address) - ->willReturn(Maybe::just($socket = $this->createMock(Client::class))->map( - static fn($client) => IOClient::of( - static fn() => null, - $client, - ), - )); - $sockets - ->expects($this->once()) - ->method('watch') - ->with($timeout) - ->willReturn(Select::timeoutAfter($timeout)); - $resource = \tmpfile(); - $socket - ->expects($this->any()) - ->method('resource') - ->willReturn($resource); - $protocol - ->expects($this->atLeast(1)) - ->method('decode') - ->with($socket) - ->will($this->onConsecutiveCalls( - Maybe::just(new ConnectionStart), - Maybe::just(new MessageReceived), - )); - $protocol - ->expects($matcher = $this->exactly(2)) - ->method('encode') - ->willReturnCallback(function($in) use ($matcher, $message) { - match ($matcher->numberOfInvocations()) { - 1 => $this->assertEquals(new ConnectionStartOk, $in), - 2 => $this->assertEquals($message, $in), - }; - - return match ($matcher->numberOfInvocations()) { - 1 => Str::of('start-ok'), - 2 => Str::of('message-to-send'), - }; - }); - $socket - ->expects($matcher = $this->exactly(2)) - ->method('write') - ->willReturnCallback(function($message) use ($matcher, $socket) { - match ($matcher->numberOfInvocations()) { - 1 => $this->assertEquals(Str::of('start-ok'), $message), - 2 => $this->assertEquals(Str::of('message-to-send'), $message), - }; - - return Either::right($socket); - }); - - $process = Unix::of( - $sockets, - $protocol, - $this->createMock(Clock::class), - $address, - $name = Name::of('foo'), - $timeout, - )->match( - static fn($process) => $process, - static fn() => null, - ); - - $this->assertSame($process, $process->send(Sequence::of($message))->match( - static fn($process) => $process, - static fn() => null, - )); - } - - public function testReturnNothingWhenErrorAtSent() - { - $message = $this->createMock(Message::class); - - $timeout = new Timeout(1000); - $sockets = $this->createMock(Sockets::class); - $protocol = $this->createMock(Protocol::class); - $address = Address::of('/tmp/foo'); - $sockets - ->expects($this->once()) - ->method('connectTo') - ->with($address) - ->willReturn(Maybe::just($socket = $this->createMock(Client::class))->map( - static fn($client) => IOClient::of( - static fn() => null, - $client, - ), - )); - $sockets - ->expects($this->once()) - ->method('watch') - ->with($timeout) - ->willReturn(Select::timeoutAfter($timeout)); - $resource = \tmpfile(); - $socket - ->expects($this->any()) - ->method('resource') - ->willReturn($resource); - $protocol - ->expects($this->once()) - ->method('decode') - ->with($socket) - ->willReturn(Maybe::just(new ConnectionStart)); - $protocol - ->expects($matcher = $this->exactly(2)) - ->method('encode') - ->willReturnCallback(function($in) use ($matcher, $message) { - match ($matcher->numberOfInvocations()) { - 1 => $this->assertEquals(new ConnectionStartOk, $in), - 2 => $this->assertEquals($message, $in), - }; - - return match ($matcher->numberOfInvocations()) { - 1 => Str::of('start-ok'), - 2 => Str::of('message content'), - }; - }); - $socket - ->expects($matcher = $this->exactly(2)) - ->method('write') - ->willReturnCallback(function($message) use ($matcher, $socket) { - match ($matcher->numberOfInvocations()) { - 1 => $this->assertEquals(Str::of('start-ok'), $message), - 2 => $this->assertEquals(Str::of('message content'), $message), - }; - - return match ($matcher->numberOfInvocations()) { - 1 => Either::right($socket), - 2 => Either::left(new FailedToWriteToStream), - }; - }); - - $process = Unix::of( - $sockets, - $protocol, - $this->createMock(Clock::class), - $address, - $name = Name::of('foo'), - $timeout, - )->match( - static fn($process) => $process, - static fn() => null, - ); - - $this->assertNull($process->send(Sequence::of($message))->match( - static fn($process) => $process, - static fn() => null, - )); - } - - public function testReturnNothingWhenTryingToSendOnClosedSocket() - { - $timeout = new Timeout(1000); - $sockets = $this->createMock(Sockets::class); - $protocol = $this->createMock(Protocol::class); - $address = Address::of('/tmp/foo'); - $sockets - ->expects($this->once()) - ->method('connectTo') - ->with($address) - ->willReturn(Maybe::just($socket = $this->createMock(Client::class))->map( - static fn($client) => IOClient::of( - static fn() => null, - $client, - ), - )); - $sockets - ->expects($this->once()) - ->method('watch') - ->with($timeout) - ->willReturn(Select::timeoutAfter($timeout)); - $resource = \tmpfile(); - $socket - ->expects($this->any()) - ->method('resource') - ->willReturn($resource); - $protocol - ->expects($this->once()) - ->method('decode') - ->with($socket) - ->willReturn(Maybe::just(new ConnectionStart)); - $protocol - ->expects($this->once()) - ->method('encode') - ->with(new ConnectionStartOk) - ->willReturn(Str::of('start-ok')); - $socket - ->expects($this->once()) - ->method('write') - ->with(Str::of('start-ok')) - ->willReturn(Either::right($socket)); - $socket - ->expects($this->exactly(3)) - ->method('closed') - ->will($this->onConsecutiveCalls( - false, - false, - true, - )); - - $process = Unix::of( - $sockets, - $protocol, - $this->createMock(Clock::class), - $address, - $name = Name::of('foo'), - $timeout, - )->match( - static fn($process) => $process, - static fn() => null, - ); - - $this->assertNull($process->send(Sequence::of($this->createMock(Message::class)))->match( - static fn($process) => $process, - static fn() => null, - )); - } - - public function testThrowWhenWaitingOnClosedSocket() - { - $timeout = new Timeout(1000); - $sockets = $this->createMock(Sockets::class); - $protocol = $this->createMock(Protocol::class); - $address = Address::of('/tmp/foo'); - $sockets - ->expects($this->once()) - ->method('connectTo') - ->with($address) - ->willReturn(Maybe::just($socket = $this->createMock(Client::class))->map( - static fn($client) => IOClient::of( - static fn() => null, - $client, - ), - )); - $sockets - ->expects($this->once()) - ->method('watch') - ->with($timeout) - ->willReturn(Select::timeoutAfter($timeout)); - $resource = \tmpfile(); - $socket - ->expects($this->any()) - ->method('resource') - ->willReturn($resource); - $protocol - ->expects($this->once()) - ->method('decode') - ->with($socket) - ->willReturn(Maybe::just(new ConnectionStart)); - $protocol - ->expects($this->once()) - ->method('encode') - ->with(new ConnectionStartOk) - ->willReturn(Str::of('start-ok')); - $socket - ->method('write') - ->with(Str::of('start-ok')) - ->willReturn(Either::right($socket)); - $socket - ->method('closed') - ->will($this->onConsecutiveCalls( - false, - false, - true, - )); - $socket - ->method('close') - ->willReturn(Either::right(new SideEffect)); - - $process = Unix::of( - $sockets, - $protocol, - $this->createMock(Clock::class), - $address, - $name = Name::of('foo'), - $timeout, - )->match( - static fn($process) => $process, - static fn() => null, - ); - - $this->assertNull($process->wait()->match( - static fn($message) => $message, - static fn() => null, - )); - $this->assertTrue($process->closed()); - } - - public function testWait() - { - $timeout = new Timeout(1000); - $sockets = $this->createMock(Sockets::class); - $protocol = $this->createMock(Protocol::class); - $address = Address::of('/tmp/foo'); - $sockets - ->expects($this->once()) - ->method('connectTo') - ->with($address) - ->willReturn(Maybe::just($socket = $this->createMock(Client::class))->map( - static fn($client) => IOClient::of( - static fn() => null, - $client, - ), - )); - $sockets - ->expects($this->once()) - ->method('watch') - ->with($timeout) - ->willReturn(Select::timeoutAfter($timeout)); - $resource = \tmpfile(); - $socket - ->expects($this->any()) - ->method('resource') - ->willReturn($resource); - $protocol - ->expects($this->once()) - ->method('encode') - ->with(new ConnectionStartOk) - ->willReturn(Str::of('start-ok')); - $protocol - ->expects($this->exactly(2)) - ->method('decode') - ->with($socket) - ->will($this->onConsecutiveCalls( - Maybe::just(new ConnectionStart), - Maybe::just($message = $this->createMock(Message::class)), - )); - $socket - ->method('write') - ->with(Str::of('start-ok')) - ->willReturn(Either::right($socket)); - - $process = Unix::of( - $sockets, - $protocol, - $this->createMock(Clock::class), - $address, - $name = Name::of('foo'), - $timeout, - )->match( - static fn($process) => $process, - static fn() => null, - ); - - $this->assertSame($message, $process->wait()->match( - static fn($message) => $message, - static fn() => null, - )); - } - - public function testDiscardHeartbeatWhenWaiting() - { - $timeout = new Timeout(1000); - $sockets = $this->createMock(Sockets::class); - $protocol = $this->createMock(Protocol::class); - $address = Address::of('/tmp/foo'); - $sockets - ->expects($this->once()) - ->method('connectTo') - ->with($address) - ->willReturn(Maybe::just($socket = $this->createMock(Client::class))->map( - static fn($client) => IOClient::of( - static fn() => null, - $client, - ), - )); - $sockets - ->expects($this->once()) - ->method('watch') - ->with($timeout) - ->willReturn(Select::timeoutAfter($timeout)); - $resource = \tmpfile(); - $socket - ->expects($this->any()) - ->method('resource') - ->willReturn($resource); - $protocol - ->expects($this->once()) - ->method('encode') - ->with(new ConnectionStartOk) - ->willReturn(Str::of('start-ok')); - $protocol - ->expects($this->exactly(3)) - ->method('decode') - ->with($socket) - ->will($this->onConsecutiveCalls( - Maybe::just(new ConnectionStart), - Maybe::just(new Heartbeat), - Maybe::just($message = $this->createMock(Message::class)), - )); - $socket - ->method('write') - ->with(Str::of('start-ok')) - ->willReturn(Either::right($socket)); - - $process = Unix::of( - $sockets, - $protocol, - $this->createMock(Clock::class), - $address, - $name = Name::of('foo'), - $timeout, - )->match( - static fn($process) => $process, - static fn() => null, - ); - - $this->assertSame($message, $process->wait()->match( - static fn($message) => $message, - static fn() => null, - )); - } - - public function testConfirmCloseWhenWaiting() - { - $timeout = new Timeout(1000); - $sockets = $this->createMock(Sockets::class); - $protocol = $this->createMock(Protocol::class); - $address = Address::of('/tmp/foo'); - $sockets - ->expects($this->once()) - ->method('connectTo') - ->with($address) - ->willReturn(Maybe::just($socket = $this->createMock(Client::class))->map( - static fn($client) => IOClient::of( - static fn() => null, - $client, - ), - )); - $sockets - ->expects($this->once()) - ->method('watch') - ->with($timeout) - ->willReturn(Select::timeoutAfter($timeout)); - $resource = \tmpfile(); - $socket - ->expects($this->any()) - ->method('resource') - ->willReturn($resource); - $protocol - ->expects($this->exactly(2)) - ->method('decode') - ->with($socket) - ->will($this->onConsecutiveCalls( - Maybe::just(new ConnectionStart), - Maybe::just(new ConnectionClose), - )); - $protocol - ->expects($matcher = $this->exactly(2)) - ->method('encode') - ->willReturnCallback(function($message) use ($matcher) { - match ($matcher->numberOfInvocations()) { - 1 => $this->assertEquals(new ConnectionStartOk, $message), - 2 => $this->assertEquals(new ConnectionCloseOk, $message), - }; - - return match ($matcher->numberOfInvocations()) { - 1 => Str::of('start-ok'), - 2 => Str::of('close-ok'), - }; - }); - $socket - ->expects($matcher = $this->exactly(2)) - ->method('write') - ->willReturnCallback(function($message) use ($matcher, $socket) { - match ($matcher->numberOfInvocations()) { - 1 => $this->assertEquals(Str::of('start-ok'), $message), - 2 => $this->assertEquals(Str::of('close-ok'), $message), - }; - - return Either::right($socket); - }); - $socket - ->method('close') - ->willReturn(Either::right(new SideEffect)); - - $process = Unix::of( - $sockets, - $protocol, - $this->createMock(Clock::class), - $address, - $name = Name::of('foo'), - $timeout, - )->match( - static fn($process) => $process, - static fn() => null, - ); - - $this->assertNull($process->wait()->match( - static fn($message) => $message, - static fn() => null, - )); - $this->assertTrue($process->closed()); - } - - public function testReturnNothingWhenNoMessageDecoded() - { - $timeout = new Timeout(1000); - $sockets = $this->createMock(Sockets::class); - $protocol = $this->createMock(Protocol::class); - $address = Address::of('/tmp/foo'); - $sockets - ->expects($this->once()) - ->method('connectTo') - ->with($address) - ->willReturn(Maybe::just($socket = $this->createMock(Client::class))->map( - static fn($client) => IOClient::of( - static fn() => null, - $client, - ), - )); - $sockets - ->expects($this->once()) - ->method('watch') - ->with($timeout) - ->willReturn(Select::timeoutAfter($timeout)); - $resource = \tmpfile(); - $socket - ->expects($this->any()) - ->method('resource') - ->willReturn($resource); - $protocol - ->expects($this->exactly(2)) - ->method('decode') - ->with($socket) - ->will($this->onConsecutiveCalls( - Maybe::just(new ConnectionStart), - Maybe::nothing(), - )); - $protocol - ->expects($this->once()) - ->method('encode') - ->with(new ConnectionStartOk) - ->willReturn(Str::of('start-ok')); - $socket - ->method('write') - ->with(Str::of('start-ok')) - ->willReturn(Either::right($socket)); - - $process = Unix::of( - $sockets, - $protocol, - $this->createMock(Clock::class), - $address, - $name = Name::of('foo'), - $timeout, - )->match( - static fn($process) => $process, - static fn() => null, - ); - - $this->assertNull($process->wait()->match( - static fn($message) => $message, - static fn() => null, - )); - } - - public function testClose() - { - $timeout = new Timeout(1000); - $sockets = $this->createMock(Sockets::class); - $protocol = $this->createMock(Protocol::class); - $address = Address::of('/tmp/foo'); - $sockets - ->expects($this->once()) - ->method('connectTo') - ->with($address) - ->willReturn(Maybe::just($socket = $this->createMock(Client::class))->map( - static fn($client) => IOClient::of( - static fn() => null, - $client, - ), - )); - $sockets - ->expects($this->once()) - ->method('watch') - ->with($timeout) - ->willReturn(Select::timeoutAfter($timeout)); - $resource = \tmpfile(); - $socket - ->expects($this->any()) - ->method('resource') - ->willReturn($resource); - $protocol - ->expects($this->exactly(2)) - ->method('decode') - ->with($socket) - ->will($this->onConsecutiveCalls( - Maybe::just(new ConnectionStart), - Maybe::just(new ConnectionCloseOk), - )); - $protocol - ->expects($matcher = $this->exactly(2)) - ->method('encode') - ->willReturnCallback(function($message) use ($matcher) { - match ($matcher->numberOfInvocations()) { - 1 => $this->assertEquals(new ConnectionStartOk, $message), - 2 => $this->assertEquals(new ConnectionClose, $message), - }; - - return match ($matcher->numberOfInvocations()) { - 1 => Str::of('start-ok'), - 2 => Str::of('close'), - }; - }); - $socket - ->expects($matcher = $this->exactly(2)) - ->method('write') - ->willReturnCallback(function($message) use ($matcher, $socket) { - match ($matcher->numberOfInvocations()) { - 1 => $this->assertEquals(Str::of('start-ok'), $message), - 2 => $this->assertEquals(Str::of('close'), $message), - }; - - return Either::right($socket); - }); - $socket - ->method('close') - ->willReturn(Either::right(new SideEffect)); - - $process = Unix::of( - $sockets, - $protocol, - $this->createMock(Clock::class), - $address, - $name = Name::of('foo'), - $timeout, - )->match( - static fn($process) => $process, - static fn() => null, - ); - - $this->assertInstanceOf(SideEffect::class, $process->close()->match( - static fn($sideEffect) => $sideEffect, - static fn() => null, - )); - $this->assertTrue($process->closed()); - } - - public function testThrowWhenInvalidCloseConfirmation() - { - $timeout = new Timeout(1000); - $sockets = $this->createMock(Sockets::class); - $protocol = $this->createMock(Protocol::class); - $address = Address::of('/tmp/foo'); - $sockets - ->expects($this->once()) - ->method('connectTo') - ->with($address) - ->willReturn(Maybe::just($socket = $this->createMock(Client::class))->map( - static fn($client) => IOClient::of( - static fn() => null, - $client, - ), - )); - $sockets - ->expects($this->once()) - ->method('watch') - ->with($timeout) - ->willReturn(Select::timeoutAfter($timeout)); - $resource = \tmpfile(); - $socket - ->expects($this->any()) - ->method('resource') - ->willReturn($resource); - $protocol - ->expects($this->exactly(2)) - ->method('decode') - ->with($socket) - ->will($this->onConsecutiveCalls( - Maybe::just(new ConnectionStart), - Maybe::just($this->createMock(Message::class)), - )); - $protocol - ->expects($matcher = $this->exactly(2)) - ->method('encode') - ->willReturnCallback(function($message) use ($matcher) { - match ($matcher->numberOfInvocations()) { - 1 => $this->assertEquals(new ConnectionStartOk, $message), - 2 => $this->assertEquals(new ConnectionClose, $message), - }; - - return match ($matcher->numberOfInvocations()) { - 1 => Str::of('start-ok'), - 2 => Str::of('close'), - }; - }); - $socket - ->expects($matcher = $this->exactly(2)) - ->method('write') - ->willReturnCallback(function($message) use ($matcher, $socket) { - match ($matcher->numberOfInvocations()) { - 1 => $this->assertEquals(Str::of('start-ok'), $message), - 2 => $this->assertEquals(Str::of('close'), $message), - }; - - return Either::right($socket); - }); - $socket - ->method('close') - ->willReturn(Either::right(new SideEffect)); - - $process = Unix::of( - $sockets, - $protocol, - $this->createMock(Clock::class), - $address, - $name = Name::of('foo'), - $timeout, - )->match( - static fn($process) => $process, - static fn() => null, - ); - - $this->assertNull($process->close()->match( - static fn($sideEffect) => $sideEffect, - static fn() => null, - )); - $this->assertTrue($process->closed()); - } - - public function testStopWaitingAfterTimeout() - { - $os = Factory::build(); - @\unlink($os->status()->tmp()->toString().'/innmind/ipc/server.sock'); - $processes = $os->control()->processes(); - $server = $processes->execute( - Command::foreground('php') - ->withArgument('fixtures/eternal-server.php') - ->withEnvironment('TMPDIR', $os->status()->tmp()->toString()) - ->withEnvironment('PATH', $_SERVER['PATH']), - ); - - $started = $server - ->output() - ->chunks() - ->find(static fn($pair) => $pair[0]->startsWith('Server ready!')); - - $this->assertTrue($started->match( - static fn() => true, - static fn() => false, - )); - - \sleep(1); - - $process = Unix::of( - $os->sockets(), - new Protocol\Binary, - $os->clock(), - Address::of($os->status()->tmp()->toString().'/innmind/ipc/server'), - $name = Name::of('server'), - new Timeout(1000), - )->match( - static fn($process) => $process, - static fn() => null, - ); - - $this->assertNotNull($process); - - try { - $this->assertNull($process->wait(new Timeout(100))->match( - static fn($message) => $message, - static fn() => null, - )); - } finally { - $processes->kill( - $server->pid()->match( - static fn($pid) => $pid, - static fn() => null, - ), - Signal::terminate, - ); - } - } - - public function testReturnNothingWhenSocketErrorWhenConnecting() - { - $sockets = $this->createMock(Sockets::class); - $protocol = $this->createMock(Protocol::class); - $address = Address::of('/tmp/foo'); - $sockets - ->expects($this->once()) - ->method('connectTo') - ->with($address) - ->willReturn(Maybe::nothing()); - - $process = Unix::of( - $sockets, - $protocol, - $this->createMock(Clock::class), - $address, - Name::of('foo'), - new Timeout(1000), - )->match( - static fn($process) => $process, - static fn() => null, - ); - - $this->assertNull($process); - } -} diff --git a/tests/Protocol/BinaryTest.php b/tests/Protocol/BinaryTest.php deleted file mode 100644 index a6227c3..0000000 --- a/tests/Protocol/BinaryTest.php +++ /dev/null @@ -1,91 +0,0 @@ -assertInstanceOf(Protocol::class, new Binary); - } - - public function testEncode() - { - $protocol = new Binary; - $message = new Message\Generic( - MediaType::of('application/json'), - Str::of('{"foo":"bar🙏"}'), - ); - - $binary = $protocol->encode($message); - - $this->assertInstanceOf(Str::class, $binary); - $this->assertSame( - \pack('n', 16).'application/json'.\pack('N', 17).'{"foo":"bar🙏"}'.\pack('C', 0xCE), - $binary->toString(), - ); - $this->assertSame('ASCII', $binary->encoding()->toString()); - } - - public function testDecode() - { - $protocol = new Binary; - $stream = Stream::ofContent(\pack('n', 16).'application/json'.\pack('N', 17).'{"foo":"bar🙏"}'.\pack('C', 0xCE).'baz'); - - $message = $protocol->decode($stream)->match( - static fn($message) => $message, - static fn() => null, - ); - - $this->assertInstanceOf(Message::class, $message); - $this->assertSame('application/json', $message->mediaType()->toString()); - $this->assertSame('{"foo":"bar🙏"}', $message->content()->toString()); - $this->assertSame('baz', $stream->read(3)->match( - static fn($chunk) => $chunk->toString(), - static fn() => null, - )); // to verify the protocol didn't read that part - } - - public function testReturnNothingWhenEmptyStream() - { - $protocol = new Binary; - - $this->assertNull($protocol->decode(Stream::ofContent(''))->match( - static fn($message) => $message, - static fn() => null, - )); - } - - public function testReturnNothingWhenMessageContentNotOfExceptedSize() - { - $protocol = new Binary; - $stream = Stream::ofContent(\pack('n', 16).'application/json'.\pack('N', 17).'{"foo":"bar🙏'.\pack('C', 0xCE).'baz'); - - $this->assertNull($protocol->decode($stream)->match( - static fn($message) => $message, - static fn() => null, - )); - } - - public function testReturnNothingWhenMessageNotEndedWithSpecialCharacter() - { - $protocol = new Binary; - $stream = Stream::ofContent(\pack('n', 16).'application/json'.\pack('N', 17).'{"foo":"bar🙏"}baz'); - - $this->assertNull($protocol->decode($stream)->match( - static fn($message) => $message, - static fn() => null, - )); - } -} diff --git a/tests/Server/ClientLifecycleTest.php b/tests/Server/ClientLifecycleTest.php deleted file mode 100644 index d6fc79a..0000000 --- a/tests/Server/ClientLifecycleTest.php +++ /dev/null @@ -1,776 +0,0 @@ -createMock(Client::class); - $client - ->expects($this->once()) - ->method('send') - ->with(new ConnectionStart) - ->willReturn(Maybe::just($client)); - $clock = $this->createMock(Clock::class); - $heartbeat = new Timeout(1000); - - $lifecycle = ClientLifecycle::of($client, $clock, $heartbeat)->match( - static fn($lifecycle) => $lifecycle, - static fn() => null, - ); - - $this->assertInstanceOf(ClientLifecycle::class, $lifecycle); - } - - public function testDoNotSendHeartbeatWhenFewerThanHeartbeatPeriod() - { - $client = $this->createMock(Client::class); - $client - ->expects($this->once()) - ->method('send') - ->with(new ConnectionStart) - ->willReturn(Maybe::just($client)); - $clock = $this->createMock(Clock::class); - $heartbeat = new Timeout(1000); - $clock - ->expects($this->exactly(2)) - ->method('now') - ->will($this->onConsecutiveCalls( - $start = $this->createMock(PointInTime::class), - $now = $this->createMock(PointInTime::class), - )); - $now - ->expects($this->once()) - ->method('elapsedSince') - ->with($start) - ->willReturn($period = $this->createMock(ElapsedPeriod::class)); - $period - ->expects($this->once()) - ->method('longerThan') - ->with($heartbeat) - ->willReturn(false); - - $lifecycle = ClientLifecycle::of($client, $clock, $heartbeat)->match( - static fn($lifecycle) => $lifecycle, - static fn() => null, - ); - - $lifecycle = $lifecycle->heartbeat(); - $this->assertInstanceOf(ClientLifecycle::class, $lifecycle); - } - - public function testSilenceFailureToHeartbeatClient() - { - $client = $this->createMock(Client::class); - $client - ->expects($matcher = $this->exactly(2)) - ->method('send') - ->willReturnCallback(function($message) use ($matcher, $client) { - match ($matcher->numberOfInvocations()) { - 1 => $this->assertEquals(new ConnectionStart, $message), - 2 => $this->assertEquals(new Heartbeat, $message), - }; - - return match ($matcher->numberOfInvocations()) { - 1 => Maybe::just($client), - 2 => Maybe::nothing(), - }; - }); - $clock = $this->createMock(Clock::class); - $heartbeat = new Timeout(1000); - $clock - ->expects($this->exactly(2)) - ->method('now') - ->will($this->onConsecutiveCalls( - $start = $this->createMock(PointInTime::class), - $now = $this->createMock(PointInTime::class), - )); - $now - ->expects($this->once()) - ->method('elapsedSince') - ->with($start) - ->willReturn($period = $this->createMock(ElapsedPeriod::class)); - $period - ->expects($this->once()) - ->method('longerThan') - ->with($heartbeat) - ->willReturn(true); - - $lifecycle = ClientLifecycle::of($client, $clock, $heartbeat)->match( - static fn($lifecycle) => $lifecycle, - static fn() => null, - ); - - $lifecycle = $lifecycle->heartbeat(); - $this->assertInstanceOf(ClientLifecycle::class, $lifecycle); - } - - public function testSendHeartbeatWhenLongerThanHeartbeatPeriod() - { - $client = $this->createMock(Client::class); - $client - ->expects($matcher = $this->exactly(2)) - ->method('send') - ->willReturnCallback(function($message) use ($matcher, $client) { - match ($matcher->numberOfInvocations()) { - 1 => $this->assertEquals(new ConnectionStart, $message), - 2 => $this->assertEquals(new Heartbeat, $message), - }; - - return Maybe::just($client); - }); - $clock = $this->createMock(Clock::class); - $heartbeat = new Timeout(1000); - $clock - ->expects($this->exactly(2)) - ->method('now') - ->will($this->onConsecutiveCalls( - $start = $this->createMock(PointInTime::class), - $now = $this->createMock(PointInTime::class), - )); - $now - ->expects($this->once()) - ->method('elapsedSince') - ->with($start) - ->willReturn($period = $this->createMock(ElapsedPeriod::class)); - $period - ->expects($this->once()) - ->method('longerThan') - ->with($heartbeat) - ->willReturn(true); - - $lifecycle = ClientLifecycle::of($client, $clock, $heartbeat)->match( - static fn($lifecycle) => $lifecycle, - static fn() => null, - ); - - $lifecycle = $lifecycle->heartbeat(); - $this->assertInstanceOf(ClientLifecycle::class, $lifecycle); - } - - public function testConsiderGarbageWhenReadingButNoMessage() - { - $client = $this->createMock(Client::class); - $client - ->expects($this->once()) - ->method('send') - ->with(new ConnectionStart) - ->willReturn(Maybe::just($client)); - $client - ->expects($this->once()) - ->method('read') - ->willReturn(Maybe::nothing()); - $clock = $this->createMock(Clock::class); - $heartbeat = new Timeout(1000); - - $lifecycle = ClientLifecycle::of($client, $clock, $heartbeat)->match( - static fn($lifecycle) => $lifecycle, - static fn() => null, - ); - $called = false; - - [$lifecycle] = $lifecycle->notify(static function() use (&$called) { - $called = true; - }, null)->match( - static fn($lifecycle) => $lifecycle, - static fn() => null, - ); - $this->assertNull($lifecycle); - $this->assertFalse($called); - } - - public function testDoNotNotifyWhenNotReceivedMesageOk() - { - $client = $this->createMock(Client::class); - $client - ->expects($this->once()) - ->method('send') - ->with(new ConnectionStart) - ->willReturn(Maybe::just($client)); - $client - ->expects($this->once()) - ->method('read') - ->willReturn(Maybe::just([$client, $this->createMock(Message::class)])); - $clock = $this->createMock(Clock::class); - $heartbeat = new Timeout(1000); - - $lifecycle = ClientLifecycle::of($client, $clock, $heartbeat)->match( - static fn($lifecycle) => $lifecycle, - static fn() => null, - ); - $called = false; - - [$lifecycle2] = $lifecycle->notify(static function() use (&$called) { - $called = true; - }, null)->match( - static fn($either) => $either->match( - static fn($lifecycle) => $lifecycle, - static fn() => null, - ), - static fn() => null, - ); - $this->assertEquals($lifecycle2, $lifecycle); - $this->assertFalse($called); - } - - public function testDoNotNotifyWhenHeartbeatMessage() - { - $client = $this->createMock(Client::class); - $client - ->expects($this->once()) - ->method('send') - ->with(new ConnectionStart) - ->willReturn(Maybe::just($client)); - $client - ->expects($this->once()) - ->method('read') - ->willReturn(Maybe::just([$client, new Heartbeat])); - $clock = $this->createMock(Clock::class); - $heartbeat = new Timeout(1000); - - $lifecycle = ClientLifecycle::of($client, $clock, $heartbeat)->match( - static fn($lifecycle) => $lifecycle, - static fn() => null, - ); - $called = false; - - [$lifecycle2] = $lifecycle->notify(static function() use (&$called) { - $called = true; - }, null)->match( - static fn($either) => $either->match( - static fn($lifecycle) => $lifecycle, - static fn() => null, - ), - static fn() => null, - ); - $this->assertEquals($lifecycle2, $lifecycle); - $this->assertFalse($called); - } - - public function testConfirmConnectionClose() - { - $client = $this->createMock(Client::class); - $client - ->expects($matcher = $this->exactly(2)) - ->method('send') - ->willReturnCallback(function($message) use ($matcher, $client) { - match ($matcher->numberOfInvocations()) { - 1 => $this->assertEquals(new ConnectionStart, $message), - 2 => $this->assertEquals(new ConnectionCloseOk, $message), - }; - - return Maybe::just($client); - }); - $client - ->expects($this->exactly(2)) - ->method('read') - ->will($this->onConsecutiveCalls( - Maybe::just([$client, new ConnectionStartOk]), - Maybe::just([$client, new ConnectionClose]), - )); - $client - ->expects($this->once()) - ->method('close') - ->willReturn(Maybe::just(new SideEffect)); - $clock = $this->createMock(Clock::class); - $heartbeat = new Timeout(1000); - - $lifecycle = ClientLifecycle::of($client, $clock, $heartbeat)->match( - static fn($lifecycle) => $lifecycle, - static fn() => null, - ); - $called = false; - $callback = static function($_, $continuation) use (&$called) { - return $continuation->continue(true); - }; - - [$lifecycle] = $lifecycle->notify($callback, null)->match( - static fn($either) => $either->match( - static fn($lifecycle) => $lifecycle, - static fn() => null, - ), - static fn() => null, - ); // connection start - $this->assertInstanceOf(ClientLifecycle::class, $lifecycle); - [$lifecycle] = $lifecycle->notify($callback, null)->match( - static fn($either) => $either, - static fn() => null, - ); // connection close - $this->assertNull($lifecycle); - $this->assertFalse($called); - } - - public function testNotify() - { - $client = $this->createMock(Client::class); - $client - ->expects($matcher = $this->exactly(3)) - ->method('send') - ->willReturnCallback(function($message) use ($matcher, $client) { - match ($matcher->numberOfInvocations()) { - 1 => $this->assertEquals(new ConnectionStart, $message), - 2 => $this->assertEquals(new MessageReceived, $message), - 3 => $this->assertEquals(new MessageReceived, $message), - }; - - return Maybe::just($client); - }); - $client - ->expects($this->exactly(3)) - ->method('read') - ->will($this->onConsecutiveCalls( - Maybe::just([$client, new ConnectionStartOk]), - Maybe::just([$client, $message = $this->createMock(Message::class)]), - Maybe::just([$client, $message]), - )); - $clock = $this->createMock(Clock::class); - $heartbeat = new Timeout(1000); - - $lifecycle = ClientLifecycle::of($client, $clock, $heartbeat)->match( - static fn($lifecycle) => $lifecycle, - static fn() => null, - ); - $called = 0; - $callback = function($a, $b) use (&$called, $message) { - ++$called; - $this->assertSame($message, $a); - $this->assertInstanceOf(Continuation::class, $b); - - return $b; - }; - - [$lifecycle] = $lifecycle->notify($callback, null)->match( - static fn($either) => $either->match( - static fn($lifecycle) => $lifecycle, - static fn() => null, - ), - static fn() => null, - ); // connection start - $this->assertInstanceOf(ClientLifecycle::class, $lifecycle); - [$lifecycle] = $lifecycle->notify($callback, null)->match( - static fn($either) => $either->match( - static fn($lifecycle) => $lifecycle, - static fn() => null, - ), - static fn() => null, - ); // message 1 - $this->assertInstanceOf(ClientLifecycle::class, $lifecycle); - [$lifecycle] = $lifecycle->notify($callback, null)->match( - static fn($either) => $either->match( - static fn($lifecycle) => $lifecycle, - static fn() => null, - ), - static fn() => null, - ); // message 2 - $this->assertInstanceOf(ClientLifecycle::class, $lifecycle); - $this->assertSame(2, $called); - } - - public function testDoNotNotifyWhenPendingCloseOkButNoConfirmation() - { - $client = $this->createMock(Client::class); - $client - ->expects($matcher = $this->exactly(3)) - ->method('send') - ->willReturnCallback(function($message) use ($matcher, $client) { - match ($matcher->numberOfInvocations()) { - 1 => $this->assertEquals(new ConnectionStart, $message), - 2 => $this->assertEquals(new MessageReceived, $message), - 3 => $this->assertEquals(new ConnectionClose, $message), - }; - - return Maybe::just($client); - }); - $client - ->expects($this->exactly(3)) - ->method('read') - ->will($this->onConsecutiveCalls( - Maybe::just([$client, new ConnectionStartOk]), - Maybe::just([$client, $this->createMock(Message::class)]), - Maybe::just([$client, $this->createMock(Message::class)]), - )); - $clock = $this->createMock(Clock::class); - $heartbeat = new Timeout(1000); - - $lifecycle = ClientLifecycle::of($client, $clock, $heartbeat)->match( - static fn($lifecycle) => $lifecycle, - static fn() => null, - ); - $callback = static function($_, $continuation, $called) { - return $continuation->close(++$called); - }; - - [$lifecycle, $called] = $lifecycle->notify($callback, 0)->match( - static fn($either) => $either->match( - static fn($lifecycle) => $lifecycle, - static fn() => null, - ), - static fn() => null, - ); // connection start - $this->assertInstanceOf(ClientLifecycle::class, $lifecycle); - [$lifecycle, $called] = $lifecycle->notify($callback, $called)->match( - static fn($either) => $either->match( - static fn($lifecycle) => $lifecycle, - static fn() => null, - ), - static fn() => null, - ); // message 1 - $this->assertInstanceOf(ClientLifecycle::class, $lifecycle); - [$lifecycle, $called] = $lifecycle->notify($callback, $called)->match( - static fn($either) => $either->match( - static fn($lifecycle) => $lifecycle, - static fn() => null, - ), - static fn() => null, - ); // message 2 - $this->assertInstanceOf(ClientLifecycle::class, $lifecycle); - $this->assertSame(1, $called); - } - - public function testCloseConfirmation() - { - $client = $this->createMock(Client::class); - $client - ->expects($this->once()) - ->method('close') - ->willReturn(Maybe::just(new SideEffect)); - $client - ->expects($matcher = $this->exactly(3)) - ->method('send') - ->willReturnCallback(function($message) use ($matcher, $client) { - match ($matcher->numberOfInvocations()) { - 1 => $this->assertEquals(new ConnectionStart, $message), - 2 => $this->assertEquals(new MessageReceived, $message), - 3 => $this->assertEquals(new ConnectionClose, $message), - }; - - return Maybe::just($client); - }); - $client - ->expects($this->exactly(3)) - ->method('read') - ->will($this->onConsecutiveCalls( - Maybe::just([$client, new ConnectionStartOk]), - Maybe::just([$client, $this->createMock(Message::class)]), - Maybe::just([$client, new ConnectionCloseOk]), - )); - $clock = $this->createMock(Clock::class); - $heartbeat = new Timeout(1000); - - $lifecycle = ClientLifecycle::of($client, $clock, $heartbeat)->match( - static fn($lifecycle) => $lifecycle, - static fn() => null, - ); - $callback = static function($_, $continuation, $called) { - return $continuation->close(++$called); - }; - - [$lifecycle, $called] = $lifecycle->notify($callback, 0)->match( - static fn($either) => $either->match( - static fn($lifecycle) => $lifecycle, - static fn() => null, - ), - static fn() => null, - ); // connection start - $this->assertInstanceOf(ClientLifecycle::class, $lifecycle); - [$lifecycle, $called] = $lifecycle->notify($callback, $called)->match( - static fn($either) => $either->match( - static fn($lifecycle) => $lifecycle, - static fn() => null, - ), - static fn() => null, - ); // message 1 - $this->assertInstanceOf(ClientLifecycle::class, $lifecycle); - [$lifecycle] = $lifecycle->notify($callback, $called)->match( - static fn($either) => $either, - static fn() => null, - ); // connection close ok - $this->assertNull($lifecycle); - $this->assertSame(1, $called); - } - - public function testCloseConfirmationEvenWhenError() - { - $client = $this->createMock(Client::class); - $client - ->expects($matcher = $this->exactly(3)) - ->method('send') - ->willReturnCallback(function($message) use ($matcher, $client) { - match ($matcher->numberOfInvocations()) { - 1 => $this->assertEquals(new ConnectionStart, $message), - 2 => $this->assertEquals(new MessageReceived, $message), - 3 => $this->assertEquals(new ConnectionClose, $message), - }; - - return Maybe::just($client); - }); - $client - ->expects($this->exactly(3)) - ->method('read') - ->will($this->onConsecutiveCalls( - Maybe::just([$client, new ConnectionStartOk]), - Maybe::just([$client, $this->createMock(Message::class)]), - Maybe::just([$client, new ConnectionCloseOk]), - )); - $client - ->expects($this->once()) - ->method('close') - ->willReturn(Maybe::nothing()); - $clock = $this->createMock(Clock::class); - $heartbeat = new Timeout(1000); - - $lifecycle = ClientLifecycle::of($client, $clock, $heartbeat)->match( - static fn($lifecycle) => $lifecycle, - static fn() => null, - ); - $callback = static function($_, $continuation, $called) { - return $continuation->close(++$called); - }; - - [$lifecycle, $called] = $lifecycle->notify($callback, 0)->match( - static fn($either) => $either->match( - static fn($lifecycle) => $lifecycle, - static fn() => null, - ), - static fn() => null, - ); // connection start - $this->assertInstanceOf(ClientLifecycle::class, $lifecycle); - [$lifecycle, $called] = $lifecycle->notify($callback, $called)->match( - static fn($either) => $either->match( - static fn($lifecycle) => $lifecycle, - static fn() => null, - ), - static fn() => null, - ); // message 1 - $this->assertInstanceOf(ClientLifecycle::class, $lifecycle); - [$lifecycle] = $lifecycle->notify($callback, $called)->match( - static fn($either) => $either, - static fn() => null, - ); // connection close ok - $this->assertNull($lifecycle); - $this->assertSame(1, $called); - } - - /** - * @dataProvider protocolMessages - */ - public function testNeverNotifyProtocolMessages($message) - { - $client = $this->createMock(Client::class); - $client - ->expects($matcher = $this->exactly(2)) - ->method('send') - ->willReturnCallback(function($message) use ($matcher, $client) { - match ($matcher->numberOfInvocations()) { - 1 => $this->assertEquals(new ConnectionStart, $message), - 2 => $this->assertEquals(new MessageReceived, $message), - }; - - return Maybe::just($client); - }); - $client - ->expects($this->exactly(3)) - ->method('read') - ->will($this->onConsecutiveCalls( - Maybe::just([$client, new ConnectionStartOk]), - Maybe::just([$client, $this->createMock(Message::class)]), - Maybe::just([$client, $message]), - )); - $clock = $this->createMock(Clock::class); - $heartbeat = new Timeout(1000); - - $lifecycle = ClientLifecycle::of($client, $clock, $heartbeat)->match( - static fn($lifecycle) => $lifecycle, - static fn() => null, - ); - $callback = static function($a, $b, $called) { - return $b->continue(++$called); - }; - - [$lifecycle, $called] = $lifecycle->notify($callback, 0)->match( - static fn($either) => $either->match( - static fn($lifecycle) => $lifecycle, - static fn() => null, - ), - static fn() => null, - ); // connection start - $this->assertInstanceOf(ClientLifecycle::class, $lifecycle); - [$lifecycle, $called] = $lifecycle->notify($callback, $called)->match( - static fn($either) => $either->match( - static fn($lifecycle) => $lifecycle, - static fn() => null, - ), - static fn() => null, - ); // message - $this->assertInstanceOf(ClientLifecycle::class, $lifecycle); - [$lifecycle, $called] = $lifecycle->notify($callback, $called)->match( - static fn($either) => $either->match( - static fn($lifecycle) => $lifecycle, - static fn() => null, - ), - static fn() => null, - ); // protocol message - $this->assertInstanceOf(ClientLifecycle::class, $lifecycle); - $this->assertSame(1, $called); - } - - public function testShutdown() - { - $client = $this->createMock(Client::class); - $client - ->expects($this->once()) - ->method('close') - ->willReturn(Maybe::just(new SideEffect)); - $client - ->expects($matcher = $this->exactly(2)) - ->method('send') - ->willReturnCallback(function($message) use ($matcher, $client) { - match ($matcher->numberOfInvocations()) { - 1 => $this->assertEquals(new ConnectionStart, $message), - 2 => $this->assertEquals(new ConnectionClose, $message), - }; - - return Maybe::just($client); - }); - $client - ->expects($this->once()) - ->method('read') - ->willReturn(Maybe::just([$client, new ConnectionCloseOk])); - $clock = $this->createMock(Clock::class); - $heartbeat = new Timeout(1000); - - $lifecycle = ClientLifecycle::of($client, $clock, $heartbeat)->match( - static fn($lifecycle) => $lifecycle, - static fn() => null, - ); - $called = false; - - $lifecycle = $lifecycle->shutdown()->match( - static fn($lifecycle) => $lifecycle, - static fn() => null, - ); - $this->assertInstanceOf(ClientLifecycle::class, $lifecycle); - $lifecycle = $lifecycle->notify(static function() use (&$called) { - $called = true; - }, null)->match( - static fn($lifecycle) => $lifecycle, - static fn() => null, - ); - $this->assertFalse($called); - $this->assertNull($lifecycle); - } - - public function testShutdownEvenWhenErrorWhenClosingConnection() - { - $client = $this->createMock(Client::class); - $client - ->expects($this->once()) - ->method('close') - ->willReturn(Maybe::nothing()); - $client - ->expects($matcher = $this->exactly(2)) - ->method('send') - ->willReturnCallback(function($message) use ($matcher, $client) { - match ($matcher->numberOfInvocations()) { - 1 => $this->assertEquals(new ConnectionStart, $message), - 2 => $this->assertEquals(new ConnectionClose, $message), - }; - - return Maybe::just($client); - }); - $client - ->expects($this->once()) - ->method('read') - ->willReturn(Maybe::just([$client, new ConnectionCloseOk])); - $clock = $this->createMock(Clock::class); - $heartbeat = new Timeout(1000); - - $lifecycle = ClientLifecycle::of($client, $clock, $heartbeat)->match( - static fn($lifecycle) => $lifecycle, - static fn() => null, - ); - $called = false; - - $lifecycle = $lifecycle->shutdown()->match( - static fn($lifecycle) => $lifecycle, - static fn() => null, - ); - $this->assertInstanceOf(ClientLifecycle::class, $lifecycle); - $lifecycle = $lifecycle->notify(static function() use (&$called) { - $called = true; - }, null)->match( - static fn($lifecycle) => $lifecycle, - static fn() => null, - ); - $this->assertFalse($called); - $this->assertNull($lifecycle); - } - - public function testConsiderToBeGarbageCollectedWhenFailToClose() - { - $client = $this->createMock(Client::class); - $client - ->expects($matcher = $this->exactly(2)) - ->method('send') - ->willReturnCallback(function($message) use ($matcher, $client) { - match ($matcher->numberOfInvocations()) { - 1 => $this->assertEquals(new ConnectionStart, $message), - 2 => $this->assertEquals(new ConnectionClose, $message), - }; - - return match ($matcher->numberOfInvocations()) { - 1 => Maybe::just($client), - 2 => Maybe::nothing(), - }; - }); - $clock = $this->createMock(Clock::class); - $heartbeat = new Timeout(1000); - - $lifecycle = ClientLifecycle::of($client, $clock, $heartbeat)->match( - static fn($lifecycle) => $lifecycle, - static fn() => null, - ); - - $this->assertNull($lifecycle->shutdown()->match( - static fn($lifecycle) => $lifecycle, - static fn() => null, - )); - } - - public static function protocolMessages(): array - { - return [ - [new ConnectionStart], - [new ConnectionStartOk], - [new ConnectionCloseOk], - [new Heartbeat], - [new MessageReceived], - ]; - } -} diff --git a/tests/Server/UnixTest.php b/tests/Server/UnixTest.php deleted file mode 100644 index 53497e1..0000000 --- a/tests/Server/UnixTest.php +++ /dev/null @@ -1,362 +0,0 @@ -assertInstanceOf( - Server::class, - new Unix( - $this->createMock(Sockets::class), - $this->createMock(Protocol::class), - $this->createMock(Clock::class), - $this->createMock(Signals::class), - Address::of('/tmp/foo.sock'), - new Timeout(1000), - ), - ); - } - - public function testFailWhenCantOpenTheServer() - { - $receive = new Unix( - $sockets = $this->createMock(Sockets::class), - $this->createMock(Protocol::class), - $this->createMock(Clock::class), - $this->createMock(Signals::class), - Address::of('/tmp/foo.sock'), - new Timeout(1000), - ); - $sockets - ->expects($this->once()) - ->method('open') - ->willReturn(Maybe::nothing()); - - $e = $receive(null, static function($_, $continuation) { - return $continuation; - })->match( - static fn() => null, - static fn($e) => $e, - ); - - $this->assertInstanceOf(Server\UnableToStart::class, $e); - } - - public function testStopWhenNoActivityInGivenPeriod() - { - $os = Factory::build(); - @\unlink($os->status()->tmp()->toString().'/innmind/ipc/server.sock'); - - $listen = new Unix( - $os->sockets(), - new Protocol\Binary, - $os->clock(), - $os->process()->signals(), - Address::of($os->status()->tmp()->toString().'/innmind/ipc/server'), - new Timeout(100), - new Timeout(1000), - ); - - $this->assertNull($listen(null, static function($_, $continuation) { - return $continuation; - })->match( - static fn() => null, - static fn($e) => $e, - )); - } - - public function testInstallSignalsHandlerOnlyWhenStartingTheServer() - { - $signals = $this->createMock(Signals::class); - - $server = new Unix( - $sockets = $this->createMock(Sockets::class), - $this->createMock(Protocol::class), - $this->createMock(Clock::class), - $signals, - $address = Address::of('/tmp/foo.sock'), - $heartbeat = new Timeout(10), - $this->createMock(ElapsedPeriod::class), - ); - $sockets - ->expects($this->any()) - ->method('open') - ->willReturn(ServerSocket\Unix::recoverable($address)->map( - static fn($server) => IOServer::of( - static fn() => null, - $server, - ), - )); - $sockets - ->expects($this->any()) - ->method('watch') - ->with($heartbeat) - ->willReturn($watch = $this->createMock(Watch::class)); - $watch - ->expects($this->any()) - ->method('forRead') - ->will($this->returnSelf()); - $watch - ->expects($this->any()) - ->method('__invoke') - ->will($this->throwException($expected = new \Exception)); - - $signals - ->expects($matcher = $this->exactly(12)) - ->method('listen') - ->willReturnCallback(function($signal, $listen) use ($matcher) { - match ($matcher->numberOfInvocations()) { - 1 => $this->assertSame(Signal::hangup, $signal), - 2 => $this->assertSame(Signal::interrupt, $signal), - 3 => $this->assertSame(Signal::abort, $signal), - 4 => $this->assertSame(Signal::terminate, $signal), - 5 => $this->assertSame(Signal::terminalStop, $signal), - 6 => $this->assertSame(Signal::alarm, $signal), - 7 => $this->assertSame(Signal::hangup, $signal), - 8 => $this->assertSame(Signal::interrupt, $signal), - 9 => $this->assertSame(Signal::abort, $signal), - 10 => $this->assertSame(Signal::terminate, $signal), - 11 => $this->assertSame(Signal::terminalStop, $signal), - 12 => $this->assertSame(Signal::alarm, $signal), - }; - - $listen(); - }); - - try { - $server(null, static function($_, $continuation) { - return $continuation->stop(null); - }); - } catch (\Exception $e) { - $this->assertSame($expected, $e); - } - - try { - // check signals are not registered twice - $server(null, static function($_, $continuation) { - return $continuation->stop(null); - }); - } catch (\Exception $e) { - $this->assertSame($expected, $e); - } - } - - public function testShutdownProcess() - { - $os = Factory::build(); - @\unlink($os->status()->tmp()->toString().'/innmind/ipc/server.sock'); - $processes = $os->control()->processes(); - $server = $processes->execute( - Command::foreground('php') - ->withArgument('fixtures/long-client.php') - ->withEnvironment('TMPDIR', $os->status()->tmp()->toString()) - ->withEnvironment('PATH', $_SERVER['PATH']), - ); - - $listen = new Unix( - $os->sockets(), - new Protocol\Binary, - $os->clock(), - $os->process()->signals(), - Address::of($os->status()->tmp()->toString().'/innmind/ipc/server'), - new Timeout(100), - new Timeout(10000), - ); - - $this->assertNull($listen(null, static function($_, $continuation) { - return $continuation->stop(null); - })->match( - static fn() => null, - static fn($e) => $e, - )); - } - - public function testClientClose() - { - $os = Factory::build(); - @\unlink($os->status()->tmp()->toString().'/innmind/ipc/server.sock'); - $processes = $os->control()->processes(); - $client = $processes->execute( - Command::foreground('php') - ->withArgument('fixtures/long-client.php') - ->withEnvironment('TMPDIR', $os->status()->tmp()->toString()) - ->withEnvironment('PATH', $_SERVER['PATH']), - ); - - $listen = new Unix( - $os->sockets(), - new Protocol\Binary, - $os->clock(), - $os->process()->signals(), - Address::of($os->status()->tmp()->toString().'/innmind/ipc/server'), - new Timeout(100), - new Timeout(3000), - ); - - $this->assertNull($listen(null, static function($message, $continuation) { - return $continuation->close(null); - })->match( - static fn() => null, - static fn($e) => $e, - )); - } - - public function testBidirectionalHeartbeat() - { - $os = Factory::build(); - @\unlink($os->status()->tmp()->toString().'/innmind/ipc/server.sock'); - $processes = $os->control()->processes(); - $processes->execute( - Command::foreground('php') - ->withArgument('fixtures/long-client.php') - ->withEnvironment('TMPDIR', $os->status()->tmp()->toString()) - ->withEnvironment('PATH', $_SERVER['PATH']), - ); - - $listen = new Unix( - $os->sockets(), - new Protocol\Binary, - $os->clock(), - $os->process()->signals(), - Address::of($os->status()->tmp()->toString().'/innmind/ipc/server'), - new Timeout(100), - new Timeout(3000), - ); - - $this->assertNull($listen(null, static function($_, $continuation) { - return $continuation; - })->match( - static fn() => null, - static fn($e) => $e, - )); - // only test coverage can show that heartbeat messages are sent - } - - public function testEmergencyShutdown() - { - $os = Factory::build(); - @\unlink($os->status()->tmp()->toString().'/innmind/ipc/server.sock'); - $processes = $os->control()->processes(); - $processes->execute( - Command::foreground('php') - ->withArgument('fixtures/long-client.php') - ->withEnvironment('TMPDIR', $os->status()->tmp()->toString()) - ->withEnvironment('PATH', $_SERVER['PATH']), - ); - - $listen = new Unix( - $os->sockets(), - new Protocol\Binary, - $os->clock(), - $os->process()->signals(), - Address::of($os->status()->tmp()->toString().'/innmind/ipc/server'), - new Timeout(100), - new Timeout(3000), - ); - - $this->expectException(\Exception::class); - - $listen(null, static function() { - throw new \Exception; - }); - // only test coverage can show that show that connections are closed on - // user exception - } - - public function testRespondToClientClose() - { - $os = Factory::build(); - @\unlink($os->status()->tmp()->toString().'/innmind/ipc/server.sock'); - $processes = $os->control()->processes(); - $client = $processes->execute( - Command::foreground('php') - ->withArgument('fixtures/self-closing-client.php') - ->withEnvironment('TMPDIR', $os->status()->tmp()->toString()) - ->withEnvironment('PATH', $_SERVER['PATH']), - ); - - $listen = new Unix( - $os->sockets(), - new Protocol\Binary, - $os->clock(), - $os->process()->signals(), - Address::of($os->status()->tmp()->toString().'/innmind/ipc/server'), - new Timeout(100), - new Timeout(3000), - ); - - $this->assertNull($listen(null, static function($_, $continuation) { - return $continuation; - })->match( - static fn() => null, - static fn($e) => $e, - )); - $client->wait(); - $this->assertSame('', $client->output()->toString()); - } - - public function testCarriedValueIsReturnedWhenStopped() - { - $os = Factory::build(); - @\unlink($os->status()->tmp()->toString().'/innmind/ipc/server.sock'); - $processes = $os->control()->processes(); - $processes->execute( - Command::foreground('php') - ->withArgument('fixtures/long-client-multi-message.php') - ->withEnvironment('TMPDIR', $os->status()->tmp()->toString()) - ->withEnvironment('PATH', $_SERVER['PATH']), - ); - - $listen = new Unix( - $os->sockets(), - new Protocol\Binary, - $os->clock(), - $os->process()->signals(), - Address::of($os->status()->tmp()->toString().'/innmind/ipc/server'), - new Timeout(100), - new Timeout(3000), - ); - - $carry = $listen(0, static function($_, $continuation, $carry) { - if ($carry === 2) { - return $continuation->stop($carry + 1); - } - - return $continuation->continue($carry + 1); - })->match( - static fn($carry) => $carry, - static fn() => null, - ); - - $this->assertSame(3, $carry); - } -}