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
19 changes: 3 additions & 16 deletions packages/cli/src/services/server-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { Standalone } from "./standalone"
export type Args = {
readonly server?: string
readonly standalone?: boolean
readonly mismatch?: "replace" | "ignore" | "error"
readonly mismatch?: "replace" | "ignore"
readonly onStart?: EnsureOptions["onStart"]
}

Expand Down Expand Up @@ -42,10 +42,9 @@ export const resolve = Effect.fn("cli.server-connection.resolve")(function* (arg
return { endpoint: yield* Standalone.start() } satisfies Resolved
}

const mismatch = args.mismatch ?? "ignore"
const options = yield* ServiceConfig.options({ checkVersion: mismatch !== "ignore" })
const options = yield* ServiceConfig.options({ checkVersion: args.mismatch === "replace" })
return {
endpoint: yield* resolveManaged({ ...options, onStart: args.onStart }, mismatch),
endpoint: yield* Service.ensure({ ...options, onStart: args.onStart }),
service: managedService(options),
} satisfies Resolved
})
Expand All @@ -71,18 +70,6 @@ export const shutdownPersistentPty = Effect.fn("cli.server-connection.shutdown-p
yield* Effect.tryPromise(() => client.experimental.persistentPty.shutdown())
})

const resolveManaged = Effect.fnUntraced(function* (options: EnsureOptions, mismatch: NonNullable<Args["mismatch"]>) {
if (mismatch === "replace") return yield* Service.ensure(options)
if (mismatch === "ignore") return yield* Service.ensure({ ...options, version: undefined })

const compatible = yield* Service.discover(options)
if (compatible !== undefined) return compatible
const existing = yield* Service.discover({ ...options, version: undefined })
if (existing !== undefined)
return yield* Effect.fail(new Error("Background server version does not match this client"))
return yield* Service.ensure(options)
})

function connectError(endpoint: Endpoint, cause: unknown) {
if (isUnauthorizedError(cause)) {
return new Error(
Expand Down
47 changes: 47 additions & 0 deletions packages/cli/test/server-connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,50 @@ test("service options only require a matching version when requested", async ()
await fs.rm(root, { recursive: true, force: true })
}
})

test("outdated managed registration is reused by default and ignore, but replace announces a version mismatch", async () => {
const root = await fs.mkdtemp(path.join("/tmp/uncode", "opencode-server-mismatch-"))
const version = "2.0.0-old"
const server = Bun.serve({
port: 0,
fetch: () => Response.json({ version, pid: process.pid, urls: [] }),
})
const registration = path.join(root, "state", ServiceConfig.filename())
const layer = Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })
const runPromise = <A, E>(effect: Effect.Effect<A, E, Global.Service | FileSystem.FileSystem | Scope.Scope>) =>
Effect.runPromise(effect.pipe(Effect.provide(layer), Effect.provide(NodeFileSystem.layer), Effect.scoped))
const starts: [string, string | undefined][] = []

try {
await fs.mkdir(path.dirname(registration), { recursive: true })
await Bun.write(registration, JSON.stringify({ version, url: server.url.toString(), pid: process.pid }))
for (const mismatch of [undefined, "ignore"] as const) {
const resolved = await runPromise(
ServerConnection.resolve({
mismatch,
onStart: (reason, previous) => starts.push([reason, previous]),
}),
)
expect(resolved.endpoint.url).toBe(server.url.toString())
expect(resolved.service).toBeDefined()
}
expect(starts).toEqual([])
// Stop at admission: the registered PID belongs to this test process.
await expect(
runPromise(
ServerConnection.resolve({
mismatch: "replace",
onStart: (reason, previous) => {
starts.push([reason, previous])
throw new Error("Replacement admitted")
},
}),
),
).rejects.toThrow("Replacement admitted")
expect(starts).toEqual([["version-mismatch", version]])
expect((await Bun.file(registration).json()).version).toBe(version)
} finally {
await server.stop(true)
await fs.rm(root, { recursive: true, force: true })
}
})
29 changes: 12 additions & 17 deletions packages/client/src/effect/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { contenderPool, spawnServiceContender } from "../service-contender.js"
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
import { matchesVersion } from "../service-version.js"
import { PtyHandoff } from "../pty-handoff.js"
import { decide, fallback, headers, probeResult, same } from "../service-probe.js"
import { decide, fallback, headers, probeResult, same, timeoutTracker } from "../service-probe.js"

export * from "../service.js"
export { headers }
Expand Down Expand Up @@ -49,7 +49,7 @@ export const incumbent = Effect.fn("service.incumbent")(function* (
export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOptions = {}) {
const timing = ensureTiming(options)
const pool = contenderPool(timing)
let timeouts: { readonly info: Info; readonly count: number } | undefined
const timeouts = timeoutTracker()
let announced = false
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) =>
Effect.sync(() => {
Expand All @@ -72,21 +72,16 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
const registration = yield* registered(options.file, timing.requestTimeout)
const info = registration.info
const service = registration.service
if (registration.timedOut && info !== undefined) {
timeouts = {
info,
count: timeouts !== undefined && same(timeouts.info, info) ? timeouts.count + 1 : 1,
}
if (timeouts.count >= 3) {
yield* announce("missing")
yield* Effect.logWarning("Background service is unresponsive; recovery cannot preserve persistent terminals")
yield* Effect.tryPromise(() => PtyHandoff.clear(options.file ?? fallback()))
yield* terminate(info, options, timing)
pool.evict(info.pid)
pool.recruitNow()
timeouts = undefined
}
} else timeouts = undefined
const unresponsive = timeouts.record(registration)
if (unresponsive !== undefined) {
yield* announce("missing")
yield* Effect.logWarning("Background service is unresponsive; recovery cannot preserve persistent terminals")
yield* Effect.tryPromise(() => PtyHandoff.clear(options.file ?? fallback()))
yield* terminate(unresponsive, options, timing)
pool.evict(unresponsive.pid)
pool.recruitNow()
timeouts.reset()
}
if (service !== undefined) {
pool.serviceAnswered()
const decision = decide(service, options)
Expand Down
29 changes: 12 additions & 17 deletions packages/client/src/promise/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { contenderPool, spawnServiceContender } from "../service-contender.js"
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
import { matchesVersion } from "../service-version.js"
import { PtyHandoff } from "../pty-handoff.js"
import { decide, fallback, headers, probeResult, same } from "../service-probe.js"
import { decide, fallback, headers, probeResult, same, timeoutTracker } from "../service-probe.js"

export * from "../service.js"
export { headers }
Expand All @@ -29,7 +29,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
const timing = ensureTiming(options)
const deadline = Date.now() + timing.promiseTimeout
const pool = contenderPool(timing)
let timeouts: { readonly info: Info; readonly count: number } | undefined
const timeouts = timeoutTracker()
let announced = false

const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) => {
Expand All @@ -52,21 +52,16 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
if (Date.now() >= deadline)
throw pool.failure() ?? new Error("Timed out waiting for the background service to start")
const registration = await registered(options.file, timing.requestTimeout)
if (registration.timedOut && registration.info !== undefined) {
timeouts = {
info: registration.info,
count: timeouts !== undefined && same(timeouts.info, registration.info) ? timeouts.count + 1 : 1,
}
if (timeouts.count >= 3) {
announce("missing")
console.warn("Background service is unresponsive; recovery cannot preserve persistent terminals")
await PtyHandoff.clear(options.file ?? fallback())
await terminate(registration.info, options, timing)
pool.evict(registration.info.pid)
pool.recruitNow()
timeouts = undefined
}
} else timeouts = undefined
const unresponsive = timeouts.record(registration)
if (unresponsive !== undefined) {
announce("missing")
console.warn("Background service is unresponsive; recovery cannot preserve persistent terminals")
await PtyHandoff.clear(options.file ?? fallback())
await terminate(unresponsive, options, timing)
pool.evict(unresponsive.pid)
pool.recruitNow()
timeouts.reset()
}

if (registration.service !== undefined) {
pool.serviceAnswered()
Expand Down
21 changes: 21 additions & 0 deletions packages/client/src/service-probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,27 @@ export function same(left: Info, right: Info) {
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
}

/** Count consecutive probe timeouts for one owner, independently for each ensure invocation. */
export function timeoutTracker() {
let timeouts: { readonly info: Info; readonly count: number } | undefined
return {
record(registration: { readonly info?: Info; readonly timedOut: boolean }) {
if (!registration.timedOut || registration.info === undefined) {
timeouts = undefined
return undefined
}
timeouts = {
info: registration.info,
count: timeouts !== undefined && same(timeouts.info, registration.info) ? timeouts.count + 1 : 1,
}
return timeouts.count >= 3 ? registration.info : undefined
},
reset() {
timeouts = undefined
},
}
}

/** Ask a registered owner for its health and classify the answer. */
export async function probeResult(info: Info, timeout = defaultEnsureTiming.requestTimeout) {
const endpoint = {
Expand Down
73 changes: 10 additions & 63 deletions packages/client/src/solid/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ export type ClientConnectionOptions = {
readonly reconnect?: (signal: AbortSignal) => Promise<OpenCodeClient>
readonly onEvent: (event: OpenCodeEvent) => void
readonly flushInterval?: number
readonly pageLifecycle?: boolean
/**
* Abort and reconnect a stream that receives no bytes for this long. The server writes a keepalive
* comment every 15 seconds, so a quiet but healthy stream never trips this.
Expand All @@ -33,9 +32,6 @@ const connectTimeout = 2_000
const reconnectDelay = 1_000
const connectionHistoryLimit = 50
export const defaultIdleTimeout = 45_000
// Longer than one server keepalive interval: a stream that is silent this long when the page
// returns to the foreground is probably half-open after the device slept.
export const foregroundIdleThreshold = 20_000

export function createClientConnection(initialApi: OpenCodeClient, options: ClientConnectionOptions) {
const abort = new AbortController()
Expand All @@ -51,9 +47,6 @@ export function createClientConnection(initialApi: OpenCodeClient, options: Clie
let flushTimer: ReturnType<typeof setTimeout> | undefined
let stream: AbortController | undefined
let current: AbortController | undefined
let run: Promise<void> | undefined
let started = false
let generation = 0
let lastActivity = 0
let forced = false

Expand Down Expand Up @@ -82,7 +75,7 @@ export function createClientConnection(initialApi: OpenCodeClient, options: Clie
signal.addEventListener("abort", cancel, { once: true })

// Any received bytes, including keepalive comments, push the stall deadline out. A timer whose
// deadline passed while the page was suspended fires as soon as the page resumes.
// deadline passed while the runtime was suspended fires as soon as it resumes.
let watchdog: ReturnType<typeof setTimeout> | undefined
const touch = () => {
lastActivity = Date.now()
Expand Down Expand Up @@ -144,17 +137,17 @@ export function createClientConnection(initialApi: OpenCodeClient, options: Clie
}
}

async function runStream(active: number) {
async function runStream() {
let attempt = 0
while (!abort.signal.aborted && started && generation === active) {
while (!abort.signal.aborted) {
setConnection({ status: attempt === 0 ? "connecting" : "reconnecting", attempt })
const controller = new AbortController()
stream = controller
const cancel = () => controller.abort(abort.signal.reason)
abort.signal.addEventListener("abort", cancel)
const result = await connect(controller.signal, attempt)
abort.signal.removeEventListener("abort", cancel)
if (abort.signal.aborted || !started || generation !== active) return
if (abort.signal.aborted) return
if (result.connectedAt !== undefined && Date.now() - result.connectedAt >= reconnectDelay) attempt = 0
attempt += 1
const message = errorMessage(result.error)
Expand All @@ -167,7 +160,7 @@ export function createClientConnection(initialApi: OpenCodeClient, options: Clie
if (!controller.signal.aborted)
options.log?.info?.("server resolution failed", { attempt, error: errorMessage(error) })
})
if (abort.signal.aborted || controller.signal.aborted || !started || generation !== active) return
if (abort.signal.aborted || controller.signal.aborted) return
if (next) {
api = next
if (attempt === 1) continue
Expand All @@ -182,67 +175,21 @@ export function createClientConnection(initialApi: OpenCodeClient, options: Clie
}
}

function start() {
if (started) return run
started = true
forced = false
const active = ++generation
const previous = run
const current = (async () => {
if (previous) await previous
await runStream(active)
})().finally(() => {
if (run !== current) return
run = undefined
})
run = current
return run
}

function stop() {
if (!started) return
started = false
generation += 1
stream?.abort()
// Nothing is listening once stopped, so consumers must treat their data as stale until start() reconnects.
setConnection({ status: "connecting", attempt: 0, error: undefined })
}

// Drop the live request so the reconnect loop replaces it now instead of waiting for the idle watchdog.
function resync(reason: string) {
if (!started || connection.status !== "connected") return
if (abort.signal.aborted || connection.status !== "connected") return
options.log?.info?.("event stream resync", { reason, idle: Date.now() - lastActivity })
forced = true
current?.abort(new Error(reason))
}

if (options.pageLifecycle) {
const pagehide = () => stop()
const pageshow = () => void start()
// Locking a phone or switching apps hides the document without a pagehide; the socket usually
// dies while the page is suspended, and the browser may never report that on the hung read.
const visibility = () => {
if (document.visibilityState !== "visible") return
if (Date.now() - lastActivity < foregroundIdleThreshold) return
resync("Page returned to the foreground after the event stream went quiet")
}
const online = () => resync("Network connection restored")
window.addEventListener("pagehide", pagehide)
window.addEventListener("pageshow", pageshow)
window.addEventListener("online", online)
document.addEventListener("visibilitychange", visibility)
onCleanup(() => {
window.removeEventListener("pagehide", pagehide)
window.removeEventListener("pageshow", pageshow)
window.removeEventListener("online", online)
document.removeEventListener("visibilitychange", visibility)
})
}
void start()
void runStream()

onCleanup(() => {
stop()
abort.abort()
// Resolution can still be using this controller after connect removed its abort listener.
stream?.abort()
setConnection({ status: "connecting", attempt: 0, error: undefined })
if (flushTimer) clearTimeout(flushTimer)
pending = []
})
Expand Down
Loading
Loading