Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/Cli/WorkflowMode.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use Claw\Run\IssueRunner;
use Claw\Run\Triage;
use Claw\Server;
use Claw\ServerLocator;
use Claw\Trace\Level;
use Claw\Trace\TraceReader;

Expand Down Expand Up @@ -101,6 +102,18 @@ private function serve(array $args): int
return 1;
}

// One server per workspace: two would write the same project dbs. A record left by a crash is
// ignored — the guard is the live connection, not the file.
$locator = new ServerLocator($this->appHome());
$existing = $locator->locate();

if ($existing !== null && $locator->alive($existing['host'], $existing['port'])) {
fwrite(STDERR, 'claw serve: a server for this workspace is already running at '
. "{$existing['host']}:{$existing['port']} (pid {$existing['pid']}).\n");

return 1;
}

new Server($this->projectsDir(), $this->root, $this->appHome())->run($host, $port);

return 0;
Expand Down
9 changes: 9 additions & 0 deletions src/Server.php
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,16 @@ public function run(string $host = '127.0.0.1', int $port = 8787): void

$this->adoptOrphanedRuns();

// Record where we can be reached, then serve. A process that FAILS to bind never became the
// server, so it must not clear the record on the way out — the winner of the bind race owns
// it. Only a clean return from start() (a graceful stop) drops the file; a crash leaves it for
// the next serve's liveness check to discard.
$locator = new ServerLocator($this->workspace);
$locator->record($host, $port);

$server->start();

$locator->clear();
}

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

declare(strict_types=1);

namespace Claw;

/**
* Where a workspace's dashboard server can be reached, written to a file so the CLI finds it without
* guessing a port. A workspace has at most one server — it owns every project db under it — so the
* record is one file at the workspace root, not one per project.
*
* The record is a hint, never a promise the server is up: it can outlive a crash. Liveness is a
* separate question ({@see alive()}), and the truthful test of it is whether the address still
* accepts a connection — a pid can be reused, a file can be stale.
*/
final readonly class ServerLocator
{
private const string FILE = 'server.json';

public function __construct(private string $workspace)
{
}

/**
* Record the address this server bound to. Best-effort: a failure to write only costs discovery.
*
* A wildcard bind is stored as the loopback it can actually be reached at — `0.0.0.0` is where the
* server listens, not an address anyone connects to, and the record exists to be connected to.
*/
public function record(string $host, int $port): void
{
@file_put_contents($this->path(), (string) json_encode([
'host' => self::reachable($host),
'port' => $port,
'pid' => getmypid(),
]));
}

/**
* The recorded address, or null when nothing readable is on file.
*
* @return array{host: string, port: int, pid: int}|null
*/
public function locate(): ?array
{
$raw = @file_get_contents($this->path());

if ($raw === false) {
return null;
}

$data = json_decode($raw, true);

if (!\is_array($data) || !\is_string($data['host'] ?? null) || !\is_int($data['port'] ?? null)) {
return null;
}

if ($data['port'] < 1 || $data['port'] > 65535) {
return null;
}

return ['host' => $data['host'], 'port' => $data['port'], 'pid' => \is_int($data['pid'] ?? null) ? $data['pid'] : 0];
}

/**
* Drop the record, but only if it is still ours.
*
* A process that lost the bind race overwrote this file with its own pid on the way to failing;
* the pid check stops it from then deleting the winner's record. Only the process whose pid the
* record carries may remove it — anyone else leaves it for {@see alive()} to judge.
*/
public function clear(): void
{
$found = $this->locate();

if ($found !== null && $found['pid'] === getmypid()) {
@unlink($this->path());
}
}

/** Whether something is accepting connections at that address right now. */
public function alive(string $host, int $port): bool
{
$socket = @fsockopen(self::reachable($host), $port, $errno, $errstr, 0.3);

if ($socket === false) {
return false;
}

fclose($socket);

return true;
}

/** A wildcard listen address turned into the loopback a client can actually connect to. */
private static function reachable(string $host): string
{
return match ($host) {
'0.0.0.0' => '127.0.0.1',
'::', '[::]' => '::1',
default => $host,
};
}

private function path(): string
{
return $this->workspace . \DIRECTORY_SEPARATOR . self::FILE;
}
}
132 changes: 132 additions & 0 deletions tests/ServerLocatorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
<?php

declare(strict_types=1);

namespace Tests;

use Claw\ServerLocator;
use Testo\Assert;
use Testo\Core\Exception\SkipTest;
use Testo\Test;

/**
* The record a server leaves so the CLI can find it: a round-trip through the file, and the
* distinction the whole design turns on — a record on disk is not proof a server is up.
*/
final class ServerLocatorTest
{
private string $base;

public function __construct()
{
$this->base = sys_get_temp_dir() . '/claw-locator-' . bin2hex(random_bytes(6));
mkdir($this->base);
}

public function __destruct()
{
foreach (glob($this->base . '/*/server.json') ?: [] as $file) {
@unlink($file);
}

foreach (glob($this->base . '/*') ?: [] as $dir) {
@rmdir($dir);
}

@rmdir($this->base);
}

/** A workspace no other test shares. */
private function freshWorkspace(): string
{
$workspace = $this->base . '/' . bin2hex(random_bytes(6));
mkdir($workspace);

return $workspace;
}

private function fresh(): ServerLocator
{
return new ServerLocator($this->freshWorkspace());
}

#[Test]
public function recordsAndReadsBackAnAddress(): void
{
$locator = $this->fresh();
$locator->record('127.0.0.1', 8787);

$found = $locator->locate();

Assert::same(\is_array($found) ? $found['host'] : null, '127.0.0.1');
Assert::same(\is_array($found) ? $found['port'] : null, 8787);
Assert::same(\is_array($found) ? $found['pid'] : null, getmypid());
}

#[Test]
public function locatesNothingWhenNoRecordExists(): void
{
Assert::same($this->fresh()->locate(), null);
}

#[Test]
public function clearDropsTheRecord(): void
{
$locator = $this->fresh();
$locator->record('127.0.0.1', 8787);
$locator->clear();

Assert::same($locator->locate(), null);
}

#[Test]
public function ignoresAMalformedRecord(): void
{
$workspace = $this->freshWorkspace();
file_put_contents($workspace . '/server.json', 'not json');

Assert::same(new ServerLocator($workspace)->locate(), null);
}

#[Test]
public function ignoresWellFormedJsonWithWrongTypes(): void
{
$workspace = $this->freshWorkspace();
// a plausible hand-edit: the port quoted as a string
file_put_contents($workspace . '/server.json', '{"host":"127.0.0.1","port":"8787"}');

Assert::same(new ServerLocator($workspace)->locate(), null);
}

#[Test]
public function clearLeavesAnotherProcessesRecordAlone(): void
{
$workspace = $this->freshWorkspace();
// a record owned by some other, still-imaginable process — clear() must not remove it
file_put_contents($workspace . '/server.json', '{"host":"127.0.0.1","port":8787,"pid":' . (getmypid() + 1) . '}');

new ServerLocator($workspace)->clear();

Assert::same(is_file($workspace . '/server.json'), true);
}

#[Test]
public function aliveIsTrueOnlyWhileSomethingListens(): void
{
$locator = $this->fresh();
$server = @stream_socket_server('tcp://127.0.0.1:0');

if (!\is_resource($server)) {
throw new SkipTest('no loopback socket available in this environment');
}

$name = (string) stream_socket_get_name($server, false);
$port = (int) substr($name, (int) strrpos($name, ':') + 1);

Assert::same($locator->alive('127.0.0.1', $port), true);

fclose($server);

Assert::same($locator->alive('127.0.0.1', $port), false);
}
}
Loading