diff --git a/src/Cli/WorkflowMode.php b/src/Cli/WorkflowMode.php index 5348da5..c4bb0e2 100644 --- a/src/Cli/WorkflowMode.php +++ b/src/Cli/WorkflowMode.php @@ -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; @@ -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; diff --git a/src/Server.php b/src/Server.php index 122845a..c9e91b0 100644 --- a/src/Server.php +++ b/src/Server.php @@ -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(); } /** diff --git a/src/ServerLocator.php b/src/ServerLocator.php new file mode 100644 index 0000000..f204430 --- /dev/null +++ b/src/ServerLocator.php @@ -0,0 +1,109 @@ +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; + } +} diff --git a/tests/ServerLocatorTest.php b/tests/ServerLocatorTest.php new file mode 100644 index 0000000..a6f010d --- /dev/null +++ b/tests/ServerLocatorTest.php @@ -0,0 +1,132 @@ +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); + } +}