From c3a68b24b6930976493466f424a905a341df5af3 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 26 Oct 2025 13:44:47 +0100 Subject: [PATCH 01/34] fix blackbox bin --- blackbox.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/blackbox.php b/blackbox.php index ff9d705..2588bdc 100644 --- a/blackbox.php +++ b/blackbox.php @@ -11,7 +11,7 @@ Application::new($argv) ->when( - \get_env('ENABLE_COVERAGE') !== false, + \getenv('ENABLE_COVERAGE') !== false, static fn(Application $app) => $app ->scenariiPerProof(1) ->codeCoverage( From f4808790b5631dde0d94129400801227004b6493 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 26 Oct 2025 14:06:43 +0100 Subject: [PATCH 02/34] setup api to test time and processes --- composer.json | 3 +- proofs/.gitkeep | 0 proofs/clock.php | 26 +++++++++++++ proofs/processes.php | 54 ++++++++++++++++++++++++++ psalm.xml | 3 ++ src/.gitkeep | 0 src/Config.php | 35 +++++++++++++++++ src/Factory.php | 92 ++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 212 insertions(+), 1 deletion(-) delete mode 100644 proofs/.gitkeep create mode 100644 proofs/clock.php create mode 100644 proofs/processes.php delete mode 100644 src/.gitkeep create mode 100644 src/Config.php create mode 100644 src/Factory.php diff --git a/composer.json b/composer.json index 7e065fd..9401d18 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,8 @@ "issues": "http://github.com/innmind/testing/issues" }, "require": { - "php": "~8.2" + "php": "~8.2", + "innmind/foundation": "~1.10" }, "autoload": { "psr-4": { diff --git a/proofs/.gitkeep b/proofs/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/proofs/clock.php b/proofs/clock.php new file mode 100644 index 0000000..d96dcc3 --- /dev/null +++ b/proofs/clock.php @@ -0,0 +1,26 @@ +startClockAt($point->format(Format::iso8601())) + ->build(); + $now = $os->clock()->now(); + + $assert->true($now->aheadOf($point)); + $assert->true( + $os->clock()->now()->aheadOf($now), + ); + }, + ); +}; diff --git a/proofs/processes.php b/proofs/processes.php new file mode 100644 index 0000000..c490b09 --- /dev/null +++ b/proofs/processes.php @@ -0,0 +1,54 @@ +handleExecutable( + 'foo', + static function( + $command, + $builder, + ) use ($assert, $output) { + $assert->same( + "foo 'display' '--option'", + $command->toString(), + ); + + return $builder->success(\array_map( + static fn($chunk) => [$chunk, 'output'], + $output, + )); + }, + ) + ->build(); + + $assert->same( + $output, + $os + ->control() + ->processes() + ->execute( + Command::foreground('bin') + ->withArgument('display') + ->withOption('option'), + ) + ->unwrap() + ->output() + ->map(static fn($chunk) => $chunk->data()->toString()) + ->toList(), + ); + }, + ); + + // todo prove the executables have access to the new os by checking the filesystem +}; diff --git a/psalm.xml b/psalm.xml index bd44196..38edc2c 100644 --- a/psalm.xml +++ b/psalm.xml @@ -14,4 +14,7 @@ + + + diff --git a/src/.gitkeep b/src/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/Config.php b/src/Config.php new file mode 100644 index 0000000..5ace34e --- /dev/null +++ b/src/Config.php @@ -0,0 +1,35 @@ + $executables + */ + public function __construct( + private ?PointInTime $start, + private Map $executables, + ) { + } + + public function __invoke(OSConfig $config): OSConfig + { + return $config; + } +} diff --git a/src/Factory.php b/src/Factory.php new file mode 100644 index 0000000..9ec708b --- /dev/null +++ b/src/Factory.php @@ -0,0 +1,92 @@ + $executables + */ + private function __construct( + private ?PointInTime $start, + private Map $executables, + ) { + } + + /** + * @psalm-pure + */ + public static function new(): self + { + return new self( + null, + Map::of(), + ); + } + + /** + * @psalm-mutation-free + * + * @param non-empty-string $date + */ + #[\NoDiscard] + public function startClockAt(string $date): self + { + return new self( + PointInTime::at(new \DateTimeImmutable($date)), + $this->executables, + ); + } + + /** + * @psalm-mutation-free + * + * @param non-empty-string $bin + * @param callable(Command, ProcessBuilder, OperatingSystem): ProcessBuilder $builder + */ + #[\NoDiscard] + public function handleExecutable( + string $bin, + callable $builder, + ): self { + return new self( + $this->start, + ($this->executables)( + $bin, + $builder, + ), + ); + } + + public function build(): OperatingSystem + { + $os = OSFactory::build(); + $config = new Config( + $this->start, + $this->executables->map(static function($_, $build) use (&$os) { + return static function(Command $command, ProcessBuilder $builder) use ($build, &$os) { + return $build($command, $builder, $os); + }; + }), + ); + // The new $os is not directly returned in order for callables to have + // the newly built OS injected at runtime. + $os = $os->map($config); + + return $os; + } +} From fc0daadaa7572c2e58b094ca2250f57353f15fc2 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 23 Nov 2025 17:57:29 +0100 Subject: [PATCH 03/34] require php 8.4 --- .github/workflows/ci.yml | 10 ++++------ composer.json | 2 +- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 189105d..779f162 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,13 +4,11 @@ on: [push, pull_request] jobs: blackbox: - uses: innmind/github-workflows/.github/workflows/black-box-matrix.yml@main + uses: innmind/github-workflows/.github/workflows/black-box-matrix.yml@next coverage: - uses: innmind/github-workflows/.github/workflows/coverage-matrix.yml@main + uses: innmind/github-workflows/.github/workflows/coverage-matrix.yml@next secrets: inherit psalm: - uses: innmind/github-workflows/.github/workflows/psalm-matrix.yml@main + uses: innmind/github-workflows/.github/workflows/psalm-matrix.yml@next cs: - uses: innmind/github-workflows/.github/workflows/cs.yml@main - with: - php-version: '8.2' + uses: innmind/github-workflows/.github/workflows/cs.yml@next diff --git a/composer.json b/composer.json index 9401d18..a2144bc 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,7 @@ "issues": "http://github.com/innmind/testing/issues" }, "require": { - "php": "~8.2", + "php": "~8.4", "innmind/foundation": "~1.10" }, "autoload": { From b74955be15ad765d7e4786a04aa02a9042cff6ec Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Mon, 24 Nov 2025 15:22:51 +0100 Subject: [PATCH 04/34] implement simulated clock --- composer.json | 35 +++++++- proofs/clock.php | 8 +- src/Config.php | 35 +++++--- src/Factory.php | 12 +-- src/Machine/ProcessBuilder.php | 141 +++++++++++++++++++++++++++++++++ src/Machine/State/Clock.php | 63 +++++++++++++++ 6 files changed, 272 insertions(+), 22 deletions(-) create mode 100644 src/Machine/ProcessBuilder.php create mode 100644 src/Machine/State/Clock.php diff --git a/composer.json b/composer.json index a2144bc..b9a7078 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,40 @@ }, "require": { "php": "~8.4", - "innmind/foundation": "~1.10" + "innmind/foundation": "dev-next", + "innmind/immutable": "dev-next", + "innmind/http": "dev-next", + "innmind/operating-system": "dev-next", + "innmind/reflection": "dev-next", + "innmind/math": "dev-next", + "innmind/xml": "dev-next", + "innmind/html": "dev-next", + "innmind/json": "dev-next", + "innmind/hash": "dev-next", + "innmind/encoding": "dev-next", + "innmind/specification": "~4.1", + "innmind/http-transport": "dev-next", + "innmind/filesystem": "dev-next", + "innmind/file-watch": "dev-next", + "innmind/time-continuum": "dev-next", + "innmind/time-warp": "dev-next", + "innmind/server-control": "dev-next", + "innmind/server-status": "dev-next", + "innmind/url": "dev-next", + "innmind/validation": "dev-next", + "innmind/media-type": "dev-next", + "innmind/io": "dev-next", + "innmind/ip": "dev-next", + "innmind/http-server": "dev-next", + "innmind/cli": "dev-next", + "innmind/router": "dev-next", + "innmind/async": "dev-next", + "innmind/signals": "dev-next", + "innmind/url-template": "dev-next", + "innmind/stack-trace": "dev-next", + "innmind/graphviz": "dev-next", + "innmind/colour": "dev-next", + "formal/access-layer": "dev-next" }, "autoload": { "psr-4": { diff --git a/proofs/clock.php b/proofs/clock.php index d96dcc3..6cf7265 100644 --- a/proofs/clock.php +++ b/proofs/clock.php @@ -2,7 +2,7 @@ declare(strict_types = 1); use Innmind\Testing\Factory; -use Innmind\TimeContinuum\Format; +use Innmind\TimeContinuum\Period; use Fixtures\Innmind\TimeContinuum\PointInTime; return static function() { @@ -13,11 +13,13 @@ ), static function($assert, $point) { $os = Factory::new() - ->startClockAt($point->format(Format::iso8601())) + ->startClockAt($point) ->build(); - $now = $os->clock()->now(); + $os->process()->halt(Period::microsecond(1)); + $now = $os->clock()->now(); $assert->true($now->aheadOf($point)); + $os->process()->halt(Period::microsecond(1)); $assert->true( $os->clock()->now()->aheadOf($now), ); diff --git a/src/Config.php b/src/Config.php index 5ace34e..89301c0 100644 --- a/src/Config.php +++ b/src/Config.php @@ -3,16 +3,21 @@ namespace Innmind\Testing; -use Innmind\OperatingSystem\{ - OperatingSystem, - Config as OSConfig, +use Innmind\Testing\Machine\{ + ProcessBuilder, + State\Clock as SimulatedClock, }; -use Innmind\Server\Control\{ - Server\Command, - Servers\Mock\ProcessBuilder, +use Innmind\OperatingSystem\Config as OSConfig; +use Innmind\Server\Control\Server\Command; +use Innmind\TimeWarp\Halt; +use Innmind\TimeContinuum\{ + Clock, + PointInTime, +}; +use Innmind\Immutable\{ + Map, + Attempt, }; -use Innmind\TimeContinuum\PointInTime; -use Innmind\Immutable\Map; /** * @internal @@ -20,7 +25,7 @@ final class Config { /** - * @param Map $executables + * @param Map $executables */ public function __construct( private ?PointInTime $start, @@ -30,6 +35,16 @@ public function __construct( public function __invoke(OSConfig $config): OSConfig { - return $config; + $clock = $config->clock(); + $simulatedClock = SimulatedClock::of( + $clock, + $this->start ?? $clock->now(), + ); + + return $config + ->withClock(Clock::via(static fn() => $simulatedClock->now())) + ->haltProcessVia(Halt::via(static fn($period) => Attempt::result( + $simulatedClock->halt($period), + ))); } } diff --git a/src/Factory.php b/src/Factory.php index 9ec708b..6cb75bc 100644 --- a/src/Factory.php +++ b/src/Factory.php @@ -3,14 +3,12 @@ namespace Innmind\Testing; +use Innmind\Testing\Machine\ProcessBuilder; use Innmind\OperatingSystem\{ OperatingSystem, Factory as OSFactory, }; -use Innmind\Server\Control\{ - Server\Command, - Servers\Mock\ProcessBuilder, -}; +use Innmind\Server\Control\Server\Command; use Innmind\TimeContinuum\PointInTime; use Innmind\Immutable\Map; @@ -40,14 +38,12 @@ public static function new(): self /** * @psalm-mutation-free - * - * @param non-empty-string $date */ #[\NoDiscard] - public function startClockAt(string $date): self + public function startClockAt(PointInTime $date): self { return new self( - PointInTime::at(new \DateTimeImmutable($date)), + $date, $this->executables, ); } diff --git a/src/Machine/ProcessBuilder.php b/src/Machine/ProcessBuilder.php new file mode 100644 index 0000000..7f84f72 --- /dev/null +++ b/src/Machine/ProcessBuilder.php @@ -0,0 +1,141 @@ + $pid + */ + private function __construct( + private int $pid, + private Success|Signaled|TimedOut|Failed $result, + ) { + } + + /** + * @internal + * + * @param int<2, max> $pid + */ + public static function new(int $pid): self + { + return new self($pid, new Success(Sequence::of())); + } + + /** + * @param Sequence|list $output + */ + #[\NoDiscard] + public function success(Sequence|array|null $output = null): self + { + return new self( + $this->pid, + new Success(self::output($output)), + ); + } + + /** + * @param Sequence|list $output + */ + #[\NoDiscard] + public function signaled(Sequence|array|null $output = null): self + { + return new self( + $this->pid, + new Signaled(self::output($output)), + ); + } + + /** + * @param Sequence|list $output + */ + #[\NoDiscard] + public function timedOut(Sequence|array|null $output = null): self + { + return new self( + $this->pid, + new TimedOut(self::output($output)), + ); + } + + /** + * @param int<1, 255> $exitCode + * @param Sequence|list $output + */ + #[\NoDiscard] + public function failed(int $exitCode = 1, Sequence|array|null $output = null): self + { + return new self( + $this->pid, + new Failed( + new ExitCode($exitCode), + self::output($output), + ), + ); + } + + /** + * @internal + */ + #[\NoDiscard] + public function build(): Process + { + $pid = $this->pid; + $result = $this->result; + + /** + * This a trick to not expose any mock contructor on the Process class. + * + * @psalm-suppress PossiblyNullFunctionCall + * @psalm-suppress MixedReturnStatement + * @psalm-suppress InaccessibleMethod + */ + return (\Closure::bind( + static fn() => new Process(new Mock($pid, $result)), + null, + Process::class, + ))(); + } + + /** + * @param Sequence|list $output + * + * @return Sequence + */ + private static function output(Sequence|array|null $output = null): Sequence + { + if (\is_null($output)) { + return Sequence::of(); + } + + if (\is_array($output)) { + return Sequence::of(...$output)->map(static fn($pair) => Chunk::of( + Str::of($pair[0]), + match ($pair[1]) { + 'output' => Type::output, + 'error' => Type::error, + }, + )); + } + + return $output; + } +} diff --git a/src/Machine/State/Clock.php b/src/Machine/State/Clock.php new file mode 100644 index 0000000..813b928 --- /dev/null +++ b/src/Machine/State/Clock.php @@ -0,0 +1,63 @@ +now(); + + if ($now->aheadOf($realNow)) { + $delta = $now->elapsedSince($realNow)->asPeriod(); + $move = static fn(PointInTime $now): PointInTime => $now->goForward($delta); + } else { + $delta = $realNow->elapsedSince($now)->asPeriod(); + $move = static fn(PointInTime $now): PointInTime => $now->goBack($delta); + } + + return new self( + $clock, + $move, + null, + ); + } + + public function now(): PointInTime + { + $now = ($this->delta)($this->clock->now()); + + if ($this->halt) { + $now = $now->goForward($this->halt); + } + + return $now; + } + + public function halt(Period $period): SideEffect + { + $this->halt = $this->halt?->add($period) ?? $period; + + return SideEffect::identity; + } +} From 9aedc3a0e29fa729dd180dcf9625289a90e101fe Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Tue, 25 Nov 2025 14:29:31 +0100 Subject: [PATCH 05/34] implement simulated processes --- proofs/processes.php | 2 +- src/Config.php | 39 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/proofs/processes.php b/proofs/processes.php index c490b09..666027a 100644 --- a/proofs/processes.php +++ b/proofs/processes.php @@ -38,7 +38,7 @@ static function( ->control() ->processes() ->execute( - Command::foreground('bin') + Command::foreground('foo') ->withArgument('display') ->withOption('option'), ) diff --git a/src/Config.php b/src/Config.php index 89301c0..890c7d8 100644 --- a/src/Config.php +++ b/src/Config.php @@ -8,7 +8,10 @@ State\Clock as SimulatedClock, }; use Innmind\OperatingSystem\Config as OSConfig; -use Innmind\Server\Control\Server\Command; +use Innmind\Server\Control\{ + Server, + Server\Command, +}; use Innmind\TimeWarp\Halt; use Innmind\TimeContinuum\{ Clock, @@ -35,16 +38,48 @@ public function __construct( public function __invoke(OSConfig $config): OSConfig { + $processes = 2; $clock = $config->clock(); $simulatedClock = SimulatedClock::of( $clock, $this->start ?? $clock->now(), ); + $executables = $this->executables; return $config ->withClock(Clock::via(static fn() => $simulatedClock->now())) ->haltProcessVia(Halt::via(static fn($period) => Attempt::result( $simulatedClock->halt($period), - ))); + ))) + ->useServerControl(Server::via( + static function($command) use ($executables, &$processes) { + // todo build proper api in package + /** + * @psalm-suppress PossiblyNullFunctionCall + * @psalm-suppress UndefinedThisPropertyFetch + * @psalm-suppress MixedReturnStatement + * @var non-empty-string + */ + $executable = (\Closure::bind( + fn(): string => $this->executable, + $command, + Command::class, + ))(); + ++$processes; + + return $executables + ->get($executable) + ->match( + static fn($build) => Attempt::result($build($command, ProcessBuilder::new($processes))) + ->map(static fn($builder) => $builder->build()), + static fn() => Attempt::error(new \RuntimeException( // todo return a failed process instead ? + \sprintf( + 'Failed to start %s command', + $executable, + ), + )), + ); + }, + )); } } From 37c3f34f610e1d452575e3fcc3f815bcd0d3bad1 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Thu, 27 Nov 2025 10:55:10 +0100 Subject: [PATCH 06/34] simulate http calls --- proofs/http.php | 54 ++++++++++++++++++++++++++++ src/Config.php | 96 ++++++++++++++++++++++++++++++++++++++++++++++++- src/Factory.php | 36 ++++++++++++++++++- 3 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 proofs/http.php diff --git a/proofs/http.php b/proofs/http.php new file mode 100644 index 0000000..90b5288 --- /dev/null +++ b/proofs/http.php @@ -0,0 +1,54 @@ +handleHttpDomain( + Url::of('http://example.com'), + static fn($request, $os) => Attempt::result(Response::of( + StatusCode::ok, + $request->protocolVersion(), + null, + Content::ofString($output), + )), + ) + ->build(); + + $assert->same( + $output, + $os + ->remote() + ->http()(Request::of( + Url::of('http://example.com/foo/bar'), + Method::get, + ProtocolVersion::v11, + )) + ->match( + static fn($success) => $success->response()->body()->toString(), + static fn() => null, + ), + ); + }, + ); + + // todo prove the os of the domain is not the same as the one being used +}; diff --git a/src/Config.php b/src/Config.php index 890c7d8..741757a 100644 --- a/src/Config.php +++ b/src/Config.php @@ -7,11 +7,29 @@ ProcessBuilder, State\Clock as SimulatedClock, }; -use Innmind\OperatingSystem\Config as OSConfig; +use Innmind\OperatingSystem\{ + OperatingSystem, + Config as OSConfig, +}; use Innmind\Server\Control\{ Server, Server\Command, }; +use Innmind\HttpTransport\{ + Transport, + Information, + Success, + Redirection, + ClientError, + ServerError, + ConnectionFailed, +}; +use Innmind\Http\{ + ServerRequest, + Response, + Response\StatusCode, +}; +use Innmind\Filesystem\File\Content; use Innmind\TimeWarp\Halt; use Innmind\TimeContinuum\{ Clock, @@ -20,6 +38,7 @@ use Innmind\Immutable\{ Map, Attempt, + Either, }; /** @@ -29,10 +48,12 @@ final class Config { /** * @param Map $executables + * @param Map> $httpDomains */ public function __construct( private ?PointInTime $start, private Map $executables, + private Map $httpDomains, ) { } @@ -45,6 +66,7 @@ public function __invoke(OSConfig $config): OSConfig $this->start ?? $clock->now(), ); $executables = $this->executables; + $httpDomains = $this->httpDomains; return $config ->withClock(Clock::via(static fn() => $simulatedClock->now())) @@ -80,6 +102,78 @@ static function($command) use ($executables, &$processes) { )), ); }, + )) + ->useHttpTransport(Transport::via( + static function($request) use ($httpDomains) { + $serverRequest = ServerRequest::of( + $request->url(), + $request->method(), + $request->protocolVersion(), + $request->headers(), + Content::ofChunks( + $request + ->body() + ->chunks() + ->snap(), // simulate network + ), + // todo parse the content + ); + $domain = $request + ->url() + ->withAuthority( + $request + ->url() + ->authority() + ->withoutUserInformation(), + ) + ->withoutPath() + ->withoutQuery() + ->withoutFragment() + ->toString(); + + return $httpDomains + ->get($domain) + ->match( + static fn($handle) => $handle( + $serverRequest, + Factory::new()->build(), // todo reuse OS between same domains + )->match( + static fn($response) => match ($response->statusCode()->range()) { + StatusCode\Range::informational => Either::left(new Information( + $request, + $response, + )), + StatusCode\Range::successful => Either::right(new Success( + $request, + $response, + )), + StatusCode\Range::redirection => Either::left(new Redirection( + $request, + $response, + )), + StatusCode\Range::clientError => Either::left(new ClientError( + $request, + $response, + )), + StatusCode\Range::serverError => Either::left(new ServerError( + $request, + $response, + )), + }, + static fn() => Either::left(new ServerError( + $request, + Response::of( + StatusCode::internalServerError, + $request->protocolVersion(), + ), + )), + ), + static fn() => Either::left(new ConnectionFailed( + $request, + \sprintf('Unable to connect to %s', $domain), + )), + ); + }, )); } } diff --git a/src/Factory.php b/src/Factory.php index 6cb75bc..2aee9db 100644 --- a/src/Factory.php +++ b/src/Factory.php @@ -9,8 +9,16 @@ Factory as OSFactory, }; use Innmind\Server\Control\Server\Command; +use Innmind\Http\{ + ServerRequest, + Response, +}; +use Innmind\Url\Url; use Innmind\TimeContinuum\PointInTime; -use Innmind\Immutable\Map; +use Innmind\Immutable\{ + Map, + Attempt, +}; final class Factory { @@ -18,10 +26,12 @@ final class Factory * @psalm-mutation-free * * @param Map $executables + * @param Map> $httpDomains */ private function __construct( private ?PointInTime $start, private Map $executables, + private Map $httpDomains, ) { } @@ -33,6 +43,7 @@ public static function new(): self return new self( null, Map::of(), + Map::of(), ); } @@ -45,6 +56,7 @@ public function startClockAt(PointInTime $date): self return new self( $date, $this->executables, + $this->httpDomains, ); } @@ -65,6 +77,27 @@ public function handleExecutable( $bin, $builder, ), + $this->httpDomains, + ); + } + + /** + * @psalm-mutation-free + * + * @param callable(ServerRequest, OperatingSystem): Attempt $handle + */ + #[\NoDiscard] + public function handleHttpDomain( + Url $domain, + callable $handle, + ): self { + return new self( + $this->start, + $this->executables, + ($this->httpDomains)( + $domain->toString(), + $handle, + ), ); } @@ -78,6 +111,7 @@ public function build(): OperatingSystem return $build($command, $builder, $os); }; }), + $this->httpDomains, ); // The new $os is not directly returned in order for callables to have // the newly built OS injected at runtime. From 6aefd29d9e7f6ea079fab87a8361631f8b2f8bf1 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 30 Nov 2025 15:40:52 +0100 Subject: [PATCH 07/34] build simulation around a cluster made of machines connected over a network --- proofs/cluster.php | 110 +++++++++++++++++++++++++++++ src/Cluster.php | 66 +++++++++++++++++ src/Machine.php | 102 ++++++++++++++++++++++++++ src/Simulation/Cluster.php | 33 +++++++++ src/Simulation/Machine.php | 89 +++++++++++++++++++++++ src/Simulation/Machine/Config.php | 25 +++++++ src/Simulation/NTPServer.php | 41 +++++++++++ src/Simulation/NTPServer/Clock.php | 65 +++++++++++++++++ src/Simulation/Network.php | 66 +++++++++++++++++ 9 files changed, 597 insertions(+) create mode 100644 proofs/cluster.php create mode 100644 src/Cluster.php create mode 100644 src/Machine.php create mode 100644 src/Simulation/Cluster.php create mode 100644 src/Simulation/Machine.php create mode 100644 src/Simulation/Machine/Config.php create mode 100644 src/Simulation/NTPServer.php create mode 100644 src/Simulation/NTPServer/Clock.php create mode 100644 src/Simulation/Network.php diff --git a/proofs/cluster.php b/proofs/cluster.php new file mode 100644 index 0000000..defbae0 --- /dev/null +++ b/proofs/cluster.php @@ -0,0 +1,110 @@ +listenHttp( + static fn($request, $os) => Attempt::result(Response::of( + StatusCode::ok, + $request->protocolVersion(), + null, + Content::ofString($os->clock()->now()->format(Format::iso8601())), + )), + ); + $cluster = Cluster::new() + ->startClockAt($start) + ->add($local) + ->boot(); + + $response = $cluster + ->http(Request::of( + Url::of('http://local.dev/'), + Method::get, + ProtocolVersion::v11, + )) + ->unwrap(); + + $assert->same( + $start + ->changeOffset(Offset::utc()) + ->format(Format::iso8601()), + $response->body()->toString(), + ); + }, + ); + + yield proof( + 'Cluster time can be fast forwarded', + given( + PointInTime::any(), + Set::integers()->between(1, 1_000), + ), + static function($assert, $start, $seconds) { + $local = Machine::new('local.dev') + ->listenHttp( + static fn($request, $os) => $os + ->process() + ->halt(Period::second($seconds)) + ->map(static fn() => Response::of( + StatusCode::ok, + $request->protocolVersion(), + null, + Content::ofString($os->clock()->now()->format(Format::iso8601())), + )), + ); + $cluster = Cluster::new() + ->startClockAt($start) + ->add($local) + ->boot(); + + $assert + ->time(static function() use ($assert, $cluster, $start, $seconds) { + $response = $cluster + ->http(Request::of( + Url::of('http://local.dev/'), + Method::get, + ProtocolVersion::v11, + )) + ->unwrap(); + + $assert->same( + $start + ->changeOffset(Offset::utc()) + ->goForward(Period::second($seconds)) + ->format(Format::iso8601()), + $response->body()->toString(), + ); + }) + ->inLessThan() + ->seconds(1); + }, + ); +}; diff --git a/src/Cluster.php b/src/Cluster.php new file mode 100644 index 0000000..680849c --- /dev/null +++ b/src/Cluster.php @@ -0,0 +1,66 @@ + $machines + */ + private function __construct( + private ?PointInTime $start, + private Set $machines, + ) { + } + + /** + * @psalm-pure + */ + #[\NoDiscard] + public static function new(): self + { + return new self(null, Set::of()); + } + + /** + * @psalm-mutation-free + */ + #[\NoDiscard] + public function startClockAt(PointInTime $date): self + { + return new self( + $date, + $this->machines, + ); + } + + /** + * @psalm-mutation-free + */ + #[\NoDiscard] + public function add(Machine $machine): self + { + return new self( + $this->start, + ($this->machines)($machine), + ); + } + + #[\NoDiscard] + public function boot(): Simulation\Cluster + { + $ntp = Simulation\NTPServer::new($this->start); + $network = Simulation\Network::new($ntp); + $_ = $this->machines->foreach( + static fn($machine) => $machine->boot($network), + ); + + return Simulation\Cluster::new($network); + } +} diff --git a/src/Machine.php b/src/Machine.php new file mode 100644 index 0000000..5d1f17a --- /dev/null +++ b/src/Machine.php @@ -0,0 +1,102 @@ + $domains + * @param Map $executables + * @param Map, callable(ServerRequest, OperatingSystem): Attempt> $http + */ + private function __construct( + private array $domains, + private Map $executables, + private Map $http, + ) { + } + + /** + * @psalm-pure + * @no-named-arguments + */ + #[\NoDiscard] + public static function new( + string $domain, + string ...$domains, + ): self { + return new self( + [$domain, ...$domains], + Map::of(), + Map::of(), + ); + } + + /** + * @psalm-mutation-free + * + * @param non-empty-string $executable + * @param callable(Command, ProcessBuilder, OperatingSystem): ProcessBuilder $builder + */ + #[\NoDiscard] + public function install( + string $executable, + callable $builder, + ): self { + return new self( + $this->domains, + ($this->executables)($executable, $builder), + $this->http, + ); + } + + /** + * @psalm-mutation-free + * + * @param callable(ServerRequest, OperatingSystem): Attempt $handle + * @param ?int<1, max> $port + */ + #[\NoDiscard] + public function listenHttp( + callable $handle, + ?int $port = null, + ): self { + return new self( + $this->domains, + $this->executables, + ($this->http)($port, $handle), + ); + } + + // todo add environment variables + // todo add clock drift + // todo map($this): self + // todo add crontab ? + + public function boot(Simulation\Network $network): void + { + $network->with( + $this->domains, + fn() => Simulation\Machine::new( + $network, + $this->executables, + $this->http, + ), + ); + } +} diff --git a/src/Simulation/Cluster.php b/src/Simulation/Cluster.php new file mode 100644 index 0000000..a6ad813 --- /dev/null +++ b/src/Simulation/Cluster.php @@ -0,0 +1,33 @@ + + */ + public function http(Request $request): Attempt + { + return $this->network->http($request); + } + + // todo allow ssh +} diff --git a/src/Simulation/Machine.php b/src/Simulation/Machine.php new file mode 100644 index 0000000..2470075 --- /dev/null +++ b/src/Simulation/Machine.php @@ -0,0 +1,89 @@ + $executables + * @param Map> $http + */ + private function __construct( + private OperatingSystem $os, + private Map $executables, + private Map $http, + // private Map $environment, todo + ) { + } + + /** + * @param Map $executables + * @param Map, callable(ServerRequest, OperatingSystem): Attempt> $http + */ + #[\NoDiscard] + public static function new( + Network $network, + Map $executables, + Map $http, + ): self { + return new self( + OperatingSystem::new(Machine\Config::of($network)), + $executables, + $http, + ); + } + + /** + * @return Attempt + */ + public function http(Request $request): Attempt + { + $port = $request->url()->authority()->port(); + + $value = match ($port->equals(Port::none())) { + true => null, + false => $port->value(), + }; + + $serverRequest = ServerRequest::of( + $request->url(), + $request->method(), + $request->protocolVersion(), + $request->headers(), + // Simulate network by preventing iterating over the initial body + // twice. Though this approach prevents streaming, use + // `Sequence::defer()` instead ? + Content::ofChunks( + $request + ->body() + ->chunks() + ->snap(), + ), + // todo parse the content + ); + + return $this + ->http + ->get($value) + ->attempt(static fn() => new \RuntimeException('Connection timeout')) // todo inject fake timeout in ntp server ? + ->flatMap(fn($http) => $http($serverRequest, $this->os)); + } + + // todo allow ssh +} diff --git a/src/Simulation/Machine/Config.php b/src/Simulation/Machine/Config.php new file mode 100644 index 0000000..5033bb0 --- /dev/null +++ b/src/Simulation/Machine/Config.php @@ -0,0 +1,25 @@ +withClock(Clock::via(static fn() => $network->ntp()->now())) + ->haltProcessVia(Halt::via(static fn($period) => Attempt::result( + $network->ntp()->advance($period), + ))); + } +} diff --git a/src/Simulation/NTPServer.php b/src/Simulation/NTPServer.php new file mode 100644 index 0000000..003fa94 --- /dev/null +++ b/src/Simulation/NTPServer.php @@ -0,0 +1,41 @@ +clock->now(); + } + + /** + * Either because a machine halted a process or a network call was made and + * introduce a latency between machines + */ + public function advance(Period $period): SideEffect + { + return $this->clock->advance($period); + } +} diff --git a/src/Simulation/NTPServer/Clock.php b/src/Simulation/NTPServer/Clock.php new file mode 100644 index 0000000..6a53b2b --- /dev/null +++ b/src/Simulation/NTPServer/Clock.php @@ -0,0 +1,65 @@ +now(); + + if (\is_null($now)) { + $move = static fn(PointInTime $now): PointInTime => $now; + } else if ($now->aheadOf($realNow)) { + $delta = $now->elapsedSince($realNow)->asPeriod(); + $move = static fn(PointInTime $now): PointInTime => $now->goForward($delta); + } else { + $delta = $realNow->elapsedSince($now)->asPeriod(); + $move = static fn(PointInTime $now): PointInTime => $now->goBack($delta); + } + + return new self( + $clock, + $move, + null, + ); + } + + public function now(): PointInTime + { + $now = ($this->delta)($this->clock->now()); + + if ($this->advance) { + $now = $now->goForward($this->advance); + } + + return $now; + } + + public function advance(Period $period): SideEffect + { + $this->advance = $this->advance?->add($period) ?? $period; + + return SideEffect::identity; + } +} diff --git a/src/Simulation/Network.php b/src/Simulation/Network.php new file mode 100644 index 0000000..f8235fd --- /dev/null +++ b/src/Simulation/Network.php @@ -0,0 +1,66 @@ + $machines + */ + private function __construct( + private Map $machines, + private NTPServer $ntp, + ) { + } + + public static function new(NTPServer $ntp): self + { + return new self( + Map::of(), + $ntp, + ); + } + + /** + * @param non-empty-list $domains + * @param callable(): Machine $boot + */ + public function with(array $domains, callable $boot): void + { + $machine = $boot(); + + foreach ($domains as $domain) { + $this->machines = ($this->machines)( + $domain, + $machine, + ); + } + } + + public function ntp(): NTPServer + { + return $this->ntp; + } + + /** + * @return Attempt + */ + public function http(Request $request): Attempt + { + return $this + ->machines + ->get($request->url()->authority()->host()->toString()) + ->attempt(static fn() => new \RuntimeException('Could not resolve host')) + ->flatMap(static fn($machine) => $machine->http($request)); + } +} From 90f4e23aefb759d3a5e8f966ce762f8b7458dd25 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 30 Nov 2025 16:11:20 +0100 Subject: [PATCH 08/34] add machine processes simulation --- proofs/cluster.php | 71 ++++++++++++++++++++++++++++ src/Simulation/Machine.php | 21 +++++--- src/Simulation/Machine/Config.php | 12 +++-- src/Simulation/Machine/OS.php | 33 +++++++++++++ src/Simulation/Machine/Processes.php | 71 ++++++++++++++++++++++++++++ 5 files changed, 199 insertions(+), 9 deletions(-) create mode 100644 src/Simulation/Machine/OS.php create mode 100644 src/Simulation/Machine/Processes.php diff --git a/proofs/cluster.php b/proofs/cluster.php index defbae0..88af494 100644 --- a/proofs/cluster.php +++ b/proofs/cluster.php @@ -5,6 +5,7 @@ Machine, Cluster, }; +use Innmind\Server\Control\Server\Command; use Innmind\Http\{ Request, Response, @@ -107,4 +108,74 @@ static function($assert, $start, $seconds) { ->seconds(1); }, ); + + yield proof( + 'HTTP app can execute simulated process on the same machine', + given( + PointInTime::any(), + ), + static function($assert, $start) { + $called = false; + $local = Machine::new('local.dev') + ->install( + 'foo', + static function( + $command, + $builder, + $os, + ) use ($assert, &$called) { + $assert->same( + "foo 'display' '--option'", + $command->toString(), + ); + $called = true; + + return $builder->success([[ + $os->clock()->now()->format(Format::iso8601()), + 'output', + ]]); + }, + ) + ->listenHttp( + static fn($request, $os) => Attempt::result(Response::of( + StatusCode::ok, + $request->protocolVersion(), + null, + Content::ofChunks( + $os + ->control() + ->processes() + ->execute( + Command::foreground('foo') + ->withArgument('display') + ->withOption('option'), + ) + ->unwrap() + ->output() + ->map(static fn($chunk) => $chunk->data()), + ), + )), + ); + $cluster = Cluster::new() + ->startClockAt($start) + ->add($local) + ->boot(); + + $response = $cluster + ->http(Request::of( + Url::of('http://local.dev/'), + Method::get, + ProtocolVersion::v11, + )) + ->unwrap(); + + $assert->true($called); + $assert->same( + $start + ->changeOffset(Offset::utc()) + ->format(Format::iso8601()), + $response->body()->toString(), + ); + }, + ); }; diff --git a/src/Simulation/Machine.php b/src/Simulation/Machine.php index 2470075..84f6136 100644 --- a/src/Simulation/Machine.php +++ b/src/Simulation/Machine.php @@ -21,12 +21,11 @@ final class Machine { /** - * @param Map $executables * @param Map> $http */ private function __construct( - private OperatingSystem $os, - private Map $executables, + private Machine\OS $os, + private Machine\Processes $processes, private Map $http, // private Map $environment, todo ) { @@ -42,9 +41,19 @@ public static function new( Map $executables, Map $http, ): self { - return new self( - OperatingSystem::new(Machine\Config::of($network)), + $os = Machine\OS::new(); + $processes = Machine\Processes::new( + $os, $executables, + ); + $os->boot(OperatingSystem::new(Machine\Config::of( + $network, + $processes, + ))); + + return new self( + $os, + $processes, $http, ); } @@ -82,7 +91,7 @@ public function http(Request $request): Attempt ->http ->get($value) ->attempt(static fn() => new \RuntimeException('Connection timeout')) // todo inject fake timeout in ntp server ? - ->flatMap(fn($http) => $http($serverRequest, $this->os)); + ->flatMap(fn($http) => $http($serverRequest, $this->os->unwrap())); } // todo allow ssh diff --git a/src/Simulation/Machine/Config.php b/src/Simulation/Machine/Config.php index 5033bb0..fe3f3a7 100644 --- a/src/Simulation/Machine/Config.php +++ b/src/Simulation/Machine/Config.php @@ -5,6 +5,7 @@ use Innmind\Testing\Simulation\Network; use Innmind\OperatingSystem\Config as OSConfig; +use Innmind\Server\Control\Server; use Innmind\TimeWarp\Halt; use Innmind\TimeContinuum\Clock; use Innmind\Immutable\Attempt; @@ -14,12 +15,17 @@ */ final class Config { - public static function of(Network $network): OSConfig - { + public static function of( + Network $network, + Processes $processes, + ): OSConfig { return OSConfig::new() ->withClock(Clock::via(static fn() => $network->ntp()->now())) ->haltProcessVia(Halt::via(static fn($period) => Attempt::result( $network->ntp()->advance($period), - ))); + ))) + ->useServerControl(Server::via( + static fn($command) => $processes->run($command), + )); } } diff --git a/src/Simulation/Machine/OS.php b/src/Simulation/Machine/OS.php new file mode 100644 index 0000000..25414d0 --- /dev/null +++ b/src/Simulation/Machine/OS.php @@ -0,0 +1,33 @@ +os = $os; + } + + public function unwrap(): OperatingSystem + { + if (\is_null($this->os)) { + throw new \LogicException('Machine OS should be booted'); + } + + return $this->os; + } +} diff --git a/src/Simulation/Machine/Processes.php b/src/Simulation/Machine/Processes.php new file mode 100644 index 0000000..35771c7 --- /dev/null +++ b/src/Simulation/Machine/Processes.php @@ -0,0 +1,71 @@ + $executables + * @param int<2, max> $lastPid + */ + private function __construct( + private OS $os, + private Map $executables, + private int $lastPid = 2, + ) { + } + + /** + * @param Map $executables + */ + public static function new(OS $os, Map $executables): self + { + return new self($os, $executables); + } + + /** + * @return Attempt + */ + public function run(Command $command): Attempt + { + // todo build proper api in package + /** + * @psalm-suppress PossiblyNullFunctionCall + * @psalm-suppress UndefinedThisPropertyFetch + * @psalm-suppress MixedReturnStatement + * @var non-empty-string + */ + $executable = (\Closure::bind( + fn(): string => $this->executable, + $command, + Command::class, + ))(); + + return $this + ->executables + ->get($executable) + ->attempt(static fn() => new \RuntimeException( // todo return a failed process instead ? + \sprintf( + 'Failed to start %s command', + $executable, + ), + )) + ->map(fn($builder) => $builder( + $command, + ProcessBuilder::new(++$this->lastPid), + $this->os->unwrap(), + )->build()); + } +} From ca35eca3fc12f6c48209e5635bee9ab0f5698d93 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 30 Nov 2025 16:58:22 +0100 Subject: [PATCH 09/34] allow to make http calls across the simulated network --- proofs/cluster.php | 219 +++++++++++++++++++++++++- src/Exception/CouldNotResolveHost.php | 15 ++ src/Simulation/Machine.php | 20 ++- src/Simulation/Machine/Config.php | 64 +++++++- src/Simulation/Network.php | 7 +- 5 files changed, 316 insertions(+), 9 deletions(-) create mode 100644 src/Exception/CouldNotResolveHost.php diff --git a/proofs/cluster.php b/proofs/cluster.php index 88af494..70563bc 100644 --- a/proofs/cluster.php +++ b/proofs/cluster.php @@ -6,6 +6,7 @@ Cluster, }; use Innmind\Server\Control\Server\Command; +use Innmind\HttpTransport\ConnectionFailed; use Innmind\Http\{ Request, Response, @@ -20,7 +21,11 @@ Format, Offset, }; -use Innmind\Immutable\Attempt; +use Innmind\Immutable\{ + Attempt, + Sequence, + Str, +}; use Innmind\BlackBox\Set; use Fixtures\Innmind\TimeContinuum\PointInTime; @@ -178,4 +183,216 @@ static function( ); }, ); + + yield proof( + 'Machine can make HTTP calls to another machine', + given( + Set::strings(), + Set::strings(), + ), + static function($assert, $input, $output) { + $remote = Machine::new('remote.dev') + ->listenHttp( + static function($request) use ($assert, $input, $output) { + $assert->same($input, $request->body()->toString()); + + return Attempt::result(Response::of( + StatusCode::ok, + $request->protocolVersion(), + null, + Content::ofString($output), + )); + }, + ); + $local = Machine::new('local.dev') + ->listenHttp( + static fn($request, $os) => $os + ->remote() + ->http()(Request::of( + Url::of('http://remote.dev/'), + Method::post, + ProtocolVersion::v11, + null, + Content::ofString($input), + )) + ->attempt(static fn() => new RuntimeException('Failed to access remote server')) + ->map(static fn($success) => $success->response()), + ); + $cluster = Cluster::new() + ->add($local) + ->add($remote) + ->boot(); + + $response = $cluster + ->http(Request::of( + Url::of('http://local.dev/'), + Method::get, + ProtocolVersion::v11, + )) + ->unwrap(); + + $assert->same( + $output, + $response->body()->toString(), + ); + }, + ); + + yield proof( + 'Streamed HTTP responses are accessed only once over the network', + given( + Set::strings(), + ), + static function($assert, $output) { + $called = 0; + $remote = Machine::new('remote.dev') + ->listenHttp( + static function($request) use ($output, &$called) { + return Attempt::result(Response::of( + StatusCode::ok, + $request->protocolVersion(), + null, + Content::ofChunks(Sequence::lazy(static function() use ($output, &$called) { + ++$called; + + yield Str::of($output); + })), + )); + }, + ); + $local = Machine::new('local.dev') + ->listenHttp( + static fn($request, $os) => $os + ->remote() + ->http()(Request::of( + Url::of('http://remote.dev/'), + Method::get, + ProtocolVersion::v11, + )) + ->attempt(static fn() => new RuntimeException('Failed to access remote server')) + ->map(static fn($success) => $success->response()->body()) + ->map(static fn($body) => Response::of( + StatusCode::ok, + $request->protocolVersion(), + null, + Content::ofString($body->toString().$body->toString()), + )), + ); + $cluster = Cluster::new() + ->add($local) + ->add($remote) + ->boot(); + + $response = $cluster + ->http(Request::of( + Url::of('http://local.dev/'), + Method::get, + ProtocolVersion::v11, + )) + ->unwrap(); + + $assert->same(1, $called); + $assert->same( + $output.$output, + $response->body()->toString(), + ); + }, + ); + + yield test( + 'Machines do not use the same operating system instance', + static function($assert) { + $remote = Machine::new('remote.dev') + ->listenHttp( + static fn($request, $os) => Attempt::result(Response::of( + StatusCode::ok, + $request->protocolVersion(), + null, + Content::ofString(\spl_object_hash($os)), + )), + ); + $local = Machine::new('local.dev') + ->listenHttp( + static fn($request, $os) => $os + ->remote() + ->http()(Request::of( + Url::of('http://remote.dev/'), + Method::get, + ProtocolVersion::v11, + )) + ->attempt(static fn() => new RuntimeException('Failed to access remote server')) + ->map(static fn($success) => $success->response()->body()) + ->map(static fn($body) => Response::of( + StatusCode::ok, + $request->protocolVersion(), + null, + Content::ofString(\spl_object_hash($os).'|'.$body->toString()), + )), + ); + $cluster = Cluster::new() + ->add($local) + ->add($remote) + ->boot(); + + $response = $cluster + ->http(Request::of( + Url::of('http://local.dev/'), + Method::get, + ProtocolVersion::v11, + )) + ->unwrap() + ->body() + ->toString(); + [$local, $remote] = \explode('|', $response); + + $assert + ->expected($local) + ->not() + ->same($remote); + }, + ); + + yield test( + 'Machine get an error when accessing unknown machine over HTTP', + static function($assert) { + $local = Machine::new('local.dev') + ->listenHttp( + static fn($request, $os) => $os + ->remote() + ->http()(Request::of( + Url::of('http://remote.dev/'), + Method::get, + ProtocolVersion::v11, + )) + ->match( + static fn($success) => Attempt::result($success->response()), + static fn($error) => match (true) { + $error instanceof ConnectionFailed => Attempt::result(Response::of( + StatusCode::ok, + $request->protocolVersion(), + null, + Content::ofString($error->reason()), + )), + default => Attempt::error(new RuntimeException), + }, + ), + ); + $cluster = Cluster::new() + ->add($local) + ->boot(); + + $response = $cluster + ->http(Request::of( + Url::of('http://local.dev/'), + Method::get, + ProtocolVersion::v11, + )) + ->unwrap(); + + $assert->same( + 'Could not resolve host: remote.dev', + $response->body()->toString(), + ); + }, + ); }; diff --git a/src/Exception/CouldNotResolveHost.php b/src/Exception/CouldNotResolveHost.php new file mode 100644 index 0000000..11a53a9 --- /dev/null +++ b/src/Exception/CouldNotResolveHost.php @@ -0,0 +1,15 @@ + $port->value(), }; + // Simulate network by preventing iterating over the request/response + // bodies twice. Though this approach prevents streaming, todo use + // `Sequence::defer()` instead ? + $serverRequest = ServerRequest::of( $request->url(), $request->method(), $request->protocolVersion(), $request->headers(), - // Simulate network by preventing iterating over the initial body - // twice. Though this approach prevents streaming, use - // `Sequence::defer()` instead ? Content::ofChunks( $request ->body() @@ -91,7 +92,18 @@ public function http(Request $request): Attempt ->http ->get($value) ->attempt(static fn() => new \RuntimeException('Connection timeout')) // todo inject fake timeout in ntp server ? - ->flatMap(fn($http) => $http($serverRequest, $this->os->unwrap())); + ->flatMap(fn($http) => $http($serverRequest, $this->os->unwrap())) + ->map(static fn($response) => Response::of( + $response->statusCode(), + $response->protocolVersion(), + $response->headers(), + Content::ofChunks( + $response + ->body() + ->chunks() + ->snap(), + ), + )); } // todo allow ssh diff --git a/src/Simulation/Machine/Config.php b/src/Simulation/Machine/Config.php index fe3f3a7..b502109 100644 --- a/src/Simulation/Machine/Config.php +++ b/src/Simulation/Machine/Config.php @@ -3,12 +3,31 @@ namespace Innmind\Testing\Simulation\Machine; -use Innmind\Testing\Simulation\Network; +use Innmind\Testing\{ + Simulation\Network, + Exception\CouldNotResolveHost, +}; use Innmind\OperatingSystem\Config as OSConfig; use Innmind\Server\Control\Server; +use Innmind\HttpTransport\{ + Transport, + Information, + Success, + Redirection, + ClientError, + ServerError, + ConnectionFailed, +}; use Innmind\TimeWarp\Halt; +use Innmind\Http\{ + Response, + Response\StatusCode, +}; use Innmind\TimeContinuum\Clock; -use Innmind\Immutable\Attempt; +use Innmind\Immutable\{ + Attempt, + Either, +}; /** * @internal @@ -26,6 +45,47 @@ public static function of( ))) ->useServerControl(Server::via( static fn($command) => $processes->run($command), + )) + ->useHttpTransport(Transport::via( + static fn($request) => $network + ->http($request) + ->match( + static fn($response) => match ($response->statusCode()->range()) { + StatusCode\Range::informational => Either::left(new Information( + $request, + $response, + )), + StatusCode\Range::successful => Either::right(new Success( + $request, + $response, + )), + StatusCode\Range::redirection => Either::left(new Redirection( + $request, + $response, + )), + StatusCode\Range::clientError => Either::left(new ClientError( + $request, + $response, + )), + StatusCode\Range::serverError => Either::left(new ServerError( + $request, + $response, + )), + }, + static fn($e) => Either::left(match (true) { + $e instanceof CouldNotResolveHost => new ConnectionFailed( + $request, + $e->getMessage(), + ), + default => new ServerError( + $request, + Response::of( + StatusCode::internalServerError, + $request->protocolVersion(), + ), + ), + }), + ), )); } } diff --git a/src/Simulation/Network.php b/src/Simulation/Network.php index f8235fd..9dc1171 100644 --- a/src/Simulation/Network.php +++ b/src/Simulation/Network.php @@ -3,6 +3,7 @@ namespace Innmind\Testing\Simulation; +use Innmind\Testing\Exception\CouldNotResolveHost; use Innmind\Http\{ Request, Response, @@ -57,10 +58,12 @@ public function ntp(): NTPServer */ public function http(Request $request): Attempt { + $host = $request->url()->authority()->host()->toString(); + return $this ->machines - ->get($request->url()->authority()->host()->toString()) - ->attempt(static fn() => new \RuntimeException('Could not resolve host')) + ->get($host) + ->attempt(static fn() => new CouldNotResolveHost($host)) ->flatMap(static fn($machine) => $machine->http($request)); } } From 9c1b89c7007d8a30ec414b0eaa2e1c559c69db85 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 30 Nov 2025 16:59:38 +0100 Subject: [PATCH 10/34] remove previous implementation --- proofs/clock.php | 28 ------ proofs/http.php | 54 ----------- proofs/processes.php | 54 ----------- src/Config.php | 179 ------------------------------------ src/Factory.php | 122 ------------------------ src/Machine/State/Clock.php | 63 ------------- 6 files changed, 500 deletions(-) delete mode 100644 proofs/clock.php delete mode 100644 proofs/http.php delete mode 100644 proofs/processes.php delete mode 100644 src/Config.php delete mode 100644 src/Factory.php delete mode 100644 src/Machine/State/Clock.php diff --git a/proofs/clock.php b/proofs/clock.php deleted file mode 100644 index 6cf7265..0000000 --- a/proofs/clock.php +++ /dev/null @@ -1,28 +0,0 @@ -startClockAt($point) - ->build(); - $os->process()->halt(Period::microsecond(1)); - - $now = $os->clock()->now(); - $assert->true($now->aheadOf($point)); - $os->process()->halt(Period::microsecond(1)); - $assert->true( - $os->clock()->now()->aheadOf($now), - ); - }, - ); -}; diff --git a/proofs/http.php b/proofs/http.php deleted file mode 100644 index 90b5288..0000000 --- a/proofs/http.php +++ /dev/null @@ -1,54 +0,0 @@ -handleHttpDomain( - Url::of('http://example.com'), - static fn($request, $os) => Attempt::result(Response::of( - StatusCode::ok, - $request->protocolVersion(), - null, - Content::ofString($output), - )), - ) - ->build(); - - $assert->same( - $output, - $os - ->remote() - ->http()(Request::of( - Url::of('http://example.com/foo/bar'), - Method::get, - ProtocolVersion::v11, - )) - ->match( - static fn($success) => $success->response()->body()->toString(), - static fn() => null, - ), - ); - }, - ); - - // todo prove the os of the domain is not the same as the one being used -}; diff --git a/proofs/processes.php b/proofs/processes.php deleted file mode 100644 index 666027a..0000000 --- a/proofs/processes.php +++ /dev/null @@ -1,54 +0,0 @@ -handleExecutable( - 'foo', - static function( - $command, - $builder, - ) use ($assert, $output) { - $assert->same( - "foo 'display' '--option'", - $command->toString(), - ); - - return $builder->success(\array_map( - static fn($chunk) => [$chunk, 'output'], - $output, - )); - }, - ) - ->build(); - - $assert->same( - $output, - $os - ->control() - ->processes() - ->execute( - Command::foreground('foo') - ->withArgument('display') - ->withOption('option'), - ) - ->unwrap() - ->output() - ->map(static fn($chunk) => $chunk->data()->toString()) - ->toList(), - ); - }, - ); - - // todo prove the executables have access to the new os by checking the filesystem -}; diff --git a/src/Config.php b/src/Config.php deleted file mode 100644 index 741757a..0000000 --- a/src/Config.php +++ /dev/null @@ -1,179 +0,0 @@ - $executables - * @param Map> $httpDomains - */ - public function __construct( - private ?PointInTime $start, - private Map $executables, - private Map $httpDomains, - ) { - } - - public function __invoke(OSConfig $config): OSConfig - { - $processes = 2; - $clock = $config->clock(); - $simulatedClock = SimulatedClock::of( - $clock, - $this->start ?? $clock->now(), - ); - $executables = $this->executables; - $httpDomains = $this->httpDomains; - - return $config - ->withClock(Clock::via(static fn() => $simulatedClock->now())) - ->haltProcessVia(Halt::via(static fn($period) => Attempt::result( - $simulatedClock->halt($period), - ))) - ->useServerControl(Server::via( - static function($command) use ($executables, &$processes) { - // todo build proper api in package - /** - * @psalm-suppress PossiblyNullFunctionCall - * @psalm-suppress UndefinedThisPropertyFetch - * @psalm-suppress MixedReturnStatement - * @var non-empty-string - */ - $executable = (\Closure::bind( - fn(): string => $this->executable, - $command, - Command::class, - ))(); - ++$processes; - - return $executables - ->get($executable) - ->match( - static fn($build) => Attempt::result($build($command, ProcessBuilder::new($processes))) - ->map(static fn($builder) => $builder->build()), - static fn() => Attempt::error(new \RuntimeException( // todo return a failed process instead ? - \sprintf( - 'Failed to start %s command', - $executable, - ), - )), - ); - }, - )) - ->useHttpTransport(Transport::via( - static function($request) use ($httpDomains) { - $serverRequest = ServerRequest::of( - $request->url(), - $request->method(), - $request->protocolVersion(), - $request->headers(), - Content::ofChunks( - $request - ->body() - ->chunks() - ->snap(), // simulate network - ), - // todo parse the content - ); - $domain = $request - ->url() - ->withAuthority( - $request - ->url() - ->authority() - ->withoutUserInformation(), - ) - ->withoutPath() - ->withoutQuery() - ->withoutFragment() - ->toString(); - - return $httpDomains - ->get($domain) - ->match( - static fn($handle) => $handle( - $serverRequest, - Factory::new()->build(), // todo reuse OS between same domains - )->match( - static fn($response) => match ($response->statusCode()->range()) { - StatusCode\Range::informational => Either::left(new Information( - $request, - $response, - )), - StatusCode\Range::successful => Either::right(new Success( - $request, - $response, - )), - StatusCode\Range::redirection => Either::left(new Redirection( - $request, - $response, - )), - StatusCode\Range::clientError => Either::left(new ClientError( - $request, - $response, - )), - StatusCode\Range::serverError => Either::left(new ServerError( - $request, - $response, - )), - }, - static fn() => Either::left(new ServerError( - $request, - Response::of( - StatusCode::internalServerError, - $request->protocolVersion(), - ), - )), - ), - static fn() => Either::left(new ConnectionFailed( - $request, - \sprintf('Unable to connect to %s', $domain), - )), - ); - }, - )); - } -} diff --git a/src/Factory.php b/src/Factory.php deleted file mode 100644 index 2aee9db..0000000 --- a/src/Factory.php +++ /dev/null @@ -1,122 +0,0 @@ - $executables - * @param Map> $httpDomains - */ - private function __construct( - private ?PointInTime $start, - private Map $executables, - private Map $httpDomains, - ) { - } - - /** - * @psalm-pure - */ - public static function new(): self - { - return new self( - null, - Map::of(), - Map::of(), - ); - } - - /** - * @psalm-mutation-free - */ - #[\NoDiscard] - public function startClockAt(PointInTime $date): self - { - return new self( - $date, - $this->executables, - $this->httpDomains, - ); - } - - /** - * @psalm-mutation-free - * - * @param non-empty-string $bin - * @param callable(Command, ProcessBuilder, OperatingSystem): ProcessBuilder $builder - */ - #[\NoDiscard] - public function handleExecutable( - string $bin, - callable $builder, - ): self { - return new self( - $this->start, - ($this->executables)( - $bin, - $builder, - ), - $this->httpDomains, - ); - } - - /** - * @psalm-mutation-free - * - * @param callable(ServerRequest, OperatingSystem): Attempt $handle - */ - #[\NoDiscard] - public function handleHttpDomain( - Url $domain, - callable $handle, - ): self { - return new self( - $this->start, - $this->executables, - ($this->httpDomains)( - $domain->toString(), - $handle, - ), - ); - } - - public function build(): OperatingSystem - { - $os = OSFactory::build(); - $config = new Config( - $this->start, - $this->executables->map(static function($_, $build) use (&$os) { - return static function(Command $command, ProcessBuilder $builder) use ($build, &$os) { - return $build($command, $builder, $os); - }; - }), - $this->httpDomains, - ); - // The new $os is not directly returned in order for callables to have - // the newly built OS injected at runtime. - $os = $os->map($config); - - return $os; - } -} diff --git a/src/Machine/State/Clock.php b/src/Machine/State/Clock.php deleted file mode 100644 index 813b928..0000000 --- a/src/Machine/State/Clock.php +++ /dev/null @@ -1,63 +0,0 @@ -now(); - - if ($now->aheadOf($realNow)) { - $delta = $now->elapsedSince($realNow)->asPeriod(); - $move = static fn(PointInTime $now): PointInTime => $now->goForward($delta); - } else { - $delta = $realNow->elapsedSince($now)->asPeriod(); - $move = static fn(PointInTime $now): PointInTime => $now->goBack($delta); - } - - return new self( - $clock, - $move, - null, - ); - } - - public function now(): PointInTime - { - $now = ($this->delta)($this->clock->now()); - - if ($this->halt) { - $now = $now->goForward($this->halt); - } - - return $now; - } - - public function halt(Period $period): SideEffect - { - $this->halt = $this->halt?->add($period) ?? $period; - - return SideEffect::identity; - } -} From fd793c7bb624dd12c86a77022fd956a6fbadadeb Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 30 Nov 2025 17:39:59 +0100 Subject: [PATCH 11/34] add time drift simulation --- proofs/cluster.php | 81 ++++++++++++++++++++++++++ src/Machine.php | 19 ++++++ src/Machine/Clock/Drift.php | 36 ++++++++++++ src/Simulation/Machine.php | 7 ++- src/Simulation/Machine/Clock/Drift.php | 77 ++++++++++++++++++++++++ src/Simulation/Machine/Config.php | 11 +++- 6 files changed, 228 insertions(+), 3 deletions(-) create mode 100644 src/Machine/Clock/Drift.php create mode 100644 src/Simulation/Machine/Clock/Drift.php diff --git a/proofs/cluster.php b/proofs/cluster.php index 70563bc..4e50477 100644 --- a/proofs/cluster.php +++ b/proofs/cluster.php @@ -13,6 +13,8 @@ Response\StatusCode, Method, ProtocolVersion, + Headers, + Header\Date, }; use Innmind\Filesystem\File\Content; use Innmind\Url\Url; @@ -395,4 +397,83 @@ static function($assert) { ); }, ); + + yield proof( + 'Time can drift between machines', + given( + Set::either( + Set::integers()->between(10, 1_000), + Set::integers()->between(-1_000, -10), + ), + ), + static function($assert, $drift) { + $format = Format::of('Y-m-dTH:i:s.v'); + $remote = Machine::new('remote.dev') + ->listenHttp( + static fn($request, $os) => Attempt::result(Response::of( + StatusCode::ok, + $request->protocolVersion(), + null, + Content::ofString(\sprintf( + '%s|%s', + $request->body()->toString(), + $os->clock()->now()->format($format), + )), + )), + ); + $local = Machine::new('local.dev') + ->driftClockBy(Machine\Clock\Drift::of(0, $drift)) + ->listenHttp( + static fn($request, $os) => $os + ->remote() + ->http()(Request::of( + Url::of('http://remote.dev/'), + Method::post, + ProtocolVersion::v11, + Headers::of( + Date::of($os->clock()->now()), // to force accessing the second drift + ), + Content::ofString($os->clock()->now()->format($format)), + )) + ->attempt(static fn() => new RuntimeException('Failed to access remote server')) + ->map(static fn($success) => $success->response()->body()) + ->map(static fn($body) => Response::of( + StatusCode::ok, + $request->protocolVersion(), + null, + Content::ofString(\sprintf( + '%s|%s', + $body->toString(), + $os->clock()->now()->format($format), + )), + )), + ); + $cluster = Cluster::new() + ->add($local) + ->add($remote) + ->boot(); + + $response = $cluster + ->http(Request::of( + Url::of('http://local.dev/'), + Method::get, + ProtocolVersion::v11, + )) + ->unwrap() + ->body() + ->toString(); + [$drifted, $remote, $resynced] = \explode('|', $response); + // remove last millisecond as the execution of the code can elapse + // over 2 miliseconds. + $drifted = \substr($drifted, 0, -1); + $remote = \substr($remote, 0, -1); + $resynced = \substr($resynced, 0, -1); + + $assert + ->expected($drifted) + ->not() + ->same($remote); + $assert->same($remote, $resynced); + }, + ); }; diff --git a/src/Machine.php b/src/Machine.php index 5d1f17a..899588a 100644 --- a/src/Machine.php +++ b/src/Machine.php @@ -28,6 +28,7 @@ private function __construct( private array $domains, private Map $executables, private Map $http, + private Machine\Clock\Drift $drift, ) { } @@ -44,6 +45,7 @@ public static function new( [$domain, ...$domains], Map::of(), Map::of(), + Machine\Clock\Drift::of(), ); } @@ -62,6 +64,7 @@ public function install( $this->domains, ($this->executables)($executable, $builder), $this->http, + $this->drift, ); } @@ -80,6 +83,21 @@ public function listenHttp( $this->domains, $this->executables, ($this->http)($port, $handle), + $this->drift, + ); + } + + /** + * @psalm-mutation-free + */ + #[\NoDiscard] + public function driftClockBy(Machine\Clock\Drift $drift): self + { + return new self( + $this->domains, + $this->executables, + $this->http, + $drift, ); } @@ -96,6 +114,7 @@ public function boot(Simulation\Network $network): void $network, $this->executables, $this->http, + $this->drift, ), ); } diff --git a/src/Machine/Clock/Drift.php b/src/Machine/Clock/Drift.php new file mode 100644 index 0000000..9b2d75a --- /dev/null +++ b/src/Machine/Clock/Drift.php @@ -0,0 +1,36 @@ + $drifts + */ + private function __construct( + private array $drifts, + ) { + } + + /** + * The drifts are expressed in milliseconds + * + * @psalm-pure + * @no-named-arguments + */ + public static function of(int ...$drifts): self + { + return new self($drifts); + } + + public function asState(): Clock\Drift + { + return Clock\Drift::of($this->drifts); + } +} diff --git a/src/Simulation/Machine.php b/src/Simulation/Machine.php index 84d03d1..fa335a1 100644 --- a/src/Simulation/Machine.php +++ b/src/Simulation/Machine.php @@ -3,7 +3,10 @@ namespace Innmind\Testing\Simulation; -use Innmind\Testing\Machine\ProcessBuilder; +use Innmind\Testing\Machine\{ + ProcessBuilder, + Clock, +}; use Innmind\OperatingSystem\OperatingSystem; use Innmind\Server\Control\Server\Command; use Innmind\Http\{ @@ -40,6 +43,7 @@ public static function new( Network $network, Map $executables, Map $http, + Clock\Drift $drift, ): self { $os = Machine\OS::new(); $processes = Machine\Processes::new( @@ -49,6 +53,7 @@ public static function new( $os->boot(OperatingSystem::new(Machine\Config::of( $network, $processes, + $drift, ))); return new self( diff --git a/src/Simulation/Machine/Clock/Drift.php b/src/Simulation/Machine/Clock/Drift.php new file mode 100644 index 0000000..45a3607 --- /dev/null +++ b/src/Simulation/Machine/Clock/Drift.php @@ -0,0 +1,77 @@ + $drifts + */ + private function __construct( + private array $drifts, + private ?int $accumulated = null, + ) { + } + + public function __invoke(PointInTime $now): PointInTime + { + if (\count($this->drifts) === 0) { + return $now; + } + + /** @var int|false */ + $drift = \current($this->drifts); + + if (!\is_int($drift)) { + $drift = \reset($this->drifts); + } + + \next($this->drifts); + + $this->accumulated = match ($this->accumulated) { + null => $drift, + default => $this->accumulated + $drift, + }; + + if ($this->accumulated === 0) { + return $now; + } + + if ($this->accumulated > 0) { + return $now->goForward(Period::millisecond($this->accumulated)); + } + + return $now->goBack(Period::millisecond($this->accumulated * -1)); + } + + /** + * @psalm-pure + * + * @param list $drifts + */ + public static function of(array $drifts): self + { + return new self($drifts); + } + + /** + * Drift reset can only occur when accessing the network to simulate the + * machine is synchronizing with the NTP server. + */ + public function reset(Network $value): Network + { + $this->accumulated = null; + \reset($this->drifts); + + return $value; + } +} diff --git a/src/Simulation/Machine/Config.php b/src/Simulation/Machine/Config.php index b502109..90ad95c 100644 --- a/src/Simulation/Machine/Config.php +++ b/src/Simulation/Machine/Config.php @@ -5,6 +5,7 @@ use Innmind\Testing\{ Simulation\Network, + Machine\Clock\Drift, Exception\CouldNotResolveHost, }; use Innmind\OperatingSystem\Config as OSConfig; @@ -37,9 +38,14 @@ final class Config public static function of( Network $network, Processes $processes, + Drift $drift, ): OSConfig { + $drift = $drift->asState(); + return OSConfig::new() - ->withClock(Clock::via(static fn() => $network->ntp()->now())) + ->withClock(Clock::via(static fn() => $drift( + $network->ntp()->now(), + ))) ->haltProcessVia(Halt::via(static fn($period) => Attempt::result( $network->ntp()->advance($period), ))) @@ -47,7 +53,8 @@ public static function of( static fn($command) => $processes->run($command), )) ->useHttpTransport(Transport::via( - static fn($request) => $network + static fn($request) => $drift + ->reset($network) ->http($request) ->match( static fn($response) => match ($response->statusCode()->range()) { From c6f13d37263b6194c64e69128de96abbdd96fe93 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 30 Nov 2025 18:18:07 +0100 Subject: [PATCH 12/34] add network latency simulation --- proofs/cluster.php | 64 ++++++++++++++++++++++++++++++ src/Cluster.php | 24 ++++++++++- src/Network/Latency.php | 38 ++++++++++++++++++ src/Simulation/Network.php | 22 ++++++++-- src/Simulation/Network/Latency.php | 49 +++++++++++++++++++++++ 5 files changed, 192 insertions(+), 5 deletions(-) create mode 100644 src/Network/Latency.php create mode 100644 src/Simulation/Network/Latency.php diff --git a/proofs/cluster.php b/proofs/cluster.php index 4e50477..2d9b879 100644 --- a/proofs/cluster.php +++ b/proofs/cluster.php @@ -4,6 +4,7 @@ use Innmind\Testing\{ Machine, Cluster, + Network, }; use Innmind\Server\Control\Server\Command; use Innmind\HttpTransport\ConnectionFailed; @@ -476,4 +477,67 @@ static function($assert, $drift) { $assert->same($remote, $resynced); }, ); + + yield proof( + 'Network latencies', + given( + PointInTime::any(), + // Below the second of latency it's hard to assert it's correctly + // applied as the clock still advances. So doing time math at this + // level of precision regularly fails with an off by one error. + Set::integers()->between(1_000, 3_000), + Set::integers()->between(1_000, 3_000), + ), + static function($assert, $start, $in, $out) { + $remote = Machine::new('remote.dev') + ->listenHttp( + static fn($request, $os) => Attempt::result(Response::of( + StatusCode::ok, + $request->protocolVersion(), + )), + ); + $local = Machine::new('local.dev') + ->listenHttp( + static fn($request, $os) => $os + ->remote() + ->http()(Request::of( + Url::of('http://remote.dev/'), + Method::get, + ProtocolVersion::v11, + )) + ->attempt(static fn() => new RuntimeException('Failed to access remote server')) + ->map(static fn($success) => $success->response()->body()) + ->map(static fn($body) => Response::of( + StatusCode::ok, + $request->protocolVersion(), + null, + Content::ofString($os->clock()->now()->format(Format::iso8601())), + )), + ); + $cluster = Cluster::new() + ->add($local) + ->add($remote) + ->startClockAt($start) + ->withNetworkLatency(Network\Latency::of($in, $out)) + ->boot(); + + $now = $cluster + ->http(Request::of( + Url::of('http://local.dev/'), + Method::get, + ProtocolVersion::v11, + )) + ->unwrap() + ->body() + ->toString(); + + $assert->same( + $start + ->changeOffset(Offset::utc()) + ->goForward(Period::millisecond($in + $in + $out)) // 2 $in as we make call local.dev over http + ->format(Format::iso8601()), + $now, + ); + }, + ); }; diff --git a/src/Cluster.php b/src/Cluster.php index 680849c..8ac3252 100644 --- a/src/Cluster.php +++ b/src/Cluster.php @@ -16,6 +16,7 @@ final class Cluster private function __construct( private ?PointInTime $start, private Set $machines, + private Network\Latency $latency, ) { } @@ -25,7 +26,11 @@ private function __construct( #[\NoDiscard] public static function new(): self { - return new self(null, Set::of()); + return new self( + null, + Set::of(), + Network\Latency::of(), + ); } /** @@ -37,6 +42,7 @@ public function startClockAt(PointInTime $date): self return new self( $date, $this->machines, + $this->latency, ); } @@ -49,6 +55,20 @@ public function add(Machine $machine): self return new self( $this->start, ($this->machines)($machine), + $this->latency, + ); + } + + /** + * @psalm-mutation-free + */ + #[\NoDiscard] + public function withNetworkLatency(Network\Latency $latency): self + { + return new self( + $this->start, + $this->machines, + $latency, ); } @@ -56,7 +76,7 @@ public function add(Machine $machine): self public function boot(): Simulation\Cluster { $ntp = Simulation\NTPServer::new($this->start); - $network = Simulation\Network::new($ntp); + $network = Simulation\Network::new($ntp, $this->latency); $_ = $this->machines->foreach( static fn($machine) => $machine->boot($network), ); diff --git a/src/Network/Latency.php b/src/Network/Latency.php new file mode 100644 index 0000000..ce8d175 --- /dev/null +++ b/src/Network/Latency.php @@ -0,0 +1,38 @@ +> $latencies + */ + private function __construct( + private array $latencies, + ) { + } + + /** + * The latencies are expressed in milliseconds + * + * @psalm-pure + * @no-named-arguments + * + * @param int<0, max> ...$latencies + */ + public static function of(int ...$latencies): self + { + return new self($latencies); + } + + public function asState(): Network\Latency + { + return Network\Latency::of($this->latencies); + } +} diff --git a/src/Simulation/Network.php b/src/Simulation/Network.php index 9dc1171..4b68ab1 100644 --- a/src/Simulation/Network.php +++ b/src/Simulation/Network.php @@ -3,7 +3,10 @@ namespace Innmind\Testing\Simulation; -use Innmind\Testing\Exception\CouldNotResolveHost; +use Innmind\Testing\{ + Network\Latency, + Exception\CouldNotResolveHost, +}; use Innmind\Http\{ Request, Response, @@ -21,14 +24,16 @@ final class Network private function __construct( private Map $machines, private NTPServer $ntp, + private Network\Latency $latency, ) { } - public static function new(NTPServer $ntp): self + public static function new(NTPServer $ntp, Latency $latency): self { return new self( Map::of(), $ntp, + $latency->asState(), ); } @@ -58,12 +63,23 @@ public function ntp(): NTPServer */ public function http(Request $request): Attempt { + ($this->latency)($this->ntp); $host = $request->url()->authority()->host()->toString(); return $this ->machines ->get($host) ->attempt(static fn() => new CouldNotResolveHost($host)) - ->flatMap(static fn($machine) => $machine->http($request)); + ->flatMap(static fn($machine) => $machine->http($request)) + ->map(function($response) { + ($this->latency)($this->ntp); + + return $response; + }) + ->mapError(function($error) { + ($this->latency)($this->ntp); + + return $error; + }); } } diff --git a/src/Simulation/Network/Latency.php b/src/Simulation/Network/Latency.php new file mode 100644 index 0000000..a0daa20 --- /dev/null +++ b/src/Simulation/Network/Latency.php @@ -0,0 +1,49 @@ +> $latencies + */ + private function __construct( + private array $latencies, + private ?int $accumulated = null, + ) { + } + + public function __invoke(NTPServer $ntp): void + { + if (\count($this->latencies) === 0) { + return; + } + + /** @var int<0, max>|false */ + $latency = \current($this->latencies); + + if (!\is_int($latency)) { + $latency = \reset($this->latencies); + } + + \next($this->latencies); + + $ntp->advance(Period::millisecond($latency)); + } + + /** + * @psalm-pure + * + * @param list> $latencies + */ + public static function of(array $latencies): self + { + return new self($latencies); + } +} From 45cd29c5413e0c1baa93dd96cb117e2ddfe03272 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Tue, 2 Dec 2025 12:22:18 +0100 Subject: [PATCH 13/34] allow to configure the machine OS --- proofs/cluster.php | 55 ++++++++++++++++++++++++++++++++++++++ src/Machine.php | 29 +++++++++++++++++++- src/Simulation/Machine.php | 17 ++++++++---- 3 files changed, 95 insertions(+), 6 deletions(-) diff --git a/proofs/cluster.php b/proofs/cluster.php index 2d9b879..1bbdd83 100644 --- a/proofs/cluster.php +++ b/proofs/cluster.php @@ -540,4 +540,59 @@ static function($assert, $start, $in, $out) { ); }, ); + + yield proof( + 'Machine OS is configurable', + given( + // France was on UTC time for part of 20th century + PointInTime::after('2000-01-01'), + ), + static function($assert, $start) { + $local = Machine::new('local.dev') + ->listenHttp( + static fn($request, $os) => Attempt::result(Response::of( + StatusCode::ok, + $request->protocolVersion(), + null, + Content::ofString(\sprintf( + '%s|%s', + $os->clock()->now()->format(Format::iso8601()), + $os->clock()->now()->changeOffset(Offset::utc())->format(Format::iso8601()), + )), + )), + ) + ->configureOperatingSystem( + static fn($config) => $config->mapClock( + static fn($clock) => $clock->switch( + static fn($timezones) => $timezones->europe()->paris(), + ), + ), + ); + $cluster = Cluster::new() + ->startClockAt($start) + ->add($local) + ->boot(); + + $response = $cluster + ->http(Request::of( + Url::of('http://local.dev/'), + Method::get, + ProtocolVersion::v11, + )) + ->unwrap() + ->body() + ->toString(); + [$paris, $utc] = \explode('|', $response); + + $assert + ->expected( + $start + ->changeOffset(Offset::utc()) + ->format(Format::iso8601()), + ) + ->same($utc) + ->not() + ->same($paris); + }, + ); }; diff --git a/src/Machine.php b/src/Machine.php index 899588a..28a1484 100644 --- a/src/Machine.php +++ b/src/Machine.php @@ -4,7 +4,10 @@ namespace Innmind\Testing; use Innmind\Testing\Machine\ProcessBuilder; -use Innmind\OperatingSystem\OperatingSystem; +use Innmind\OperatingSystem\{ + OperatingSystem, + Config, +}; use Innmind\Server\Control\Server\Command; use Innmind\Http\{ ServerRequest, @@ -23,12 +26,14 @@ final class Machine * @param non-empty-list $domains * @param Map $executables * @param Map, callable(ServerRequest, OperatingSystem): Attempt> $http + * @param \Closure(Config): Config $configureOS */ private function __construct( private array $domains, private Map $executables, private Map $http, private Machine\Clock\Drift $drift, + private \Closure $configureOS, ) { } @@ -46,6 +51,7 @@ public static function new( Map::of(), Map::of(), Machine\Clock\Drift::of(), + static fn(Config $config) => $config, ); } @@ -65,6 +71,7 @@ public function install( ($this->executables)($executable, $builder), $this->http, $this->drift, + $this->configureOS, ); } @@ -84,6 +91,7 @@ public function listenHttp( $this->executables, ($this->http)($port, $handle), $this->drift, + $this->configureOS, ); } @@ -98,6 +106,24 @@ public function driftClockBy(Machine\Clock\Drift $drift): self $this->executables, $this->http, $drift, + $this->configureOS, + ); + } + + /** + * @psalm-mutation-free + * + * @param callable(Config): Config $map + */ + #[\NoDiscard] + public function configureOperatingSystem(callable $map): self + { + return new self( + $this->domains, + $this->executables, + $this->http, + $this->drift, + \Closure::fromCallable($map), ); } @@ -115,6 +141,7 @@ public function boot(Simulation\Network $network): void $this->executables, $this->http, $this->drift, + $this->configureOS, ), ); } diff --git a/src/Simulation/Machine.php b/src/Simulation/Machine.php index fa335a1..4811fa8 100644 --- a/src/Simulation/Machine.php +++ b/src/Simulation/Machine.php @@ -7,7 +7,10 @@ ProcessBuilder, Clock, }; -use Innmind\OperatingSystem\OperatingSystem; +use Innmind\OperatingSystem\{ + OperatingSystem, + Config, +}; use Innmind\Server\Control\Server\Command; use Innmind\Http\{ ServerRequest, @@ -37,6 +40,7 @@ private function __construct( /** * @param Map $executables * @param Map, callable(ServerRequest, OperatingSystem): Attempt> $http + * @param \Closure(Config): Config $configureOS */ #[\NoDiscard] public static function new( @@ -44,16 +48,19 @@ public static function new( Map $executables, Map $http, Clock\Drift $drift, + \Closure $configureOS, ): self { $os = Machine\OS::new(); $processes = Machine\Processes::new( $os, $executables, ); - $os->boot(OperatingSystem::new(Machine\Config::of( - $network, - $processes, - $drift, + $os->boot(OperatingSystem::new($configureOS( + Machine\Config::of( + $network, + $processes, + $drift, + ), ))); return new self( From df276679ed1316802acfedb10351f82dad920267 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Tue, 2 Dec 2025 12:22:26 +0100 Subject: [PATCH 14/34] remove implemented todo --- src/Machine.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Machine.php b/src/Machine.php index 28a1484..fe303f4 100644 --- a/src/Machine.php +++ b/src/Machine.php @@ -128,7 +128,6 @@ public function configureOperatingSystem(callable $map): self } // todo add environment variables - // todo add clock drift // todo map($this): self // todo add crontab ? From 8e8a05101081313c1da226e4d1ecf18c1fa6fce4 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Tue, 2 Dec 2025 12:25:30 +0100 Subject: [PATCH 15/34] fix precision of time drift due to underlying real clock that still advances --- proofs/cluster.php | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/proofs/cluster.php b/proofs/cluster.php index 1bbdd83..358a717 100644 --- a/proofs/cluster.php +++ b/proofs/cluster.php @@ -402,13 +402,15 @@ static function($assert) { yield proof( 'Time can drift between machines', given( + // Below the second of drift it's hard to assert it's correctly + // applied as the clock still advances. So doing time math at this + // level of precision regularly fails with an off by one error. Set::either( - Set::integers()->between(10, 1_000), - Set::integers()->between(-1_000, -10), + Set::integers()->between(1_000, 3_000), + Set::integers()->between(-3_000, -1_000), ), ), static function($assert, $drift) { - $format = Format::of('Y-m-dTH:i:s.v'); $remote = Machine::new('remote.dev') ->listenHttp( static fn($request, $os) => Attempt::result(Response::of( @@ -418,7 +420,7 @@ static function($assert, $drift) { Content::ofString(\sprintf( '%s|%s', $request->body()->toString(), - $os->clock()->now()->format($format), + $os->clock()->now()->format(Format::iso8601()), )), )), ); @@ -434,7 +436,7 @@ static function($assert, $drift) { Headers::of( Date::of($os->clock()->now()), // to force accessing the second drift ), - Content::ofString($os->clock()->now()->format($format)), + Content::ofString($os->clock()->now()->format(Format::iso8601())), )) ->attempt(static fn() => new RuntimeException('Failed to access remote server')) ->map(static fn($success) => $success->response()->body()) @@ -445,7 +447,7 @@ static function($assert, $drift) { Content::ofString(\sprintf( '%s|%s', $body->toString(), - $os->clock()->now()->format($format), + $os->clock()->now()->format(Format::iso8601()), )), )), ); From 9e5fb0aa62f7d51e7d881aa7de53454fe2eb0eda Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Tue, 2 Dec 2025 12:44:06 +0100 Subject: [PATCH 16/34] add machine environment variables --- proofs/cluster.php | 115 +++++++++++++++++++++++++++ src/Machine.php | 33 ++++++-- src/Simulation/Machine.php | 19 +++-- src/Simulation/Machine/Processes.php | 17 ++-- 4 files changed, 169 insertions(+), 15 deletions(-) diff --git a/proofs/cluster.php b/proofs/cluster.php index 358a717..98246bb 100644 --- a/proofs/cluster.php +++ b/proofs/cluster.php @@ -597,4 +597,119 @@ static function($assert, $start) { ->same($paris); }, ); + + yield proof( + 'HTTP app has access to machine environment variables', + given( + Set::strings(), + Set::strings(), + ), + static function($assert, $key, $value) { + $local = Machine::new('local.dev') + ->listenHttp( + static fn($request, $_, $environment) => Attempt::result(Response::of( + StatusCode::ok, + $request->protocolVersion(), + null, + Content::ofString(\implode( + '', + $environment + ->map(static fn($key, $value) => $key.$value) + ->values() + ->toList(), + )), + )), + ) + ->withEnvironment($key, $value); + $cluster = Cluster::new() + ->add($local) + ->boot(); + + $response = $cluster + ->http(Request::of( + Url::of('http://local.dev/'), + Method::get, + ProtocolVersion::v11, + )) + ->unwrap(); + + $assert->same( + $key.$value, + $response->body()->toString(), + ); + }, + ); + + yield proof( + 'CLI app has access to machine environment variables', + given( + Set::strings(), + Set::strings(), + ), + static function($assert, $key, $value) { + $local = Machine::new('local.dev') + ->install( + 'foo', + static function( + $command, + $builder, + $_, + $environment, + ) use ($assert) { + $assert->same( + "foo 'display' '--option'", + $command->toString(), + ); + + return $builder->success([[ + \implode( + '', + $environment + ->map(static fn($key, $value) => $key.$value) + ->values() + ->toList(), + ), + 'output', + ]]); + }, + ) + ->listenHttp( + static fn($request, $os) => Attempt::result(Response::of( + StatusCode::ok, + $request->protocolVersion(), + null, + Content::ofChunks( + $os + ->control() + ->processes() + ->execute( + Command::foreground('foo') + ->withArgument('display') + ->withOption('option'), + ) + ->unwrap() + ->output() + ->map(static fn($chunk) => $chunk->data()), + ), + )), + ) + ->withEnvironment($key, $value); + $cluster = Cluster::new() + ->add($local) + ->boot(); + + $response = $cluster + ->http(Request::of( + Url::of('http://local.dev/'), + Method::get, + ProtocolVersion::v11, + )) + ->unwrap(); + + $assert->same( + $key.$value, + $response->body()->toString(), + ); + }, + ); }; diff --git a/src/Machine.php b/src/Machine.php index fe303f4..89f2171 100644 --- a/src/Machine.php +++ b/src/Machine.php @@ -24,14 +24,16 @@ final class Machine * @psalm-mutation-free * * @param non-empty-list $domains - * @param Map $executables - * @param Map, callable(ServerRequest, OperatingSystem): Attempt> $http + * @param Map): ProcessBuilder> $executables + * @param Map, callable(ServerRequest, OperatingSystem, Map): Attempt> $http + * @param Map $environment * @param \Closure(Config): Config $configureOS */ private function __construct( private array $domains, private Map $executables, private Map $http, + private Map $environment, private Machine\Clock\Drift $drift, private \Closure $configureOS, ) { @@ -50,6 +52,7 @@ public static function new( [$domain, ...$domains], Map::of(), Map::of(), + Map::of(), Machine\Clock\Drift::of(), static fn(Config $config) => $config, ); @@ -59,7 +62,7 @@ public static function new( * @psalm-mutation-free * * @param non-empty-string $executable - * @param callable(Command, ProcessBuilder, OperatingSystem): ProcessBuilder $builder + * @param callable(Command, ProcessBuilder, OperatingSystem, Map): ProcessBuilder $builder */ #[\NoDiscard] public function install( @@ -70,6 +73,7 @@ public function install( $this->domains, ($this->executables)($executable, $builder), $this->http, + $this->environment, $this->drift, $this->configureOS, ); @@ -78,7 +82,7 @@ public function install( /** * @psalm-mutation-free * - * @param callable(ServerRequest, OperatingSystem): Attempt $handle + * @param callable(ServerRequest, OperatingSystem, Map): Attempt $handle * @param ?int<1, max> $port */ #[\NoDiscard] @@ -90,6 +94,23 @@ public function listenHttp( $this->domains, $this->executables, ($this->http)($port, $handle), + $this->environment, + $this->drift, + $this->configureOS, + ); + } + + /** + * @psalm-mutation-free + */ + #[\NoDiscard] + public function withEnvironment(string $key, string $value): self + { + return new self( + $this->domains, + $this->executables, + $this->http, + ($this->environment)($key, $value), $this->drift, $this->configureOS, ); @@ -105,6 +126,7 @@ public function driftClockBy(Machine\Clock\Drift $drift): self $this->domains, $this->executables, $this->http, + $this->environment, $drift, $this->configureOS, ); @@ -122,12 +144,12 @@ public function configureOperatingSystem(callable $map): self $this->domains, $this->executables, $this->http, + $this->environment, $this->drift, \Closure::fromCallable($map), ); } - // todo add environment variables // todo map($this): self // todo add crontab ? @@ -139,6 +161,7 @@ public function boot(Simulation\Network $network): void $network, $this->executables, $this->http, + $this->environment, $this->drift, $this->configureOS, ), diff --git a/src/Simulation/Machine.php b/src/Simulation/Machine.php index 4811fa8..18f7f86 100644 --- a/src/Simulation/Machine.php +++ b/src/Simulation/Machine.php @@ -27,19 +27,21 @@ final class Machine { /** - * @param Map> $http + * @param Map): Attempt> $http + * @param Map $environment */ private function __construct( private Machine\OS $os, private Machine\Processes $processes, private Map $http, - // private Map $environment, todo + private Map $environment, ) { } /** - * @param Map $executables - * @param Map, callable(ServerRequest, OperatingSystem): Attempt> $http + * @param Map): ProcessBuilder> $executables + * @param Map, callable(ServerRequest, OperatingSystem, Map): Attempt> $http + * @param Map $environment * @param \Closure(Config): Config $configureOS */ #[\NoDiscard] @@ -47,6 +49,7 @@ public static function new( Network $network, Map $executables, Map $http, + Map $environment, Clock\Drift $drift, \Closure $configureOS, ): self { @@ -54,6 +57,7 @@ public static function new( $processes = Machine\Processes::new( $os, $executables, + $environment, ); $os->boot(OperatingSystem::new($configureOS( Machine\Config::of( @@ -67,6 +71,7 @@ public static function new( $os, $processes, $http, + $environment, ); } @@ -104,7 +109,11 @@ public function http(Request $request): Attempt ->http ->get($value) ->attempt(static fn() => new \RuntimeException('Connection timeout')) // todo inject fake timeout in ntp server ? - ->flatMap(fn($http) => $http($serverRequest, $this->os->unwrap())) + ->flatMap(fn($http) => $http( + $serverRequest, + $this->os->unwrap(), + $this->environment, + )) ->map(static fn($response) => Response::of( $response->statusCode(), $response->protocolVersion(), diff --git a/src/Simulation/Machine/Processes.php b/src/Simulation/Machine/Processes.php index 35771c7..80b49e5 100644 --- a/src/Simulation/Machine/Processes.php +++ b/src/Simulation/Machine/Processes.php @@ -17,22 +17,28 @@ final class Processes { /** - * @param Map $executables + * @param Map): ProcessBuilder> $executables + * @param Map $environment * @param int<2, max> $lastPid */ private function __construct( private OS $os, private Map $executables, + private Map $environment, private int $lastPid = 2, ) { } /** - * @param Map $executables + * @param Map): ProcessBuilder> $executables + * @param Map $environment */ - public static function new(OS $os, Map $executables): self - { - return new self($os, $executables); + public static function new( + OS $os, + Map $executables, + Map $environment, + ): self { + return new self($os, $executables, $environment); } /** @@ -66,6 +72,7 @@ public function run(Command $command): Attempt $command, ProcessBuilder::new(++$this->lastPid), $this->os->unwrap(), + $this->environment, )->build()); } } From 99ad23da891dacbd8cfb99320ee41645d0ac74e9 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Tue, 2 Dec 2025 12:47:56 +0100 Subject: [PATCH 17/34] add Machine::map() --- src/Machine.php | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/Machine.php b/src/Machine.php index 89f2171..c8e0e0f 100644 --- a/src/Machine.php +++ b/src/Machine.php @@ -150,7 +150,18 @@ public function configureOperatingSystem(callable $map): self ); } - // todo map($this): self + /** + * @psalm-mutation-free + * + * @param callable(self): self $map + */ + #[\NoDiscard] + public function map(callable $map): self + { + /** @psalm-suppress ImpureFunctionCall */ + return $map($this); + } + // todo add crontab ? public function boot(Simulation\Network $network): void From 14cb6e9f1e19e66989ed778314b65fc035f5563a Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Tue, 2 Dec 2025 13:16:09 +0100 Subject: [PATCH 18/34] wrap http/cli apps in objects to allow different strategies to define them --- proofs/cluster.php | 103 ++++++++++++++------------- src/Machine.php | 32 +++------ src/Machine/CLI.php | 49 +++++++++++++ src/Machine/HTTP.php | 51 +++++++++++++ src/Simulation/Machine.php | 12 ++-- src/Simulation/Machine/Processes.php | 12 ++-- 6 files changed, 177 insertions(+), 82 deletions(-) create mode 100644 src/Machine/CLI.php create mode 100644 src/Machine/HTTP.php diff --git a/proofs/cluster.php b/proofs/cluster.php index 98246bb..d24b45c 100644 --- a/proofs/cluster.php +++ b/proofs/cluster.php @@ -40,13 +40,13 @@ ), static function($assert, $start) { $local = Machine::new('local.dev') - ->listenHttp( - static fn($request, $os) => Attempt::result(Response::of( + ->listen( + Machine\HTTP::of(static fn($request, $os) => Attempt::result(Response::of( StatusCode::ok, $request->protocolVersion(), null, Content::ofString($os->clock()->now()->format(Format::iso8601())), - )), + ))), ); $cluster = Cluster::new() ->startClockAt($start) @@ -78,8 +78,8 @@ static function($assert, $start) { ), static function($assert, $start, $seconds) { $local = Machine::new('local.dev') - ->listenHttp( - static fn($request, $os) => $os + ->listen( + Machine\HTTP::of(static fn($request, $os) => $os ->process() ->halt(Period::second($seconds)) ->map(static fn() => Response::of( @@ -88,6 +88,7 @@ static function($assert, $start, $seconds) { null, Content::ofString($os->clock()->now()->format(Format::iso8601())), )), + ), ); $cluster = Cluster::new() ->startClockAt($start) @@ -127,7 +128,7 @@ static function($assert, $start) { $local = Machine::new('local.dev') ->install( 'foo', - static function( + Machine\CLI::of(static function( $command, $builder, $os, @@ -142,10 +143,10 @@ static function( $os->clock()->now()->format(Format::iso8601()), 'output', ]]); - }, + }), ) - ->listenHttp( - static fn($request, $os) => Attempt::result(Response::of( + ->listen( + Machine\HTTP::of(static fn($request, $os) => Attempt::result(Response::of( StatusCode::ok, $request->protocolVersion(), null, @@ -162,7 +163,7 @@ static function( ->output() ->map(static fn($chunk) => $chunk->data()), ), - )), + ))), ); $cluster = Cluster::new() ->startClockAt($start) @@ -195,8 +196,8 @@ static function( ), static function($assert, $input, $output) { $remote = Machine::new('remote.dev') - ->listenHttp( - static function($request) use ($assert, $input, $output) { + ->listen( + Machine\HTTP::of(static function($request) use ($assert, $input, $output) { $assert->same($input, $request->body()->toString()); return Attempt::result(Response::of( @@ -205,11 +206,11 @@ static function($request) use ($assert, $input, $output) { null, Content::ofString($output), )); - }, + }), ); $local = Machine::new('local.dev') - ->listenHttp( - static fn($request, $os) => $os + ->listen( + Machine\HTTP::of(static fn($request, $os) => $os ->remote() ->http()(Request::of( Url::of('http://remote.dev/'), @@ -220,6 +221,7 @@ static function($request) use ($assert, $input, $output) { )) ->attempt(static fn() => new RuntimeException('Failed to access remote server')) ->map(static fn($success) => $success->response()), + ), ); $cluster = Cluster::new() ->add($local) @@ -249,8 +251,8 @@ static function($request) use ($assert, $input, $output) { static function($assert, $output) { $called = 0; $remote = Machine::new('remote.dev') - ->listenHttp( - static function($request) use ($output, &$called) { + ->listen( + Machine\HTTP::of(static function($request) use ($output, &$called) { return Attempt::result(Response::of( StatusCode::ok, $request->protocolVersion(), @@ -261,11 +263,11 @@ static function($request) use ($output, &$called) { yield Str::of($output); })), )); - }, + }), ); $local = Machine::new('local.dev') - ->listenHttp( - static fn($request, $os) => $os + ->listen( + Machine\HTTP::of(static fn($request, $os) => $os ->remote() ->http()(Request::of( Url::of('http://remote.dev/'), @@ -280,6 +282,7 @@ static function($request) use ($output, &$called) { null, Content::ofString($body->toString().$body->toString()), )), + ), ); $cluster = Cluster::new() ->add($local) @@ -306,17 +309,17 @@ static function($request) use ($output, &$called) { 'Machines do not use the same operating system instance', static function($assert) { $remote = Machine::new('remote.dev') - ->listenHttp( - static fn($request, $os) => Attempt::result(Response::of( + ->listen( + Machine\HTTP::of(static fn($request, $os) => Attempt::result(Response::of( StatusCode::ok, $request->protocolVersion(), null, Content::ofString(\spl_object_hash($os)), - )), + ))), ); $local = Machine::new('local.dev') - ->listenHttp( - static fn($request, $os) => $os + ->listen( + Machine\HTTP::of(static fn($request, $os) => $os ->remote() ->http()(Request::of( Url::of('http://remote.dev/'), @@ -331,6 +334,7 @@ static function($assert) { null, Content::ofString(\spl_object_hash($os).'|'.$body->toString()), )), + ), ); $cluster = Cluster::new() ->add($local) @@ -359,8 +363,8 @@ static function($assert) { 'Machine get an error when accessing unknown machine over HTTP', static function($assert) { $local = Machine::new('local.dev') - ->listenHttp( - static fn($request, $os) => $os + ->listen( + Machine\HTTP::of(static fn($request, $os) => $os ->remote() ->http()(Request::of( Url::of('http://remote.dev/'), @@ -379,6 +383,7 @@ static function($assert) { default => Attempt::error(new RuntimeException), }, ), + ), ); $cluster = Cluster::new() ->add($local) @@ -412,8 +417,8 @@ static function($assert) { ), static function($assert, $drift) { $remote = Machine::new('remote.dev') - ->listenHttp( - static fn($request, $os) => Attempt::result(Response::of( + ->listen( + Machine\HTTP::of(static fn($request, $os) => Attempt::result(Response::of( StatusCode::ok, $request->protocolVersion(), null, @@ -422,12 +427,12 @@ static function($assert, $drift) { $request->body()->toString(), $os->clock()->now()->format(Format::iso8601()), )), - )), + ))), ); $local = Machine::new('local.dev') ->driftClockBy(Machine\Clock\Drift::of(0, $drift)) - ->listenHttp( - static fn($request, $os) => $os + ->listen( + Machine\HTTP::of(static fn($request, $os) => $os ->remote() ->http()(Request::of( Url::of('http://remote.dev/'), @@ -450,6 +455,7 @@ static function($assert, $drift) { $os->clock()->now()->format(Format::iso8601()), )), )), + ), ); $cluster = Cluster::new() ->add($local) @@ -492,15 +498,15 @@ static function($assert, $drift) { ), static function($assert, $start, $in, $out) { $remote = Machine::new('remote.dev') - ->listenHttp( - static fn($request, $os) => Attempt::result(Response::of( + ->listen( + Machine\HTTP::of(static fn($request, $os) => Attempt::result(Response::of( StatusCode::ok, $request->protocolVersion(), - )), + ))), ); $local = Machine::new('local.dev') - ->listenHttp( - static fn($request, $os) => $os + ->listen( + Machine\HTTP::of(static fn($request, $os) => $os ->remote() ->http()(Request::of( Url::of('http://remote.dev/'), @@ -515,6 +521,7 @@ static function($assert, $start, $in, $out) { null, Content::ofString($os->clock()->now()->format(Format::iso8601())), )), + ), ); $cluster = Cluster::new() ->add($local) @@ -551,8 +558,8 @@ static function($assert, $start, $in, $out) { ), static function($assert, $start) { $local = Machine::new('local.dev') - ->listenHttp( - static fn($request, $os) => Attempt::result(Response::of( + ->listen( + Machine\HTTP::of(static fn($request, $os) => Attempt::result(Response::of( StatusCode::ok, $request->protocolVersion(), null, @@ -561,7 +568,7 @@ static function($assert, $start) { $os->clock()->now()->format(Format::iso8601()), $os->clock()->now()->changeOffset(Offset::utc())->format(Format::iso8601()), )), - )), + ))), ) ->configureOperatingSystem( static fn($config) => $config->mapClock( @@ -606,8 +613,8 @@ static function($assert, $start) { ), static function($assert, $key, $value) { $local = Machine::new('local.dev') - ->listenHttp( - static fn($request, $_, $environment) => Attempt::result(Response::of( + ->listen( + Machine\HTTP::of(static fn($request, $_, $environment) => Attempt::result(Response::of( StatusCode::ok, $request->protocolVersion(), null, @@ -618,7 +625,7 @@ static function($assert, $key, $value) { ->values() ->toList(), )), - )), + ))), ) ->withEnvironment($key, $value); $cluster = Cluster::new() @@ -650,7 +657,7 @@ static function($assert, $key, $value) { $local = Machine::new('local.dev') ->install( 'foo', - static function( + Machine\CLI::of(static function( $command, $builder, $_, @@ -671,10 +678,10 @@ static function( ), 'output', ]]); - }, + }), ) - ->listenHttp( - static fn($request, $os) => Attempt::result(Response::of( + ->listen( + Machine\HTTP::of(static fn($request, $os) => Attempt::result(Response::of( StatusCode::ok, $request->protocolVersion(), null, @@ -691,7 +698,7 @@ static function( ->output() ->map(static fn($chunk) => $chunk->data()), ), - )), + ))), ) ->withEnvironment($key, $value); $cluster = Cluster::new() diff --git a/src/Machine.php b/src/Machine.php index c8e0e0f..50ba379 100644 --- a/src/Machine.php +++ b/src/Machine.php @@ -3,20 +3,8 @@ namespace Innmind\Testing; -use Innmind\Testing\Machine\ProcessBuilder; -use Innmind\OperatingSystem\{ - OperatingSystem, - Config, -}; -use Innmind\Server\Control\Server\Command; -use Innmind\Http\{ - ServerRequest, - Response, -}; -use Innmind\Immutable\{ - Attempt, - Map, -}; +use Innmind\OperatingSystem\Config; +use Innmind\Immutable\Map; final class Machine { @@ -24,8 +12,8 @@ final class Machine * @psalm-mutation-free * * @param non-empty-list $domains - * @param Map): ProcessBuilder> $executables - * @param Map, callable(ServerRequest, OperatingSystem, Map): Attempt> $http + * @param Map $executables + * @param Map, Machine\HTTP> $http * @param Map $environment * @param \Closure(Config): Config $configureOS */ @@ -62,16 +50,15 @@ public static function new( * @psalm-mutation-free * * @param non-empty-string $executable - * @param callable(Command, ProcessBuilder, OperatingSystem, Map): ProcessBuilder $builder */ #[\NoDiscard] public function install( string $executable, - callable $builder, + Machine\CLI $app, ): self { return new self( $this->domains, - ($this->executables)($executable, $builder), + ($this->executables)($executable, $app), $this->http, $this->environment, $this->drift, @@ -82,18 +69,17 @@ public function install( /** * @psalm-mutation-free * - * @param callable(ServerRequest, OperatingSystem, Map): Attempt $handle * @param ?int<1, max> $port */ #[\NoDiscard] - public function listenHttp( - callable $handle, + public function listen( + Machine\HTTP $app, ?int $port = null, ): self { return new self( $this->domains, $this->executables, - ($this->http)($port, $handle), + ($this->http)($port, $app), $this->environment, $this->drift, $this->configureOS, diff --git a/src/Machine/CLI.php b/src/Machine/CLI.php new file mode 100644 index 0000000..4fcd1c5 --- /dev/null +++ b/src/Machine/CLI.php @@ -0,0 +1,49 @@ +): ProcessBuilder $app + */ + private function __construct( + private \Closure $app, // todo support innmind/framework + ) { + } + + /** + * @param Map $environment + */ + public function __invoke( + Command $command, + ProcessBuilder $builder, + OperatingSystem $os, + Map $environment, + ): ProcessBuilder { + return ($this->app)( + $command, + $builder, + $os, + $environment, + ); + } + + /** + * @psalm-pure + * + * @param callable(Command, ProcessBuilder, OperatingSystem, Map): ProcessBuilder $app + */ + #[\NoDiscard] + public static function of(callable $app): self + { + return new self(\Closure::fromCallable($app)); + } +} diff --git a/src/Machine/HTTP.php b/src/Machine/HTTP.php new file mode 100644 index 0000000..2671710 --- /dev/null +++ b/src/Machine/HTTP.php @@ -0,0 +1,51 @@ +): Attempt $app + */ + private function __construct( + private \Closure $app, // todo support innmind/framework + ) { + } + + /** + * @param Map $environment + * + * @return Attempt + */ + public function __invoke( + ServerRequest $request, + OperatingSystem $os, + Map $environment, + ): Attempt { + return ($this->app)($request, $os, $environment); + } + + /** + * @psalm-pure + * + * @param callable(ServerRequest, OperatingSystem, Map): Attempt $app + */ + #[\NoDiscard] + public static function of(callable $app): self + { + return new self(\Closure::fromCallable($app)); + } +} diff --git a/src/Simulation/Machine.php b/src/Simulation/Machine.php index 18f7f86..f1bdd19 100644 --- a/src/Simulation/Machine.php +++ b/src/Simulation/Machine.php @@ -4,14 +4,14 @@ namespace Innmind\Testing\Simulation; use Innmind\Testing\Machine\{ - ProcessBuilder, Clock, + CLI, + HTTP, }; use Innmind\OperatingSystem\{ OperatingSystem, Config, }; -use Innmind\Server\Control\Server\Command; use Innmind\Http\{ ServerRequest, Request, @@ -27,7 +27,7 @@ final class Machine { /** - * @param Map): Attempt> $http + * @param Map $http * @param Map $environment */ private function __construct( @@ -39,8 +39,8 @@ private function __construct( } /** - * @param Map): ProcessBuilder> $executables - * @param Map, callable(ServerRequest, OperatingSystem, Map): Attempt> $http + * @param Map $executables + * @param Map, HTTP> $http * @param Map $environment * @param \Closure(Config): Config $configureOS */ @@ -109,7 +109,7 @@ public function http(Request $request): Attempt ->http ->get($value) ->attempt(static fn() => new \RuntimeException('Connection timeout')) // todo inject fake timeout in ntp server ? - ->flatMap(fn($http) => $http( + ->flatMap(fn($app) => $app( $serverRequest, $this->os->unwrap(), $this->environment, diff --git a/src/Simulation/Machine/Processes.php b/src/Simulation/Machine/Processes.php index 80b49e5..ebce053 100644 --- a/src/Simulation/Machine/Processes.php +++ b/src/Simulation/Machine/Processes.php @@ -3,8 +3,10 @@ namespace Innmind\Testing\Simulation\Machine; -use Innmind\Testing\Machine\ProcessBuilder; -use Innmind\OperatingSystem\OperatingSystem; +use Innmind\Testing\Machine\{ + ProcessBuilder, + CLI, +}; use Innmind\Server\Control\Server\{ Command, Process, @@ -17,7 +19,7 @@ final class Processes { /** - * @param Map): ProcessBuilder> $executables + * @param Map $executables * @param Map $environment * @param int<2, max> $lastPid */ @@ -30,7 +32,7 @@ private function __construct( } /** - * @param Map): ProcessBuilder> $executables + * @param Map $executables * @param Map $environment */ public static function new( @@ -68,7 +70,7 @@ public function run(Command $command): Attempt $executable, ), )) - ->map(fn($builder) => $builder( + ->map(fn($app) => $app( $command, ProcessBuilder::new(++$this->lastPid), $this->os->unwrap(), From b87627daae9edfb7ca82994de5f4d91a6add8895 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Tue, 2 Dec 2025 13:36:30 +0100 Subject: [PATCH 19/34] allow to access a machine over ssh --- proofs/cluster.php | 72 ++++++++++++++++++++++++++++++++++++++ src/Simulation/Cluster.php | 12 ++++++- src/Simulation/Machine.php | 12 ++++++- src/Simulation/Network.php | 16 +++++++++ 4 files changed, 110 insertions(+), 2 deletions(-) diff --git a/proofs/cluster.php b/proofs/cluster.php index d24b45c..7e9c6ed 100644 --- a/proofs/cluster.php +++ b/proofs/cluster.php @@ -5,6 +5,7 @@ Machine, Cluster, Network, + Exception\CouldNotResolveHost, }; use Innmind\Server\Control\Server\Command; use Innmind\HttpTransport\ConnectionFailed; @@ -28,6 +29,7 @@ Attempt, Sequence, Str, + Monoid\Concat, }; use Innmind\BlackBox\Set; use Fixtures\Innmind\TimeContinuum\PointInTime; @@ -404,6 +406,24 @@ static function($assert) { }, ); + yield test( + 'Cluster get an error when accessing unknown machine over SSH', + static function($assert) { + $cluster = Cluster::new()->boot(); + + $e = $cluster + ->ssh('local.dev') + ->match( + static fn() => null, + static fn($e) => $e, + ); + + $assert + ->object($e) + ->instance(CouldNotResolveHost::class); + }, + ); + yield proof( 'Time can drift between machines', given( @@ -719,4 +739,56 @@ static function($assert, $key, $value) { ); }, ); + + yield proof( + 'Machine is accessible over ssh', + given( + PointInTime::any(), + ), + static function($assert, $start) { + $local = Machine::new('local.dev') + ->install( + 'foo', + Machine\CLI::of(static function( + $command, + $builder, + $os, + ) use ($assert) { + $assert->same( + "foo 'display' '--option'", + $command->toString(), + ); + + return $builder->success([[ + $os->clock()->now()->format(Format::iso8601()), + 'output', + ]]); + }), + ); + $cluster = Cluster::new() + ->startClockAt($start) + ->add($local) + ->boot(); + + $output = $cluster + ->ssh('local.dev') + ->flatMap(static fn($run) => $run( + Command::foreground('foo') + ->withArgument('display') + ->withOption('option'), + )) + ->unwrap() + ->output() + ->map(static fn($chunk) => $chunk->data()) + ->fold(new Concat) + ->toString(); + + $assert->same( + $start + ->changeOffset(Offset::utc()) + ->format(Format::iso8601()), + $output, + ); + }, + ); }; diff --git a/src/Simulation/Cluster.php b/src/Simulation/Cluster.php index a6ad813..4bb15a2 100644 --- a/src/Simulation/Cluster.php +++ b/src/Simulation/Cluster.php @@ -3,6 +3,10 @@ namespace Innmind\Testing\Simulation; +use Innmind\Server\Control\Server\{ + Command, + Process, +}; use Innmind\Http\{ Request, Response, @@ -29,5 +33,11 @@ public function http(Request $request): Attempt return $this->network->http($request); } - // todo allow ssh + /** + * @return Attempt> + */ + public function ssh(string $host): Attempt + { + return $this->network->ssh($host); + } } diff --git a/src/Simulation/Machine.php b/src/Simulation/Machine.php index f1bdd19..1f22187 100644 --- a/src/Simulation/Machine.php +++ b/src/Simulation/Machine.php @@ -12,6 +12,10 @@ OperatingSystem, Config, }; +use Innmind\Server\Control\Server\{ + Command, + Process, +}; use Innmind\Http\{ ServerRequest, Request, @@ -127,5 +131,11 @@ public function http(Request $request): Attempt )); } - // todo allow ssh + /** + * @return Attempt + */ + public function run(Command $command): Attempt + { + return $this->processes->run($command); + } } diff --git a/src/Simulation/Network.php b/src/Simulation/Network.php index 4b68ab1..feec257 100644 --- a/src/Simulation/Network.php +++ b/src/Simulation/Network.php @@ -7,6 +7,10 @@ Network\Latency, Exception\CouldNotResolveHost, }; +use Innmind\Server\Control\Server\{ + Command, + Process, +}; use Innmind\Http\{ Request, Response, @@ -82,4 +86,16 @@ public function http(Request $request): Attempt return $error; }); } + + /** + * @return Attempt> + */ + public function ssh(string $host): Attempt + { + return $this + ->machines + ->get($host) + ->attempt(static fn() => new CouldNotResolveHost($host)) + ->map(static fn($machine) => static fn(Command $command) => $machine->run($command)); + } } From 0ac252331d7c6fa48af3ffb8daf9b73f90ec0393 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Tue, 2 Dec 2025 13:52:29 +0100 Subject: [PATCH 20/34] remove unused property --- src/Simulation/Network/Latency.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Simulation/Network/Latency.php b/src/Simulation/Network/Latency.php index a0daa20..0b65f2e 100644 --- a/src/Simulation/Network/Latency.php +++ b/src/Simulation/Network/Latency.php @@ -15,7 +15,6 @@ final class Latency */ private function __construct( private array $latencies, - private ?int $accumulated = null, ) { } From abc3fa05b13fb78af131283f1636cbf99214d898 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Thu, 4 Dec 2025 14:49:55 +0100 Subject: [PATCH 21/34] use mutable rings to circle throught drifts/latencies --- composer.json | 1 + src/Simulation/Machine/Clock/Drift.php | 52 ++++++++++---------------- src/Simulation/Network/Latency.php | 29 ++++++-------- 3 files changed, 32 insertions(+), 50 deletions(-) diff --git a/composer.json b/composer.json index b9a7078..b2edc94 100644 --- a/composer.json +++ b/composer.json @@ -18,6 +18,7 @@ "php": "~8.4", "innmind/foundation": "dev-next", "innmind/immutable": "dev-next", + "innmind/mutable": "dev-next", "innmind/http": "dev-next", "innmind/operating-system": "dev-next", "innmind/reflection": "dev-next", diff --git a/src/Simulation/Machine/Clock/Drift.php b/src/Simulation/Machine/Clock/Drift.php index 45a3607..04a05f6 100644 --- a/src/Simulation/Machine/Clock/Drift.php +++ b/src/Simulation/Machine/Clock/Drift.php @@ -8,49 +8,35 @@ PointInTime, Period, }; +use Innmind\Mutable\Ring; final class Drift { /** * @psalm-mutation-free * - * @param list $drifts + * @param Ring $drifts */ private function __construct( - private array $drifts, - private ?int $accumulated = null, + private Ring $drifts, + private int $accumulated = 0, ) { } public function __invoke(PointInTime $now): PointInTime { - if (\count($this->drifts) === 0) { - return $now; - } - - /** @var int|false */ - $drift = \current($this->drifts); - - if (!\is_int($drift)) { - $drift = \reset($this->drifts); - } - - \next($this->drifts); - - $this->accumulated = match ($this->accumulated) { - null => $drift, - default => $this->accumulated + $drift, - }; - - if ($this->accumulated === 0) { - return $now; - } - - if ($this->accumulated > 0) { - return $now->goForward(Period::millisecond($this->accumulated)); - } - - return $now->goBack(Period::millisecond($this->accumulated * -1)); + return $this + ->drifts + ->pull() + ->map(fn($drift) => $this->accumulated += $drift) + ->match( + static fn($period) => match (true) { + $period === 0 => $now, + $period > 0 => $now->goForward(Period::millisecond($period)), + $period < 0 => $now->goBack(Period::millisecond($period * -1)), + }, + static fn() => $now, + ); } /** @@ -60,7 +46,7 @@ public function __invoke(PointInTime $now): PointInTime */ public static function of(array $drifts): self { - return new self($drifts); + return new self(Ring::of(...$drifts)); } /** @@ -69,8 +55,8 @@ public static function of(array $drifts): self */ public function reset(Network $value): Network { - $this->accumulated = null; - \reset($this->drifts); + $this->accumulated = 0; + $this->drifts->reset(); return $value; } diff --git a/src/Simulation/Network/Latency.php b/src/Simulation/Network/Latency.php index 0b65f2e..fd177fd 100644 --- a/src/Simulation/Network/Latency.php +++ b/src/Simulation/Network/Latency.php @@ -5,35 +5,30 @@ use Innmind\Testing\Simulation\NTPServer; use Innmind\TimeContinuum\Period; +use Innmind\Mutable\Ring; final class Latency { /** * @psalm-mutation-free * - * @param list> $latencies + * @param Ring> $latencies */ private function __construct( - private array $latencies, + private Ring $latencies, ) { } public function __invoke(NTPServer $ntp): void { - if (\count($this->latencies) === 0) { - return; - } - - /** @var int<0, max>|false */ - $latency = \current($this->latencies); - - if (!\is_int($latency)) { - $latency = \reset($this->latencies); - } - - \next($this->latencies); - - $ntp->advance(Period::millisecond($latency)); + $this + ->latencies + ->pull() + ->map(Period::millisecond(...)) + ->match( + $ntp->advance(...), + static fn() => null, + ); } /** @@ -43,6 +38,6 @@ public function __invoke(NTPServer $ntp): void */ public static function of(array $latencies): self { - return new self($latencies); + return new self(Ring::of(...$latencies)); } } From 30e12f1eccce4b0689074be2e22ea5680fc1adfd Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Thu, 4 Dec 2025 14:51:22 +0100 Subject: [PATCH 22/34] allow to execute processes over ssh --- proofs/cluster.php | 73 +++++++++++++++++++++++++++++++ src/Simulation/Cluster.php | 5 ++- src/Simulation/Machine.php | 16 ++++++- src/Simulation/Machine/Config.php | 17 ++++--- src/Simulation/Network.php | 9 +--- 5 files changed, 106 insertions(+), 14 deletions(-) diff --git a/proofs/cluster.php b/proofs/cluster.php index 7e9c6ed..9e00ea2 100644 --- a/proofs/cluster.php +++ b/proofs/cluster.php @@ -791,4 +791,77 @@ static function($assert, $start) { ); }, ); + + yield proof( + 'Machine can execute processes on another machine over ssh', + given( + Set::strings(), + ), + static function($assert, $expected) { + $remote = Machine::new('remote.dev') + ->install( + 'bar', + Machine\CLI::of(static function( + $command, + $builder, + ) use ($assert, $expected) { + $assert->same( + "bar 'display' '--option'", + $command->toString(), + ); + + return $builder->success([[ + $expected, + 'output', + ]]); + }), + ); + $local = Machine::new('local.dev') + ->install( + 'foo', + Machine\CLI::of(static function( + $_, + $builder, + $os, + ) use ($assert) { + $output = $os + ->remote() + ->ssh(Url::of('ssh://watev@remote.dev/')) + ->processes() + ->execute( + Command::foreground('bar') + ->withArgument('display') + ->withOption('option'), + ) + ->unwrap() + ->output() + ->map(static fn($chunk) => [ + $chunk->data()->toString(), + $chunk->type()->name, + ]) + ->toList(); + + return $builder->success($output); + }), + ); + $cluster = Cluster::new() + ->add($local) + ->add($remote) + ->boot(); + + $output = $cluster + ->ssh('local.dev') + ->flatMap(static fn($run) => $run(Command::foreground('foo'))) + ->unwrap() + ->output() + ->map(static fn($chunk) => $chunk->data()) + ->fold(new Concat) + ->toString(); + + $assert->same( + $expected, + $output, + ); + }, + ); }; diff --git a/src/Simulation/Cluster.php b/src/Simulation/Cluster.php index 4bb15a2..81b94be 100644 --- a/src/Simulation/Cluster.php +++ b/src/Simulation/Cluster.php @@ -38,6 +38,9 @@ public function http(Request $request): Attempt */ public function ssh(string $host): Attempt { - return $this->network->ssh($host); + return $this + ->network + ->ssh($host) + ->map(static fn($machine) => static fn(Command $command) => $machine->run($command)); } } diff --git a/src/Simulation/Machine.php b/src/Simulation/Machine.php index 1f22187..ce35b0a 100644 --- a/src/Simulation/Machine.php +++ b/src/Simulation/Machine.php @@ -36,7 +36,9 @@ final class Machine */ private function __construct( private Machine\OS $os, + private Network $network, private Machine\Processes $processes, + private Machine\Clock\Drift $drift, private Map $http, private Map $environment, ) { @@ -57,6 +59,8 @@ public static function new( Clock\Drift $drift, \Closure $configureOS, ): self { + $drift = $drift->asState(); + $os = Machine\OS::new(); $processes = Machine\Processes::new( $os, @@ -73,7 +77,9 @@ public static function new( return new self( $os, + $network, $processes, + $drift, $http, $environment, ); @@ -134,8 +140,16 @@ public function http(Request $request): Attempt /** * @return Attempt */ - public function run(Command $command): Attempt + public function run(Command|Command\OverSsh $command): Attempt { + if ($command instanceof Command\OverSsh) { + return $this + ->drift + ->reset($this->network) + ->ssh($command->host()->toString()) + ->flatMap(static fn($machine) => $machine->run($command->command())); + } + return $this->processes->run($command); } } diff --git a/src/Simulation/Machine/Config.php b/src/Simulation/Machine/Config.php index 90ad95c..fc71fd4 100644 --- a/src/Simulation/Machine/Config.php +++ b/src/Simulation/Machine/Config.php @@ -5,11 +5,14 @@ use Innmind\Testing\{ Simulation\Network, - Machine\Clock\Drift, + Simulation\Machine\Clock\Drift, Exception\CouldNotResolveHost, }; use Innmind\OperatingSystem\Config as OSConfig; -use Innmind\Server\Control\Server; +use Innmind\Server\Control\{ + Server, + Server\Command, +}; use Innmind\HttpTransport\{ Transport, Information, @@ -40,8 +43,6 @@ public static function of( Processes $processes, Drift $drift, ): OSConfig { - $drift = $drift->asState(); - return OSConfig::new() ->withClock(Clock::via(static fn() => $drift( $network->ntp()->now(), @@ -50,7 +51,13 @@ public static function of( $network->ntp()->advance($period), ))) ->useServerControl(Server::via( - static fn($command) => $processes->run($command), + static fn($command) => match (true) { + $command instanceof Command => $processes->run($command), + default => $drift + ->reset($network) + ->ssh($command->host()->toString()) + ->flatMap(static fn($machine) => $machine->run($command->command())), + }, )) ->useHttpTransport(Transport::via( static fn($request) => $drift diff --git a/src/Simulation/Network.php b/src/Simulation/Network.php index feec257..5c76655 100644 --- a/src/Simulation/Network.php +++ b/src/Simulation/Network.php @@ -7,10 +7,6 @@ Network\Latency, Exception\CouldNotResolveHost, }; -use Innmind\Server\Control\Server\{ - Command, - Process, -}; use Innmind\Http\{ Request, Response, @@ -88,14 +84,13 @@ public function http(Request $request): Attempt } /** - * @return Attempt> + * @return Attempt */ public function ssh(string $host): Attempt { return $this ->machines ->get($host) - ->attempt(static fn() => new CouldNotResolveHost($host)) - ->map(static fn($machine) => static fn(Command $command) => $machine->run($command)); + ->attempt(static fn() => new CouldNotResolveHost($host)); } } From e473d021747f5d4762de824615a9e1c2d3c505c7 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Thu, 4 Dec 2025 17:27:55 +0100 Subject: [PATCH 23/34] add Cluster::map() --- src/Cluster.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/Cluster.php b/src/Cluster.php index 8ac3252..d89477d 100644 --- a/src/Cluster.php +++ b/src/Cluster.php @@ -72,6 +72,18 @@ public function withNetworkLatency(Network\Latency $latency): self ); } + /** + * @psalm-mutation-free + * + * @param callable(self): self $map + */ + #[\NoDiscard] + public function map(callable $map): self + { + /** @psalm-suppress ImpureFunctionCall */ + return $map($this); + } + #[\NoDiscard] public function boot(): Simulation\Cluster { From e3f2ba311a42b721256c8f744e5d1ddad91da2d3 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Thu, 4 Dec 2025 17:53:51 +0100 Subject: [PATCH 24/34] add piped commands support --- proofs/cluster.php | 73 ++++++++++++++++++++++++++++ src/Simulation/Machine/Processes.php | 58 +++++++++++++++++++--- 2 files changed, 123 insertions(+), 8 deletions(-) diff --git a/proofs/cluster.php b/proofs/cluster.php index 9e00ea2..922a53e 100644 --- a/proofs/cluster.php +++ b/proofs/cluster.php @@ -864,4 +864,77 @@ static function($assert, $expected) { ); }, ); + + yield proof( + 'Machine can execute piped commands', + given( + Set::strings(), + Set::strings(), + Set::strings(), + ), + static function($assert, $input, $intermediary, $expected) { + $local = Machine::new('local.dev') + ->install( + 'bar', + Machine\CLI::of(static function( + $command, + $builder, + ) use ($assert, $intermediary, $expected) { + $assert->same( + $intermediary, + $command->input()->match( + static fn($input) => $input->toString(), + static fn() => null, + ), + ); + + return $builder->success([[ + $expected, + 'output', + ]]); + }), + ) + ->install( + 'foo', + Machine\CLI::of(static function( + $command, + $builder, + ) use ($assert, $input, $intermediary) { + $assert->same( + $input, + $command->input()->match( + static fn($input) => $input->toString(), + static fn() => null, + ), + ); + + return $builder->success([[ + $intermediary, + 'output', + ]]); + }), + ); + $cluster = Cluster::new() + ->add($local) + ->boot(); + + $output = $cluster + ->ssh('local.dev') + ->flatMap(static fn($run) => $run( + Command::foreground('foo') + ->withInput(Content::ofString($input)) + ->pipe(Command::foreground('bar')), + )) + ->unwrap() + ->output() + ->map(static fn($chunk) => $chunk->data()) + ->fold(new Concat) + ->toString(); + + $assert->same( + $expected, + $output, + ); + }, + ); }; diff --git a/src/Simulation/Machine/Processes.php b/src/Simulation/Machine/Processes.php index ebce053..e3ddf04 100644 --- a/src/Simulation/Machine/Processes.php +++ b/src/Simulation/Machine/Processes.php @@ -11,6 +11,7 @@ Command, Process, }; +use Innmind\Filesystem\File\Content; use Innmind\Immutable\{ Map, Attempt, @@ -48,23 +49,64 @@ public static function new( */ public function run(Command $command): Attempt { - // todo build proper api in package + // todo handle timeouts, by hijacking the Process::halt() to make sure + // we don't go over the threshold ? (but how ?) + + return $this->dispatch($command->internal()); + } + + /** + * @return Attempt + */ + private function dispatch(Command\Implementation $command): Attempt + { + if ($command instanceof Command\Definition) { + return $this->doRun($command); + } + + if ($command instanceof Command\Pipe) { + return $this + ->dispatch($command->a()) + ->map( + static fn($process) => $process + ->output() + ->map(static fn($chunk) => $chunk->data()), + ) + ->map(Content::ofChunks(...)) + ->map(static fn($output) => $command->b()->withInput($output)) + ->flatMap($this->dispatch(...)); + } + + throw new \LogicException(\sprintf( + 'Unknown command implementation %s', + $command::class, + )); + } + + /** + * @return Attempt + */ + private function doRun(Command\Definition $command): Attempt + { + $executable = $command->executable(); /** + * @psalm-suppress MixedAgument * @psalm-suppress PossiblyNullFunctionCall - * @psalm-suppress UndefinedThisPropertyFetch - * @psalm-suppress MixedReturnStatement - * @var non-empty-string + * @psalm-suppress InaccessibleMethod + * @var Command */ - $executable = (\Closure::bind( - fn(): string => $this->executable, - $command, + $command = (\Closure::bind( + static fn(): Command => new Command($command), + null, Command::class, ))(); + // todo handle output redirection + return $this ->executables ->get($executable) - ->attempt(static fn() => new \RuntimeException( // todo return a failed process instead ? + ->attempt(static fn() => new \RuntimeException( // todo return a failed process with exit code 127 (zsh behaviour) \sprintf( 'Failed to start %s command', $executable, From d17371098d79547872ac998d25558b2a98cb30d1 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sat, 6 Dec 2025 14:36:59 +0100 Subject: [PATCH 25/34] allow to speed up time --- proofs/cluster.php | 49 ++++++++++++++++++++++++ src/Cluster.php | 27 ++++++++++++- src/Simulation/NTPServer.php | 10 ++++- src/Simulation/NTPServer/Clock.php | 9 ++++- src/Simulation/NTPServer/Clock/Speed.php | 42 ++++++++++++++++++++ 5 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 src/Simulation/NTPServer/Clock/Speed.php diff --git a/proofs/cluster.php b/proofs/cluster.php index 922a53e..952d015 100644 --- a/proofs/cluster.php +++ b/proofs/cluster.php @@ -120,6 +120,55 @@ static function($assert, $start, $seconds) { }, ); + yield proof( + 'Cluster time can be sped up', + given( + PointInTime::any(), + Set::integers()->between(2, 10), + ), + static function($assert, $start, $speed) { + $local = Machine::new('local.dev') + ->listen( + Machine\HTTP::of(static fn($request, $os) => $os + ->process() + ->halt(Period::second(1)) + ->map(static fn() => Response::of( + StatusCode::ok, + $request->protocolVersion(), + null, + Content::ofString($os->clock()->now()->format(Format::iso8601())), + )), + ), + ); + $cluster = Cluster::new() + ->startClockAt($start) + ->speedUpTimeBy($speed) + ->add($local) + ->boot(); + + $assert + ->time(static function() use ($assert, $cluster, $start, $speed) { + $response = $cluster + ->http(Request::of( + Url::of('http://local.dev/'), + Method::get, + ProtocolVersion::v11, + )) + ->unwrap(); + + $assert->same( + $start + ->changeOffset(Offset::utc()) + ->goForward(Period::second($speed)) + ->format(Format::iso8601()), + $response->body()->toString(), + ); + }) + ->inLessThan() + ->seconds(1); + }, + ); + yield proof( 'HTTP app can execute simulated process on the same machine', given( diff --git a/src/Cluster.php b/src/Cluster.php index d89477d..92bd0ef 100644 --- a/src/Cluster.php +++ b/src/Cluster.php @@ -12,11 +12,13 @@ final class Cluster * @psalm-mutation-free * * @param Set $machines + * @param ?int<2, 10> $clockSpeed */ private function __construct( private ?PointInTime $start, private Set $machines, private Network\Latency $latency, + private ?int $clockSpeed, ) { } @@ -30,6 +32,7 @@ public static function new(): self null, Set::of(), Network\Latency::of(), + null, ); } @@ -43,6 +46,23 @@ public function startClockAt(PointInTime $date): self $date, $this->machines, $this->latency, + $this->clockSpeed, + ); + } + + /** + * @psalm-mutation-free + * + * @param int<2, 10> $speed + */ + #[\NoDiscard] + public function speedUpTimeBy(int $speed): self + { + return new self( + $this->start, + $this->machines, + $this->latency, + $speed, ); } @@ -56,6 +76,7 @@ public function add(Machine $machine): self $this->start, ($this->machines)($machine), $this->latency, + $this->clockSpeed, ); } @@ -69,6 +90,7 @@ public function withNetworkLatency(Network\Latency $latency): self $this->start, $this->machines, $latency, + $this->clockSpeed, ); } @@ -87,7 +109,10 @@ public function map(callable $map): self #[\NoDiscard] public function boot(): Simulation\Cluster { - $ntp = Simulation\NTPServer::new($this->start); + $ntp = Simulation\NTPServer::new( + $this->start, + $this->clockSpeed, + ); $network = Simulation\Network::new($ntp, $this->latency); $_ = $this->machines->foreach( static fn($machine) => $machine->boot($network), diff --git a/src/Simulation/NTPServer.php b/src/Simulation/NTPServer.php index 003fa94..ec956e2 100644 --- a/src/Simulation/NTPServer.php +++ b/src/Simulation/NTPServer.php @@ -17,11 +17,17 @@ private function __construct( ) { } - public static function new(?PointInTime $start): self - { + /** + * @param ?int<2, 10> $clockSpeed + */ + public static function new( + ?PointInTime $start, + ?int $clockSpeed, + ): self { return new self(NTPServer\Clock::of( RealClock::live(), $start, + $clockSpeed, )); } diff --git a/src/Simulation/NTPServer/Clock.php b/src/Simulation/NTPServer/Clock.php index 6a53b2b..fea8e09 100644 --- a/src/Simulation/NTPServer/Clock.php +++ b/src/Simulation/NTPServer/Clock.php @@ -19,14 +19,20 @@ private function __construct( private RealClock $clock, private \Closure $delta, private ?Period $advance, + private Clock\Speed $speed, ) { } + /** + * @param ?int<2, 10> $clockSpeed + */ public static function of( RealClock $clock, ?PointInTime $now, + ?int $clockSpeed, ): self { $realNow = $clock->now(); + $speed = Clock\Speed::of($now ?? $realNow, $clockSpeed); if (\is_null($now)) { $move = static fn(PointInTime $now): PointInTime => $now; @@ -42,6 +48,7 @@ public static function of( $clock, $move, null, + $speed, ); } @@ -53,7 +60,7 @@ public function now(): PointInTime $now = $now->goForward($this->advance); } - return $now; + return ($this->speed)($now); } public function advance(Period $period): SideEffect diff --git a/src/Simulation/NTPServer/Clock/Speed.php b/src/Simulation/NTPServer/Clock/Speed.php new file mode 100644 index 0000000..fd3f59e --- /dev/null +++ b/src/Simulation/NTPServer/Clock/Speed.php @@ -0,0 +1,42 @@ + $multiplier + */ + private function __construct( + private PointInTime $previous, + private ?int $multiplier, + ) { + } + + public function __invoke(PointInTime $now): PointInTime + { + if (\is_null($this->multiplier)) { + return $now; + } + + $elapsed = $now->elapsedSince($this->previous)->asPeriod(); + $this->previous = $now; + + for ($i = 1; $i < $this->multiplier; $i++) { + $now = $now->goForward($elapsed); + } + + return $now; + } + + /** + * @param ?int<2, 10> $multiplier + */ + public static function of(PointInTime $previous, ?int $multiplier): self + { + return new self($previous, $multiplier); + } +} From d2665eaecd3ce1aad0e7e43faf36ec45d62185ff Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sat, 6 Dec 2025 14:54:35 +0100 Subject: [PATCH 26/34] add missing annotations --- src/Cluster.php | 3 +++ src/Exception/CouldNotResolveHost.php | 3 +++ src/Machine.php | 3 +++ src/Machine/CLI.php | 3 +++ src/Machine/Clock/Drift.php | 5 +++++ src/Machine/HTTP.php | 3 +++ src/Machine/ProcessBuilder.php | 18 ++++++++++++++++++ src/Network/Latency.php | 5 +++++ src/Simulation/Cluster.php | 6 ++++++ src/Simulation/Machine.php | 7 +++++++ src/Simulation/Machine/Clock/Drift.php | 5 +++++ src/Simulation/Machine/Config.php | 4 ++++ src/Simulation/Machine/OS.php | 12 ++++++++++++ src/Simulation/Machine/Processes.php | 10 ++++++++++ src/Simulation/NTPServer.php | 8 ++++++++ src/Simulation/NTPServer/Clock.php | 8 ++++++++ src/Simulation/NTPServer/Clock/Speed.php | 10 ++++++++++ src/Simulation/Network.php | 11 +++++++++++ src/Simulation/Network/Latency.php | 5 +++++ 19 files changed, 129 insertions(+) diff --git a/src/Cluster.php b/src/Cluster.php index 92bd0ef..3aa5365 100644 --- a/src/Cluster.php +++ b/src/Cluster.php @@ -106,6 +106,9 @@ public function map(callable $map): self return $map($this); } + /** + * @internal + */ #[\NoDiscard] public function boot(): Simulation\Cluster { diff --git a/src/Exception/CouldNotResolveHost.php b/src/Exception/CouldNotResolveHost.php index 11a53a9..8160955 100644 --- a/src/Exception/CouldNotResolveHost.php +++ b/src/Exception/CouldNotResolveHost.php @@ -3,6 +3,9 @@ namespace Innmind\Testing\Exception; +/** + * @internal + */ final class CouldNotResolveHost extends \RuntimeException { public function __construct(string $host) diff --git a/src/Machine.php b/src/Machine.php index 50ba379..b4e9755 100644 --- a/src/Machine.php +++ b/src/Machine.php @@ -150,6 +150,9 @@ public function map(callable $map): self // todo add crontab ? + /** + * @internal + */ public function boot(Simulation\Network $network): void { $network->with( diff --git a/src/Machine/CLI.php b/src/Machine/CLI.php index 4fcd1c5..f71e28f 100644 --- a/src/Machine/CLI.php +++ b/src/Machine/CLI.php @@ -20,8 +20,11 @@ private function __construct( } /** + * @internal + * * @param Map $environment */ + #[\NoDiscard] public function __invoke( Command $command, ProcessBuilder $builder, diff --git a/src/Machine/Clock/Drift.php b/src/Machine/Clock/Drift.php index 9b2d75a..8561d02 100644 --- a/src/Machine/Clock/Drift.php +++ b/src/Machine/Clock/Drift.php @@ -24,11 +24,16 @@ private function __construct( * @psalm-pure * @no-named-arguments */ + #[\NoDiscard] public static function of(int ...$drifts): self { return new self($drifts); } + /** + * @internal + */ + #[\NoDiscard] public function asState(): Clock\Drift { return Clock\Drift::of($this->drifts); diff --git a/src/Machine/HTTP.php b/src/Machine/HTTP.php index 2671710..930bdb4 100644 --- a/src/Machine/HTTP.php +++ b/src/Machine/HTTP.php @@ -26,10 +26,13 @@ private function __construct( } /** + * @internal + * * @param Map $environment * * @return Attempt */ + #[\NoDiscard] public function __invoke( ServerRequest $request, OperatingSystem $os, diff --git a/src/Machine/ProcessBuilder.php b/src/Machine/ProcessBuilder.php index 7f84f72..5e0a758 100644 --- a/src/Machine/ProcessBuilder.php +++ b/src/Machine/ProcessBuilder.php @@ -19,9 +19,14 @@ Str, }; +/** + * @todo move back to innmind/server-control + */ final class ProcessBuilder { /** + * @psalm-mutation-free + * * @param int<2, max> $pid */ private function __construct( @@ -32,15 +37,20 @@ private function __construct( /** * @internal + * @psalm-pure * * @param int<2, max> $pid */ + #[\NoDiscard] public static function new(int $pid): self { + /** @psalm-suppress ImpureMethodCall todo remove this line when in innmind/server-control */ return new self($pid, new Success(Sequence::of())); } /** + * @psalm-mutation-free + * * @param Sequence|list $output */ #[\NoDiscard] @@ -53,6 +63,8 @@ public function success(Sequence|array|null $output = null): self } /** + * @psalm-mutation-free + * * @param Sequence|list $output */ #[\NoDiscard] @@ -65,6 +77,8 @@ public function signaled(Sequence|array|null $output = null): self } /** + * @psalm-mutation-free + * * @param Sequence|list $output */ #[\NoDiscard] @@ -77,6 +91,8 @@ public function timedOut(Sequence|array|null $output = null): self } /** + * @psalm-mutation-free + * * @param int<1, 255> $exitCode * @param Sequence|list $output */ @@ -116,6 +132,8 @@ public function build(): Process } /** + * @psalm-pure + * * @param Sequence|list $output * * @return Sequence diff --git a/src/Network/Latency.php b/src/Network/Latency.php index ce8d175..3706c42 100644 --- a/src/Network/Latency.php +++ b/src/Network/Latency.php @@ -26,11 +26,16 @@ private function __construct( * * @param int<0, max> ...$latencies */ + #[\NoDiscard] public static function of(int ...$latencies): self { return new self($latencies); } + /** + * @internal + */ + #[\NoDiscard] public function asState(): Network\Latency { return Network\Latency::of($this->latencies); diff --git a/src/Simulation/Cluster.php b/src/Simulation/Cluster.php index 81b94be..7f25d59 100644 --- a/src/Simulation/Cluster.php +++ b/src/Simulation/Cluster.php @@ -20,6 +20,10 @@ private function __construct( ) { } + /** + * @internal + */ + #[\NoDiscard] public static function new(Network $network): self { return new self($network); @@ -28,6 +32,7 @@ public static function new(Network $network): self /** * @return Attempt */ + #[\NoDiscard] public function http(Request $request): Attempt { return $this->network->http($request); @@ -36,6 +41,7 @@ public function http(Request $request): Attempt /** * @return Attempt> */ + #[\NoDiscard] public function ssh(string $host): Attempt { return $this diff --git a/src/Simulation/Machine.php b/src/Simulation/Machine.php index ce35b0a..ea346cf 100644 --- a/src/Simulation/Machine.php +++ b/src/Simulation/Machine.php @@ -28,6 +28,9 @@ Attempt, }; +/** + * @internal + */ final class Machine { /** @@ -45,6 +48,8 @@ private function __construct( } /** + * @internal + * * @param Map $executables * @param Map, HTTP> $http * @param Map $environment @@ -88,6 +93,7 @@ public static function new( /** * @return Attempt */ + #[\NoDiscard] public function http(Request $request): Attempt { $port = $request->url()->authority()->port(); @@ -140,6 +146,7 @@ public function http(Request $request): Attempt /** * @return Attempt */ + #[\NoDiscard] public function run(Command|Command\OverSsh $command): Attempt { if ($command instanceof Command\OverSsh) { diff --git a/src/Simulation/Machine/Clock/Drift.php b/src/Simulation/Machine/Clock/Drift.php index 04a05f6..f870cfc 100644 --- a/src/Simulation/Machine/Clock/Drift.php +++ b/src/Simulation/Machine/Clock/Drift.php @@ -10,6 +10,9 @@ }; use Innmind\Mutable\Ring; +/** + * @internal + */ final class Drift { /** @@ -23,6 +26,7 @@ private function __construct( ) { } + #[\NoDiscard] public function __invoke(PointInTime $now): PointInTime { return $this @@ -41,6 +45,7 @@ public function __invoke(PointInTime $now): PointInTime /** * @psalm-pure + * @internal * * @param list $drifts */ diff --git a/src/Simulation/Machine/Config.php b/src/Simulation/Machine/Config.php index fc71fd4..cc2c755 100644 --- a/src/Simulation/Machine/Config.php +++ b/src/Simulation/Machine/Config.php @@ -38,6 +38,10 @@ */ final class Config { + /** + * @internal + */ + #[\NoDiscard] public static function of( Network $network, Processes $processes, diff --git a/src/Simulation/Machine/OS.php b/src/Simulation/Machine/OS.php index 25414d0..0823202 100644 --- a/src/Simulation/Machine/OS.php +++ b/src/Simulation/Machine/OS.php @@ -5,13 +5,24 @@ use Innmind\OperatingSystem\OperatingSystem; +/** + * @internal + */ final class OS { + /** + * @psalm-mutation-free + */ private function __construct( private ?OperatingSystem $os, ) { } + /** + * @psalm-pure + * @internal + */ + #[\NoDiscard] public static function new(): self { return new self(null); @@ -22,6 +33,7 @@ public function boot(OperatingSystem $os): void $this->os = $os; } + #[\NoDiscard] public function unwrap(): OperatingSystem { if (\is_null($this->os)) { diff --git a/src/Simulation/Machine/Processes.php b/src/Simulation/Machine/Processes.php index e3ddf04..0e49fb1 100644 --- a/src/Simulation/Machine/Processes.php +++ b/src/Simulation/Machine/Processes.php @@ -17,9 +17,14 @@ Attempt, }; +/** + * @internal + */ final class Processes { /** + * @psalm-mutation-free + * * @param Map $executables * @param Map $environment * @param int<2, max> $lastPid @@ -33,9 +38,13 @@ private function __construct( } /** + * @psalm-pure + * @internal + * * @param Map $executables * @param Map $environment */ + #[\NoDiscard] public static function new( OS $os, Map $executables, @@ -47,6 +56,7 @@ public static function new( /** * @return Attempt */ + #[\NoDiscard] public function run(Command $command): Attempt { // todo handle timeouts, by hijacking the Process::halt() to make sure diff --git a/src/Simulation/NTPServer.php b/src/Simulation/NTPServer.php index ec956e2..6657db0 100644 --- a/src/Simulation/NTPServer.php +++ b/src/Simulation/NTPServer.php @@ -10,6 +10,9 @@ }; use Innmind\Immutable\SideEffect; +/** + * @internal + */ final class NTPServer { private function __construct( @@ -18,8 +21,11 @@ private function __construct( } /** + * @internal + * * @param ?int<2, 10> $clockSpeed */ + #[\NoDiscard] public static function new( ?PointInTime $start, ?int $clockSpeed, @@ -31,6 +37,7 @@ public static function new( )); } + #[\NoDiscard] public function now(): PointInTime { return $this->clock->now(); @@ -40,6 +47,7 @@ public function now(): PointInTime * Either because a machine halted a process or a network call was made and * introduce a latency between machines */ + #[\NoDiscard] public function advance(Period $period): SideEffect { return $this->clock->advance($period); diff --git a/src/Simulation/NTPServer/Clock.php b/src/Simulation/NTPServer/Clock.php index fea8e09..1db785b 100644 --- a/src/Simulation/NTPServer/Clock.php +++ b/src/Simulation/NTPServer/Clock.php @@ -10,6 +10,9 @@ }; use Innmind\Immutable\SideEffect; +/** + * @internal + */ final class Clock { /** @@ -24,8 +27,11 @@ private function __construct( } /** + * @internal + * * @param ?int<2, 10> $clockSpeed */ + #[\NoDiscard] public static function of( RealClock $clock, ?PointInTime $now, @@ -52,6 +58,7 @@ public static function of( ); } + #[\NoDiscard] public function now(): PointInTime { $now = ($this->delta)($this->clock->now()); @@ -63,6 +70,7 @@ public function now(): PointInTime return ($this->speed)($now); } + #[\NoDiscard] public function advance(Period $period): SideEffect { $this->advance = $this->advance?->add($period) ?? $period; diff --git a/src/Simulation/NTPServer/Clock/Speed.php b/src/Simulation/NTPServer/Clock/Speed.php index fd3f59e..3dbbac8 100644 --- a/src/Simulation/NTPServer/Clock/Speed.php +++ b/src/Simulation/NTPServer/Clock/Speed.php @@ -5,9 +5,14 @@ use Innmind\TimeContinuum\PointInTime; +/** + * @internal + */ final class Speed { /** + * @psalm-mutation-free + * * @param ?int<2, 10> $multiplier */ private function __construct( @@ -16,6 +21,7 @@ private function __construct( ) { } + #[\NoDiscard] public function __invoke(PointInTime $now): PointInTime { if (\is_null($this->multiplier)) { @@ -33,8 +39,12 @@ public function __invoke(PointInTime $now): PointInTime } /** + * @psalm-pure + * @internal + * * @param ?int<2, 10> $multiplier */ + #[\NoDiscard] public static function of(PointInTime $previous, ?int $multiplier): self { return new self($previous, $multiplier); diff --git a/src/Simulation/Network.php b/src/Simulation/Network.php index 5c76655..0924071 100644 --- a/src/Simulation/Network.php +++ b/src/Simulation/Network.php @@ -16,6 +16,9 @@ Attempt, }; +/** + * @internal + */ final class Network { /** @@ -28,6 +31,10 @@ private function __construct( ) { } + /** + * @internal + */ + #[\NoDiscard] public static function new(NTPServer $ntp, Latency $latency): self { return new self( @@ -41,6 +48,7 @@ public static function new(NTPServer $ntp, Latency $latency): self * @param non-empty-list $domains * @param callable(): Machine $boot */ + #[\NoDiscard] public function with(array $domains, callable $boot): void { $machine = $boot(); @@ -53,6 +61,7 @@ public function with(array $domains, callable $boot): void } } + #[\NoDiscard] public function ntp(): NTPServer { return $this->ntp; @@ -61,6 +70,7 @@ public function ntp(): NTPServer /** * @return Attempt */ + #[\NoDiscard] public function http(Request $request): Attempt { ($this->latency)($this->ntp); @@ -86,6 +96,7 @@ public function http(Request $request): Attempt /** * @return Attempt */ + #[\NoDiscard] public function ssh(string $host): Attempt { return $this diff --git a/src/Simulation/Network/Latency.php b/src/Simulation/Network/Latency.php index fd177fd..932a887 100644 --- a/src/Simulation/Network/Latency.php +++ b/src/Simulation/Network/Latency.php @@ -7,6 +7,9 @@ use Innmind\TimeContinuum\Period; use Innmind\Mutable\Ring; +/** + * @internal + */ final class Latency { /** @@ -33,9 +36,11 @@ public function __invoke(NTPServer $ntp): void /** * @psalm-pure + * @internal * * @param list> $latencies */ + #[\NoDiscard] public static function of(array $latencies): self { return new self(Ring::of(...$latencies)); From 82e9cfbc45fa2fdd11c46f2998081f3c797f3e5a Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sat, 6 Dec 2025 14:54:40 +0100 Subject: [PATCH 27/34] typo --- src/Simulation/NTPServer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Simulation/NTPServer.php b/src/Simulation/NTPServer.php index 6657db0..df8e6b2 100644 --- a/src/Simulation/NTPServer.php +++ b/src/Simulation/NTPServer.php @@ -45,7 +45,7 @@ public function now(): PointInTime /** * Either because a machine halted a process or a network call was made and - * introduce a latency between machines + * introduced a latency between machines */ #[\NoDiscard] public function advance(Period $period): SideEffect From c2b7ff0236ff6549a4844a5011da4648e96c5606 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sat, 6 Dec 2025 14:55:16 +0100 Subject: [PATCH 28/34] remove indirection --- src/Machine.php | 2 +- src/Simulation/Network.php | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/Machine.php b/src/Machine.php index b4e9755..6be715a 100644 --- a/src/Machine.php +++ b/src/Machine.php @@ -157,7 +157,7 @@ public function boot(Simulation\Network $network): void { $network->with( $this->domains, - fn() => Simulation\Machine::new( + Simulation\Machine::new( $network, $this->executables, $this->http, diff --git a/src/Simulation/Network.php b/src/Simulation/Network.php index 0924071..4a83913 100644 --- a/src/Simulation/Network.php +++ b/src/Simulation/Network.php @@ -46,13 +46,10 @@ public static function new(NTPServer $ntp, Latency $latency): self /** * @param non-empty-list $domains - * @param callable(): Machine $boot */ #[\NoDiscard] - public function with(array $domains, callable $boot): void + public function with(array $domains, Machine $machine): void { - $machine = $boot(); - foreach ($domains as $domain) { $this->machines = ($this->machines)( $domain, From c62aa57032edc36733699630372dcbe73c784c0e Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sat, 6 Dec 2025 15:01:07 +0100 Subject: [PATCH 29/34] return a failed process when the executable doesnt exist --- src/Simulation/Machine/Processes.php | 29 +++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/src/Simulation/Machine/Processes.php b/src/Simulation/Machine/Processes.php index 0e49fb1..9c352ad 100644 --- a/src/Simulation/Machine/Processes.php +++ b/src/Simulation/Machine/Processes.php @@ -112,21 +112,24 @@ private function doRun(Command\Definition $command): Attempt ))(); // todo handle output redirection + // todo add fault injection to simulate inability to spawn a process + // todo build a shell abstraction to allow to change the behaviour ? - return $this + $process = $this ->executables ->get($executable) - ->attempt(static fn() => new \RuntimeException( // todo return a failed process with exit code 127 (zsh behaviour) - \sprintf( - 'Failed to start %s command', - $executable, - ), - )) - ->map(fn($app) => $app( - $command, - ProcessBuilder::new(++$this->lastPid), - $this->os->unwrap(), - $this->environment, - )->build()); + ->match( + fn($app) => $app( + $command, + ProcessBuilder::new(++$this->lastPid), + $this->os->unwrap(), + $this->environment, + )->build(), + fn() => ProcessBuilder::new(++$this->lastPid) // zsh behaviour + ->failed(127) + ->build(), + ); + + return Attempt::result($process); } } From f04e0fe6c651677e99857ce79d7088bf1e3df7b7 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sat, 6 Dec 2025 15:13:35 +0100 Subject: [PATCH 30/34] return a failure in case of a connection timeout due to unknown port on the remote machine --- src/Exception/ConnectionTimeout.php | 11 +++++++++++ src/Simulation/Machine.php | 11 ++++++----- src/Simulation/Machine/Config.php | 6 ++++++ 3 files changed, 23 insertions(+), 5 deletions(-) create mode 100644 src/Exception/ConnectionTimeout.php diff --git a/src/Exception/ConnectionTimeout.php b/src/Exception/ConnectionTimeout.php new file mode 100644 index 0000000..fd99612 --- /dev/null +++ b/src/Exception/ConnectionTimeout.php @@ -0,0 +1,11 @@ +http ->get($value) - ->attempt(static fn() => new \RuntimeException('Connection timeout')) // todo inject fake timeout in ntp server ? + ->attempt(static fn() => new ConnectionTimeout) // todo inject fake timeout in ntp server ? ->flatMap(fn($app) => $app( $serverRequest, $this->os->unwrap(), diff --git a/src/Simulation/Machine/Config.php b/src/Simulation/Machine/Config.php index cc2c755..6b5e8c9 100644 --- a/src/Simulation/Machine/Config.php +++ b/src/Simulation/Machine/Config.php @@ -7,6 +7,7 @@ Simulation\Network, Simulation\Machine\Clock\Drift, Exception\CouldNotResolveHost, + Exception\ConnectionTimeout, }; use Innmind\OperatingSystem\Config as OSConfig; use Innmind\Server\Control\{ @@ -21,6 +22,7 @@ ClientError, ServerError, ConnectionFailed, + Failure, }; use Innmind\TimeWarp\Halt; use Innmind\Http\{ @@ -95,6 +97,10 @@ public static function of( $request, $e->getMessage(), ), + $e instanceof ConnectionTimeout => new Failure( + $request, + 'Connection timeout', + ), default => new ServerError( $request, Response::of( From d990679aef98af6e4401342a036b6e1e3d135d09 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sat, 6 Dec 2025 15:20:49 +0100 Subject: [PATCH 31/34] inject a 1 minute timeout when no http app is available on the specified port --- src/Simulation/Machine.php | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/Simulation/Machine.php b/src/Simulation/Machine.php index e2ec447..5b4f347 100644 --- a/src/Simulation/Machine.php +++ b/src/Simulation/Machine.php @@ -24,6 +24,7 @@ }; use Innmind\Filesystem\File\Content; use Innmind\Url\Authority\Port; +use Innmind\TimeContinuum\Period; use Innmind\Immutable\{ Map, Attempt, @@ -125,7 +126,17 @@ public function http(Request $request): Attempt return $this ->http ->get($value) - ->attempt(static fn() => new ConnectionTimeout) // todo inject fake timeout in ntp server ? + ->attempt( + fn() => $this + ->os + ->unwrap() + ->process() + ->halt(Period::minute(1)) // todo make this configurable ? + ->match( + static fn() => new ConnectionTimeout, + static fn($e) => $e, + ), + ) ->flatMap(fn($app) => $app( $serverRequest, $this->os->unwrap(), From 1c9b9b9f92cc3a10118f97709ef402dec2e6caae Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sat, 6 Dec 2025 16:05:43 +0100 Subject: [PATCH 32/34] add note on the time speed up that may not be correct --- src/Simulation/NTPServer/Clock.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Simulation/NTPServer/Clock.php b/src/Simulation/NTPServer/Clock.php index 1db785b..198d940 100644 --- a/src/Simulation/NTPServer/Clock.php +++ b/src/Simulation/NTPServer/Clock.php @@ -67,6 +67,10 @@ public function now(): PointInTime $now = $now->goForward($this->advance); } + // Not sure this is correct or be applied before applying `$this->advance` + // as this means that when a process wait for 1 second it will advance + // by 2. This could lead to unexpected behaviour such as when waiting + // for timeouts. return ($this->speed)($now); } From c4b023cceea1073e4d8002ed0bbcda658550b8d3 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sat, 6 Dec 2025 16:07:12 +0100 Subject: [PATCH 33/34] fix ci badge --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f567c18..666ab25 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # testing -[![Build Status](https://github.com/innmind/testing/workflows/CI/badge.svg?branch=main)](https://github.com/innmind/testing/actions?query=workflow%3ACI) +[![Build Status](https://github.com/Innmind/testing/actions/workflows/ci.yml/badge.svg)](https://github.com/Innmind/testing/actions/workflows/ci.yml) [![codecov](https://codecov.io/gh/innmind/testing/branch/develop/graph/badge.svg)](https://codecov.io/gh/innmind/testing) [![Type Coverage](https://shepherd.dev/github/innmind/testing/coverage.svg)](https://shepherd.dev/github/innmind/testing) From 409478fe60bc5ff71ec0418c6f2582aa0aa6c75e Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sat, 6 Dec 2025 16:11:36 +0100 Subject: [PATCH 34/34] remove milliseconds to avoid changing the second due to test execution time --- proofs/cluster.php | 1 + 1 file changed, 1 insertion(+) diff --git a/proofs/cluster.php b/proofs/cluster.php index 952d015..6c0fbb8 100644 --- a/proofs/cluster.php +++ b/proofs/cluster.php @@ -626,6 +626,7 @@ static function($assert, $start, $in, $out) { PointInTime::after('2000-01-01'), ), static function($assert, $start) { + $start = $start->goBack(Period::millisecond($start->millisecond()->toInt())); $local = Machine::new('local.dev') ->listen( Machine\HTTP::of(static fn($request, $os) => Attempt::result(Response::of(