fix(cli): elect managed service by port bind (#37572)
Co-authored-by: Dax Raad <thdxr@users.noreply.github.com>
This commit is contained in:
parent
987242b3e8
commit
6ea8247e0f
6 changed files with 232 additions and 39 deletions
|
|
@ -29,6 +29,17 @@ export const discover = Effect.fn("service.discover")(function* (options: Discov
|
|||
return (yield* discoverLocal(options))?.endpoint
|
||||
})
|
||||
|
||||
/** Recognize an authenticated compatible service bound to an expected URL, including while it starts or fails. */
|
||||
export const incumbent = Effect.fn("service.incumbent")(function* (
|
||||
options: DiscoverOptions & { readonly url: string },
|
||||
) {
|
||||
const info = yield* read(options.file)
|
||||
const found = info === undefined ? undefined : yield* probe({ ...info, url: options.url })
|
||||
if (found === undefined || found.legacy) return undefined
|
||||
if (options.version !== undefined && found.version !== options.version) return undefined
|
||||
return { endpoint: found.endpoint, state: found.state }
|
||||
})
|
||||
|
||||
const discoverLocal = Effect.fnUntraced(function* (options: DiscoverOptions) {
|
||||
const found = (yield* registered(options.file)).service
|
||||
if (found?.state !== "ready") return undefined
|
||||
|
|
@ -101,8 +112,15 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
|||
lastSpawn = Date.now()
|
||||
}
|
||||
return Option.none<LocalService>()
|
||||
}).pipe(Effect.repeat({ until: Option.isSome, schedule: Schedule.spaced("1 second") }))
|
||||
return Option.getOrThrow(found).endpoint
|
||||
}).pipe(
|
||||
Effect.repeat({
|
||||
until: Option.isSome,
|
||||
schedule: Schedule.max([Schedule.spaced("1 second"), Schedule.recurs(120)]),
|
||||
}),
|
||||
)
|
||||
if (Option.isNone(found))
|
||||
return yield* Effect.fail(new Error("Timed out waiting for the background service to start"))
|
||||
return found.value.endpoint
|
||||
})
|
||||
|
||||
function contenderFailure(contender: Contender) {
|
||||
|
|
@ -143,6 +161,7 @@ export const Info = Schema.Struct({
|
|||
url: Schema.String,
|
||||
pid: Schema.Int.check(Schema.isGreaterThan(0)),
|
||||
password: Schema.optional(Schema.String),
|
||||
startedAt: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))),
|
||||
})
|
||||
|
||||
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
|
||||
|
|
@ -273,4 +292,4 @@ const requestStop = Effect.fnUntraced(function* (service: LocalService) {
|
|||
})
|
||||
|
||||
/** Effect-based local service lifecycle operations. */
|
||||
export const Service = { discover, ensure, stop, headers, Info }
|
||||
export const Service = { discover, incumbent, ensure, stop, headers, Info }
|
||||
|
|
|
|||
|
|
@ -2,13 +2,7 @@ import { readFile } from "node:fs/promises"
|
|||
import { spawn, type ChildProcess } from "node:child_process"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type {
|
||||
DiscoverOptions,
|
||||
Endpoint,
|
||||
Info,
|
||||
EnsureOptions,
|
||||
StopOptions,
|
||||
} from "../service.js"
|
||||
import type { DiscoverOptions, Endpoint, Info, EnsureOptions, StopOptions } from "../service.js"
|
||||
import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js"
|
||||
|
||||
export * from "../service.js"
|
||||
|
|
@ -38,6 +32,7 @@ async function discoverLocal(options: DiscoverOptions) {
|
|||
|
||||
/** Ensure a healthy, compatible local service is running. */
|
||||
export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
const deadline = Date.now() + 120_000
|
||||
const contenders = new Set<Contender>()
|
||||
let announced = false
|
||||
let lastSpawn = 0
|
||||
|
|
@ -66,6 +61,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
|||
}
|
||||
|
||||
while (true) {
|
||||
if (Date.now() >= deadline) throw new Error("Timed out waiting for the background service to start")
|
||||
const registration = await registered(options.file, true)
|
||||
|
||||
if (registration.service !== undefined) {
|
||||
|
|
@ -128,7 +124,9 @@ function fallback() {
|
|||
/** Create HTTP authentication headers for a service endpoint. */
|
||||
export function headers(endpoint: Endpoint) {
|
||||
if (endpoint.auth === undefined) return undefined
|
||||
return { authorization: "Basic " + Buffer.from(endpoint.auth.username + ":" + endpoint.auth.password).toString("base64") }
|
||||
return {
|
||||
authorization: "Basic " + Buffer.from(endpoint.auth.username + ":" + endpoint.auth.password).toString("base64"),
|
||||
}
|
||||
}
|
||||
|
||||
async function read(file?: string) {
|
||||
|
|
@ -227,7 +225,8 @@ async function kill(service: LocalService, options: { readonly file?: string })
|
|||
const latest = await find(options)
|
||||
if (latest === undefined || !same(latest.info, service.info)) return
|
||||
signal(service.info.pid, "SIGKILL")
|
||||
if (!(await waitUntilStopped(service.info.pid))) throw new Error(`Server process ${service.info.pid} is still running`)
|
||||
if (!(await waitUntilStopped(service.info.pid)))
|
||||
throw new Error(`Server process ${service.info.pid} is still running`)
|
||||
}
|
||||
|
||||
async function requestStop(service: LocalService) {
|
||||
|
|
|
|||
|
|
@ -50,4 +50,6 @@ export type Info = {
|
|||
readonly pid: number
|
||||
/** Private service password, when authentication is enabled. */
|
||||
readonly password?: string
|
||||
/** Registration generation used to resolve owner write races. */
|
||||
readonly startedAt?: number
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue