mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
fix(cli): fall back from unavailable managed service port
This commit is contained in:
@@ -59,10 +59,15 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined
|
||||
const config = options.mode === "service" ? yield* ServiceConfig.read() : {}
|
||||
const hostname = options.hostname ?? config.hostname ?? "127.0.0.1"
|
||||
const port = options.port ?? config.port ?? (options.mode === "service" ? ServiceConfig.defaultPort() : undefined)
|
||||
const configuredPort = options.port ?? config.port
|
||||
const port = configuredPort ?? (options.mode === "service" ? ServiceConfig.defaultPort() : undefined)
|
||||
const incumbent =
|
||||
serviceOptions !== undefined && port !== undefined
|
||||
? yield* Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })
|
||||
serviceOptions !== undefined
|
||||
? yield* Service.incumbent(
|
||||
configuredPort === undefined
|
||||
? serviceOptions
|
||||
: { ...serviceOptions, url: serviceURL(hostname, configuredPort) },
|
||||
)
|
||||
: undefined
|
||||
if (incumbent !== undefined) return
|
||||
// Keep a package-manager or curl install replaceable while the service runs; Desktop updates its own copy.
|
||||
@@ -84,46 +89,43 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
if (!password) return yield* Effect.fail(new Error("Missing server password"))
|
||||
const instanceID = randomUUID()
|
||||
const transform = yield* WebUi.handler()
|
||||
const server = yield* start(
|
||||
{
|
||||
app: {
|
||||
name: process.env.OPENCODE_CLIENT ?? OPENCODE_ARTIFACT,
|
||||
version: OPENCODE_VERSION,
|
||||
channel: OPENCODE_CHANNEL,
|
||||
},
|
||||
hostname,
|
||||
port,
|
||||
cors: options.cors ?? config.cors,
|
||||
password,
|
||||
pty: { handoff },
|
||||
simulation: truthy(process.env.OPENCODE_SIMULATE),
|
||||
database: {
|
||||
path: databasePath(global.data),
|
||||
},
|
||||
models: {
|
||||
url: process.env.OPENCODE_MODELS_URL,
|
||||
file: process.env.OPENCODE_MODELS_PATH,
|
||||
fetch: !truthy(process.env.OPENCODE_DISABLE_MODELS_FETCH),
|
||||
},
|
||||
config: {
|
||||
directory: process.env.OPENCODE_CONFIG_DIR,
|
||||
project: !truthy(
|
||||
process.env.OPENCODE_CONFIG_PROJECT_DISABLE ?? process.env.OPENCODE_DISABLE_PROJECT_CONFIG,
|
||||
),
|
||||
file: process.env.OPENCODE_CONFIG,
|
||||
content: process.env.OPENCODE_CONFIG_CONTENT,
|
||||
},
|
||||
windows: {
|
||||
gitbash: process.env.OPENCODE_GIT_BASH_PATH,
|
||||
},
|
||||
fs: {
|
||||
filewatcher: !truthy(process.env.OPENCODE_FILEWATCHER_DISABLE ?? process.env.OPENCODE_DISABLE_FILEWATCHER),
|
||||
fff:
|
||||
process.env.OPENCODE_DISABLE_FFF === undefined
|
||||
? process.platform !== "win32"
|
||||
: !truthy(process.env.OPENCODE_DISABLE_FFF),
|
||||
},
|
||||
const serverOptions = {
|
||||
app: {
|
||||
name: process.env.OPENCODE_CLIENT ?? OPENCODE_ARTIFACT,
|
||||
version: OPENCODE_VERSION,
|
||||
channel: OPENCODE_CHANNEL,
|
||||
},
|
||||
hostname,
|
||||
cors: options.cors ?? config.cors,
|
||||
password,
|
||||
pty: { handoff },
|
||||
simulation: truthy(process.env.OPENCODE_SIMULATE),
|
||||
database: {
|
||||
path: databasePath(global.data),
|
||||
},
|
||||
models: {
|
||||
url: process.env.OPENCODE_MODELS_URL,
|
||||
file: process.env.OPENCODE_MODELS_PATH,
|
||||
fetch: !truthy(process.env.OPENCODE_DISABLE_MODELS_FETCH),
|
||||
},
|
||||
config: {
|
||||
directory: process.env.OPENCODE_CONFIG_DIR,
|
||||
project: !truthy(process.env.OPENCODE_CONFIG_PROJECT_DISABLE ?? process.env.OPENCODE_DISABLE_PROJECT_CONFIG),
|
||||
file: process.env.OPENCODE_CONFIG,
|
||||
content: process.env.OPENCODE_CONFIG_CONTENT,
|
||||
},
|
||||
windows: {
|
||||
gitbash: process.env.OPENCODE_GIT_BASH_PATH,
|
||||
},
|
||||
fs: {
|
||||
filewatcher: !truthy(process.env.OPENCODE_FILEWATCHER_DISABLE ?? process.env.OPENCODE_DISABLE_FILEWATCHER),
|
||||
fff:
|
||||
process.env.OPENCODE_DISABLE_FFF === undefined
|
||||
? process.platform !== "win32"
|
||||
: !truthy(process.env.OPENCODE_DISABLE_FFF),
|
||||
},
|
||||
}
|
||||
const lifecycle =
|
||||
serviceOptions === undefined
|
||||
? undefined
|
||||
: {
|
||||
@@ -138,11 +140,17 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
shutdown,
|
||||
})
|
||||
}),
|
||||
},
|
||||
transform,
|
||||
).pipe(
|
||||
}
|
||||
const startOnPort = (selectedPort?: number) =>
|
||||
start({ ...serverOptions, port: selectedPort }, lifecycle, transform)
|
||||
const server = yield* startOnPort(port).pipe(
|
||||
Effect.catch((error) => {
|
||||
if (serviceOptions === undefined || port === undefined || !addressInUse(error)) return Effect.fail(error)
|
||||
if (serviceOptions === undefined || port === undefined) return Effect.fail(error)
|
||||
if (configuredPort === undefined && (addressInUse(error) || accessDenied(error)))
|
||||
return Service.incumbent(serviceOptions).pipe(
|
||||
Effect.flatMap((found) => (found ? Effect.void : startOnPort(undefined))),
|
||||
)
|
||||
if (!addressInUse(error)) return Effect.fail(error)
|
||||
return recognizeIncumbent(serviceOptions, hostname, port).pipe(
|
||||
Effect.flatMap((found) =>
|
||||
found
|
||||
@@ -194,6 +202,12 @@ function addressInUse(error: unknown): boolean {
|
||||
return "cause" in error && addressInUse(error.cause)
|
||||
}
|
||||
|
||||
function accessDenied(error: unknown): boolean {
|
||||
if (typeof error !== "object" || error === null) return false
|
||||
if ("code" in error && error.code === "EACCES") return true
|
||||
return "cause" in error && accessDenied(error.cause)
|
||||
}
|
||||
|
||||
function waitForStdinClose() {
|
||||
return Effect.callback<void>((resume) => {
|
||||
const close = () => resume(Effect.void)
|
||||
|
||||
@@ -316,6 +316,95 @@ test("configured managed service port overrides the channel default", async () =
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
test("unconfigured managed service skips occupied ports and registers the selected address", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-auto-port-"))
|
||||
const previousDefaultPort = 0xc0df
|
||||
// An existing service may already own either port on a developer machine.
|
||||
const occupied = await Promise.all([4096, previousDefaultPort].map(reservePort))
|
||||
const registration = path.join(root, "state", "opencode", "service-local.json")
|
||||
const owner = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
|
||||
env: serviceEnv(root),
|
||||
stderr: "pipe",
|
||||
stdout: "ignore",
|
||||
})
|
||||
try {
|
||||
const info = await Promise.race([
|
||||
waitForInfo(registration),
|
||||
owner.exited.then(() => {
|
||||
throw new Error("Service exited before registering a fallback port")
|
||||
}),
|
||||
])
|
||||
expect(new URL(info.url).port).not.toBe(String(previousDefaultPort))
|
||||
expect(new URL(info.url).port).not.toBe("4096")
|
||||
expect(
|
||||
await Effect.runPromise(Service.discover({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer))),
|
||||
).toMatchObject({
|
||||
url: info.url,
|
||||
})
|
||||
} finally {
|
||||
owner.kill("SIGTERM")
|
||||
await owner.exited
|
||||
occupied.forEach((server) => server?.stop(true))
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
test("concurrent unconfigured services converge on one registered owner", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-auto-election-"))
|
||||
const occupied = await reservePort(0xc0df)
|
||||
const registration = path.join(root, "state", "opencode", "service-local.json")
|
||||
const command = [process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"]
|
||||
const processes = Array.from({ length: 2 }, () =>
|
||||
Bun.spawn(command, { env: serviceEnv(root), stderr: "pipe", stdout: "ignore" }),
|
||||
)
|
||||
try {
|
||||
const exited = await Promise.race([
|
||||
...processes.map((process, index) => process.exited.then((code) => ({ index, code }))),
|
||||
Bun.sleep(20_000).then(() => undefined),
|
||||
])
|
||||
if (!exited) throw new Error("Concurrent services did not settle")
|
||||
expect(exited.code).toBe(0)
|
||||
const winner = processes[1 - exited.index]!
|
||||
const info = await waitForInfo(registration, (value) => value.pid === winner.pid)
|
||||
expect(winner.exitCode).toBeNull()
|
||||
await Bun.sleep(5_500)
|
||||
expect((await Bun.file(registration).json()).id).toBe(info.id)
|
||||
expect(winner.exitCode).toBeNull()
|
||||
const contender = Bun.spawn(command, { env: serviceEnv(root), stderr: "pipe", stdout: "ignore" })
|
||||
expect(await contender.exited).toBe(0)
|
||||
expect((await Bun.file(registration).json()).id).toBe(info.id)
|
||||
} finally {
|
||||
processes.forEach((process) => process.kill("SIGTERM"))
|
||||
await Promise.all(processes.map((process) => process.exited))
|
||||
occupied?.stop(true)
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 35_000)
|
||||
|
||||
test("a failed service on a fallback port retains its registered owner", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-auto-failed-"))
|
||||
const occupied = await reservePort(0xc0df)
|
||||
const database = path.join(root, "database")
|
||||
await fs.mkdir(database)
|
||||
const registration = path.join(root, "state", "opencode", "service-local.json")
|
||||
const command = [process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"]
|
||||
const env = { ...serviceEnv(root), OPENCODE_DB: database }
|
||||
const owner = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })
|
||||
try {
|
||||
const info = await waitForInfo(registration)
|
||||
await waitForFailed(info)
|
||||
const contender = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })
|
||||
expect(await contender.exited).toBe(0)
|
||||
expect((await Bun.file(registration).json()).id).toBe(info.id)
|
||||
expect(owner.exitCode).toBeNull()
|
||||
} finally {
|
||||
owner.kill("SIGTERM")
|
||||
await owner.exited
|
||||
occupied?.stop(true)
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
test.each([
|
||||
{ args: [], origins: ["http://192.0.2.10:3001", "https://configured.example.com"] },
|
||||
{
|
||||
@@ -599,6 +688,21 @@ async function availablePort() {
|
||||
return port
|
||||
}
|
||||
|
||||
async function reservePort(port: number) {
|
||||
return Effect.runPromise(
|
||||
Effect.try({
|
||||
try: () => Bun.serve({ hostname: "127.0.0.1", port, fetch: () => new Response("unrelated") }),
|
||||
catch: (cause) => cause,
|
||||
}).pipe(
|
||||
Effect.catch((error) =>
|
||||
typeof error === "object" && error !== null && "code" in error && error.code === "EADDRINUSE"
|
||||
? Effect.succeed(undefined)
|
||||
: Effect.fail(error),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function serviceEnv(root: string) {
|
||||
return {
|
||||
...process.env,
|
||||
|
||||
@@ -35,12 +35,12 @@ export const discover = Effect.fn("service.discover")(function* (options: Discov
|
||||
return found.endpoint
|
||||
})
|
||||
|
||||
/** Recognize an authenticated compatible service bound to an expected URL, including while it starts or fails. */
|
||||
/** Recognize an authenticated compatible service, optionally at an expected URL, including while it starts or fails. */
|
||||
export const incumbent = Effect.fn("service.incumbent")(function* (
|
||||
options: DiscoverOptions & { readonly url: string },
|
||||
options: DiscoverOptions & { readonly url?: string },
|
||||
) {
|
||||
const info = yield* read(options.file)
|
||||
const found = info === undefined ? undefined : yield* probe({ ...info, url: options.url })
|
||||
const found = info === undefined ? undefined : yield* probe({ ...info, url: options.url ?? info.url })
|
||||
if (found === undefined) return undefined
|
||||
if (!found.compatible) return undefined
|
||||
if (!matchesVersion(found.version, options)) return undefined
|
||||
|
||||
Reference in New Issue
Block a user