diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index 0ba72099a76..426f5f7f5b7 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -4,7 +4,7 @@ import { run } from "@opencode/tui" import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" import { Config } from "../../config" -import { Context, Effect, Fiber, FileSystem, Option, Queue } from "effect" +import { Context, Effect, FileSystem, Option, Queue, Schedule, Semaphore } from "effect" import { ServerConnection } from "../../services/server-connection" import { Updater } from "../../services/updater" import { UpdatePreflight } from "../../services/update-preflight" @@ -61,13 +61,29 @@ export default Runtime.handler(Commands, (input) => })) !== undefined const updater = yield* Updater.Service let installing: string | undefined - const updateListeners = new Set<(version: string) => void>() - const update = yield* updater + let latest: Updater.RunResult | undefined + const installListeners = new Set<(version: string) => void>() + const resultListeners = new Set<(result: Updater.RunResult) => void>() + // Background checks, `/update` lookups, and manual installs take turns so two installs never overlap. + const checking = yield* Semaphore.make(1) + yield* updater .run((version) => { installing = version - updateListeners.forEach((notify) => notify(version)) + installListeners.forEach((notify) => notify(version)) }) - .pipe(Effect.ensuring(Effect.sync(() => (installing = undefined))), Effect.forkScoped) + .pipe( + Effect.ensuring(Effect.sync(() => (installing = undefined))), + Effect.tap((result) => + Effect.sync(() => { + if (!result || (result.type === latest?.type && result.version === latest.version)) return + latest = result + resultListeners.forEach((notify) => notify(result)) + }), + ), + checking.withPermits(1), + Effect.repeat(Schedule.spaced("10 minutes")), + Effect.forkScoped({ startImmediately: true }), + ) preflight.loading() const config = yield* Config.Service const npm = yield* Npm.Service @@ -106,21 +122,19 @@ export default Runtime.handler(Commands, (input) => }, updater: { remote: requestedServer !== undefined, - subscribe: (notify, signal) => - runPromise( - Fiber.join(update).pipe( - Effect.flatMap((result) => (result === undefined ? Effect.void : Effect.sync(() => notify(result)))), - ), - { signal }, - ), + subscribe: (notify) => { + if (latest) notify(latest) + resultListeners.add(notify) + return () => resultListeners.delete(notify) + }, check: (signal, notify) => { if (installing) notify(installing) - updateListeners.add(notify) - return runPromise(Fiber.join(update).pipe(Effect.flatMap(() => updater.check())), { signal }).finally(() => - updateListeners.delete(notify), + installListeners.add(notify) + return runPromise(checking.withPermits(1)(updater.check()), { signal }).finally(() => + installListeners.delete(notify), ) }, - apply: (version) => runPromise(updater.apply(version)), + apply: (version) => runPromise(checking.withPermits(1)(updater.apply(version))), }, packages: { prepare: (spec, install = true) => runPromise(install ? npm.add(spec) : npm.resolve(spec)), diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 0592f3c94d7..f396062c2fb 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -13,6 +13,7 @@ import { Global } from "@opencode/util/global" import { AppProcess } from "@opencode/util/process" import { Config } from "./config" import { Npm } from "@opencode/util/npm" +import { EffectFlock } from "@opencode/util/effect-flock" import { Heap } from "./heap" import { CpuProfile } from "./cpu-profile" @@ -113,7 +114,7 @@ Effect.gen(function* () { Effect.provide(Config.layer), Effect.provide(Updater.layer), Effect.provide( - LayerNode.compile(LayerNode.group([Global.node, AppProcess.node, Npm.node]), { + LayerNode.compile(LayerNode.group([Global.node, AppProcess.node, Npm.node, EffectFlock.node]), { replacements: [ Global.node.replace( Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}), diff --git a/packages/cli/src/services/updater.ts b/packages/cli/src/services/updater.ts index 20828386c51..daa95bf4dbc 100644 --- a/packages/cli/src/services/updater.ts +++ b/packages/cli/src/services/updater.ts @@ -1,5 +1,6 @@ import { Global } from "@opencode/util/global" import { AppProcess } from "@opencode/util/process" +import { EffectFlock } from "@opencode/util/effect-flock" import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version" import { Context, Duration, Effect, FileSystem, Layer, Option, Ref, Schema } from "effect" import { ChildProcess } from "effect/unstable/process" @@ -127,6 +128,7 @@ const make = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem const global = yield* Global.Service const appProcess = yield* AppProcess.Service + const flock = yield* EffectFlock.Service const installedVersion = yield* Ref.make(OPENCODE_VERSION) const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-") const installedPackage = yield* Effect.gen(function* () { @@ -370,6 +372,9 @@ const make = Effect.gen(function* () { } yield* Effect.scoped( Effect.gen(function* () { + // Other OpenCode processes may be installing at the same time. Wait longer than the + // slowest install (curl runs two 5-minute commands). + yield* flock.acquire("cli-upgrade", undefined, { timeoutMs: Duration.toMillis("15 minutes") }) if (method === "bun") { // Bun does not prune old versions from its shared package cache. yield* fs.makeDirectory(global.cache, { recursive: true }) diff --git a/packages/cli/test/updater-install.test.ts b/packages/cli/test/updater-install.test.ts index 313ec915489..8ef9082aca7 100644 --- a/packages/cli/test/updater-install.test.ts +++ b/packages/cli/test/updater-install.test.ts @@ -1,8 +1,10 @@ import { NodeServices } from "@effect/platform-node" +import { EffectFlock } from "@opencode/util/effect-flock" +import { LayerNode } from "@opencode/util/effect/layer-node" import { Global } from "@opencode/util/global" import { AppProcess } from "@opencode/util/process" import { expect, spyOn, test } from "bun:test" -import { Effect, FileSystem, PlatformError, Stream } from "effect" +import { Effect, FileSystem, Layer, PlatformError, Stream } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { existsSync, readdirSync, readFileSync } from "node:fs" import path from "node:path" @@ -59,6 +61,11 @@ function fixture( const commands: string[][] = [] const updater = yield* Updater.Service.pipe( Effect.provide(Updater.layer), + Effect.provide( + LayerNode.compile(EffectFlock.node, { + replacements: [Global.node.replace(Layer.succeed(Global.Service, global))], + }), + ), Effect.provideService(Global.Service, global), Effect.provideService(FileSystem.FileSystem, { ...fs, diff --git a/packages/tui/src/context/update-notification.tsx b/packages/tui/src/context/update-notification.tsx index d4190681623..8179c1fba49 100644 --- a/packages/tui/src/context/update-notification.tsx +++ b/packages/tui/src/context/update-notification.tsx @@ -17,7 +17,7 @@ export type UpdateState = export type UpdateSource = { readonly remote: boolean - readonly subscribe: (notify: (notice: ClientNotice) => void, signal: AbortSignal) => Promise + readonly subscribe: (notify: (notice: ClientNotice) => void) => () => void readonly check: ( signal: AbortSignal, onInstall: (version: string) => void, @@ -115,15 +115,8 @@ export const { use: useUpdateNotification, provider: UpdateNotificationProvider } onMount(() => { - const updater = props.updater - if (!updater) return - const controller = new AbortController() - onCleanup(() => controller.abort()) - void updater - .subscribe((notice) => notify({ ...notice, source: "client" }), controller.signal) - .catch((error) => { - if (!controller.signal.aborted) log.error("update check failed", { error }) - }) + if (!props.updater) return + onCleanup(props.updater.subscribe((notice) => notify({ ...notice, source: "client" }))) }) onCleanup(