Files
oh-my-pi/packages/coding-agent/src/tools/browser/relay/daemon.ts
T
can1357 92b50faacc feat(coding-agent/lsp): implemented lsp multiplexer server and shared daemon lifecycle
- Added an LSP multiplexer server, protocol definitions, and daemon lifecycle management to route traffic across sessions.
- Introduced `lsp.shared` settings configuration and SDK session creation support for shared language servers.
- Migrated shared daemon ensure helpers into a central launch module with updated import references.
- Added comprehensive unit tests and fake LSP server fixtures covering muxing, sharing, caching, and restarts.
2026-08-02 18:15:22 +02:00

118 lines
4.8 KiB
TypeScript

/**
* Broker-owned browser relay daemon.
*
* The MV3 extension can only dial OUT (service workers cannot listen on
* sockets), so a native process must own the relay port. Instead of making
* the user run `omp browser-relay` by hand, the relay kind lazily starts one
* under a profile-independent, machine-global daemon broker. Every relay
* consumer holds a connection to that broker, so one project exiting cannot
* tear down the fixed-port singleton while another project still uses it.
*
* A manually started relay may already own the port. Consumers still acquire
* the global broker lease before probing, then adopt that external server
* without attempting another bind.
*/
import { logger } from "@oh-my-pi/pi-utils";
import { daemonClientForGlobal } from "../../../launch/client";
import { describeQuietly, stopQuietly, waitReady } from "../../../launch/ensure";
import { resolveWorkerSpawnCmd } from "../../../subprocess/worker-client";
import { throwIfAborted } from "../../tool-errors";
/** Stable broker daemon name for the relay server. */
export const RELAY_DAEMON_NAME = "omp.browser.relay";
const RELAY_BROKER_SCOPE = "browser-relay";
/** Matches the serve banner (`omp browser relay listening on http://…`). */
const READY_LOG_PATTERN = String.raw`browser relay listening on http://\S+`;
const READY_TIMEOUT_MS = 15_000;
const PROBE_TIMEOUT_MS = 1_500;
/** probe→describe→start rounds; bounds cross-process races and wedged-relay replacement. */
const ENSURE_ATTEMPTS = 3;
/** True when the relay HTTP server answers /json/version at all (200 = extension connected, 503 = waiting for it). */
export async function probeRelayServer(cdpUrl: string): Promise<boolean> {
try {
const res = await fetch(`${cdpUrl}/json/version`, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
await res.body?.cancel();
return res.ok || res.status === 503;
} catch {
return false;
}
}
/** Auto-start is only safe for endpoints this machine can own. */
export function isLoopbackRelayUrl(cdpUrl: string): boolean {
try {
const { hostname } = new URL(cdpUrl);
return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "[::1]" || hostname === "::1";
} catch {
return false;
}
}
/**
* Ensure a relay server answers at `cdpUrl`, starting the broker-owned daemon
* when nothing is serving. Returns true once the HTTP endpoint responds — the
* extension handshake (503 → 200) is the caller's wait. False when the relay
* could not be started (broker unavailable or start rounds exhausted).
*/
export async function ensureRelayDaemon(opts: { cdpUrl: string; signal?: AbortSignal }): Promise<boolean> {
let port: string;
try {
port = String(new URL(opts.cdpUrl).port || 80);
} catch {
return false;
}
// Open the lazy client before probing. Merely caching SocketDaemonClient
// would not create the broker connection (and therefore would hold no lease).
const client = await daemonClientForGlobal(RELAY_BROKER_SCOPE);
throwIfAborted(opts.signal);
await client.request({ op: "ping" }, opts.signal);
if (await probeRelayServer(opts.cdpUrl)) return true;
const spawn = resolveWorkerSpawnCmd("browser-relay");
for (let attempt = 0; attempt < ENSURE_ATTEMPTS; attempt++) {
throwIfAborted(opts.signal);
// A manual serve or concurrent global-broker start may have won the
// port since the last round; adopt it instead of fighting the bind.
if (await probeRelayServer(opts.cdpUrl)) return true;
const existing = await describeQuietly(client, RELAY_DAEMON_NAME, "Browser relay", opts.signal);
if (existing && existing.state !== "exited" && existing.state !== "failed") {
if (existing.readyAt === undefined) await waitReady(client, RELAY_DAEMON_NAME, "Browser relay", opts.signal);
if (await probeRelayServer(opts.cdpUrl)) return true;
// Live record but nothing listening: replace the wedged daemon.
await stopQuietly(client, RELAY_DAEMON_NAME, "Browser relay", opts.signal);
continue;
}
try {
const started = await client.request(
{
op: "start",
spec: {
name: RELAY_DAEMON_NAME,
application: spawn.cmd[0]!,
args: [...spawn.cmd.slice(1), "--port", port],
env: {},
cwd: spawn.cwd ?? client.projectDir,
pty: false,
ready: { log: READY_LOG_PATTERN, timeoutMs: READY_TIMEOUT_MS },
restart: "no",
persist: false,
detached: false,
},
},
opts.signal,
);
if (started.op !== "start") continue;
if (await probeRelayServer(opts.cdpUrl)) return true;
await stopQuietly(client, RELAY_DAEMON_NAME, "Browser relay", opts.signal);
} catch (error) {
throwIfAborted(opts.signal);
// Lost a cross-process start race; the next round adopts the winner.
logger.debug("Browser relay start contention", {
name: RELAY_DAEMON_NAME,
error: error instanceof Error ? error.message : String(error),
});
}
}
return false;
}