fix(pty): own daemon lifetime and hand off restarts (#45551)

This commit is contained in:
James Long
2026-08-27 11:52:48 -04:00
committed by GitHub
parent 6dbb5f5008
commit 2b8a3e7ee0
24 changed files with 1107 additions and 645 deletions
+9 -9
View File
@@ -125,7 +125,7 @@
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/pty": "0.1.12",
"@opencode-ai/pty": "0.1.13",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
@@ -364,7 +364,7 @@
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/pty": "0.1.12",
"@opencode-ai/pty": "0.1.13",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@parcel/watcher": "2.5.1",
@@ -2175,19 +2175,19 @@
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
"@opencode-ai/pty": ["@opencode-ai/pty@0.1.12", "", { "optionalDependencies": { "@opencode-ai/pty-darwin-arm64": "0.1.12", "@opencode-ai/pty-darwin-x64": "0.1.12", "@opencode-ai/pty-linux-arm64-gnu": "0.1.12", "@opencode-ai/pty-linux-arm64-musl": "0.1.12", "@opencode-ai/pty-linux-x64-gnu": "0.1.12", "@opencode-ai/pty-linux-x64-musl": "0.1.12" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-dl4FyJUhTXThsWYY8txG/8/nwN7dE0M5Sic9r4L9f2pvtJnbR5zrCrPoiPIBIxZle1wVks1dhz4z/CfqLf5sCg=="],
"@opencode-ai/pty": ["@opencode-ai/pty@0.1.13", "", { "optionalDependencies": { "@opencode-ai/pty-darwin-arm64": "0.1.13", "@opencode-ai/pty-darwin-x64": "0.1.13", "@opencode-ai/pty-linux-arm64-gnu": "0.1.13", "@opencode-ai/pty-linux-arm64-musl": "0.1.13", "@opencode-ai/pty-linux-x64-gnu": "0.1.13", "@opencode-ai/pty-linux-x64-musl": "0.1.13" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-WPCN8h8HaZhhUcrMG0zu+4D9vco0EZiEg/gCF1K3JPRN6UsHMiXq1HVIy5IlyfcoyjfViRmQmXYE4AuU3laBjA=="],
"@opencode-ai/pty-darwin-arm64": ["@opencode-ai/pty-darwin-arm64@0.1.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tMvoriq3VegVlj1uEglc6qE0M7VXy61nyf9Si7tTO7xa8JiyxuFJSXOZ1pGeErDu+pe24hvTyVOR+gkdew8w9g=="],
"@opencode-ai/pty-darwin-arm64": ["@opencode-ai/pty-darwin-arm64@0.1.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fVtQZqVLBuJx/aB+5ojfmQifS1KMc9gxlxpFQ6bxEFU8tn8xHQTiFPaNroZgOtaw7I4ceGyx/eXieK1wp68yAA=="],
"@opencode-ai/pty-darwin-x64": ["@opencode-ai/pty-darwin-x64@0.1.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-Sn5vMLL5giHOhx7J5H6zwDp4YjjXorY+QV0IEYY+SCT4wQfRBliokIyj23pRl6P2RK3u9bDLXJHDNMfDVZ2Rxg=="],
"@opencode-ai/pty-darwin-x64": ["@opencode-ai/pty-darwin-x64@0.1.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-b/tAEm0hCMXraPM9cxR8Rg7X1UBZInRTaxWAS4Ht9eH1nWj1rANOLvHWiWX/vVh5TB0Ubg8bWPu4B0nZkEHROQ=="],
"@opencode-ai/pty-linux-arm64-gnu": ["@opencode-ai/pty-linux-arm64-gnu@0.1.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-HbnlKZy052l7G527wK0+05EXaUpZ4ykVAmNBEzqWCoi4TeQj2+Nr9kJ9trx9o1KrVcT4Ki58CCvN5QOls6Z0yQ=="],
"@opencode-ai/pty-linux-arm64-gnu": ["@opencode-ai/pty-linux-arm64-gnu@0.1.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-I124aSYBBjpGZnYExHfIajkvVK1FiK+//OJBGdqqFp5pas2Oruq4O8tv+pMoxomZIYh2ce/QhOOYLHRwXsthTg=="],
"@opencode-ai/pty-linux-arm64-musl": ["@opencode-ai/pty-linux-arm64-musl@0.1.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-2nTN7ggu1h9XgjNcoQMYjP5sirfYnAskpdFCOqjokLqhytX/IMMmkRTQs+foaEaPz0dAIQD3DQplR2jZIgxp1w=="],
"@opencode-ai/pty-linux-arm64-musl": ["@opencode-ai/pty-linux-arm64-musl@0.1.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-feWsfKpaDytGJzutoK43GqQwVghG2vHZt6BE/ydPZNuqIrySQ/6JfliUAMwn5BWs/Ky7ouSwKHCyAVeukusSvg=="],
"@opencode-ai/pty-linux-x64-gnu": ["@opencode-ai/pty-linux-x64-gnu@0.1.12", "", { "os": "linux", "cpu": "x64" }, "sha512-FnD5ndnObTQKAoaVvxLKi5W+r3/+dsaMsobz6uK0B9hlmffXxY5CQ6HQyUU/h3aIKLWXxho5XYkA2b9yrp8/gA=="],
"@opencode-ai/pty-linux-x64-gnu": ["@opencode-ai/pty-linux-x64-gnu@0.1.13", "", { "os": "linux", "cpu": "x64" }, "sha512-jliNgsevGuxfIeX7eyzjHhrJkF8uEUPnDLbF2v16uv69FhEHrraf7jyWkxazMP6rNvn2CGtwMMc4BXPS5pzjhg=="],
"@opencode-ai/pty-linux-x64-musl": ["@opencode-ai/pty-linux-x64-musl@0.1.12", "", { "os": "linux", "cpu": "x64" }, "sha512-prkrNu6uvjqoffxdGiDHSU5C0Y+kCSfv+lslu7dfRPgPKenVELNpRTAbOduyrWPac2vGt8j5NM61icJyodbJmA=="],
"@opencode-ai/pty-linux-x64-musl": ["@opencode-ai/pty-linux-x64-musl@0.1.13", "", { "os": "linux", "cpu": "x64" }, "sha512-rXDpidW66gz2b2M/NbUN8ZKmAxaJcASnuHATeXevlrFdiPUv8uJwvkRd6Pla1fp01Q65MkBmgRa7Q9c+H1PlzA=="],
"@opencode-ai/schema": ["@opencode-ai/schema@workspace:packages/schema"],
+1 -1
View File
@@ -27,7 +27,7 @@
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/pty": "0.1.12",
"@opencode-ai/pty": "0.1.13",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
+7 -2
View File
@@ -27,8 +27,13 @@ export async function resolveOpencodePty(target: Target): Promise<OpencodePtyAss
.map((value) => (value === "glibc" ? "gnu" : value))
.join("-")
const name = `@opencode-ai/pty-${suffix}`
const source = pty.resolve(`${name}/bin/opencode-pty`)
const manifest: unknown = JSON.parse(await readFile(pty.resolve(`${name}/package.json`), "utf8"))
const local = process.env.OPENCODE_PTY_BIN
if (local && (target.platform !== process.platform || target.arch !== process.arch))
throw new Error("OPENCODE_PTY_BIN can only be embedded in a build for the current platform and architecture")
const source = local ? path.resolve(local) : pty.resolve(`${name}/bin/opencode-pty`)
const manifest: unknown = local
? { version: "local" }
: JSON.parse(await readFile(pty.resolve(`${name}/package.json`), "utf8"))
if (!manifest || typeof manifest !== "object" || !("version" in manifest) || typeof manifest.version !== "string")
throw new Error(`Invalid package metadata for ${name}`)
+11 -1
View File
@@ -7,7 +7,8 @@ import { Global } from "@opencode-ai/util/global"
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
import { AppProcess } from "@opencode-ai/util/process"
import { randomBytes, randomUUID } from "node:crypto"
import { Effect, Option, Redacted, Schedule } from "effect"
import { Effect, Option, Redacted, Schedule, Schema } from "effect"
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
import { HttpServer } from "effect/unstable/http"
import { Env } from "./env"
import { ServiceConfig } from "./services/service-config"
@@ -40,6 +41,14 @@ export const run = Effect.fnUntraced(function* (options: Options) {
})
const processEffect = Effect.fnUntraced(function* (options: Options) {
const inherited = process.env.OPENCODE_PTY_HANDOFF
delete process.env.OPENCODE_PTY_HANDOFF
const handoff =
inherited === undefined
? undefined
: yield* Schema.decodeUnknownEffect(Schema.fromJsonString(PersistentPty.Handoff))(inherited).pipe(
Effect.mapError(() => new Error("Invalid PTY restart handoff")),
)
const global = yield* Global.Service
if (options.mode === "service") yield* Effect.sync(() => process.chdir(global.home))
return yield* Effect.scoped(
@@ -80,6 +89,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
hostname,
port,
password,
pty: { handoff },
simulation: truthy(process.env.OPENCODE_SIMULATE),
database: {
path:
+14
View File
@@ -1689,6 +1689,19 @@ export type ExperimentalPersistentPtyShutdownOperation<E = never> = () => Effect
E
>
export type ExperimentalPersistentPtyHandoffOutput = {
readonly handoff: {
readonly directory: string
readonly instanceID: string
readonly ticket: string
readonly expiresAt: number
} | null
}
export type ExperimentalPersistentPtyHandoffOperation<E = never> = () => Effect.Effect<
ExperimentalPersistentPtyHandoffOutput,
E
>
export type ExperimentalPersistentPtyGetInput = { readonly ptyID: Pty.ID }
export type ExperimentalPersistentPtyGetOutput = {
readonly id: Pty.ID
@@ -1775,6 +1788,7 @@ export interface ExperimentalApi<E = never> {
readonly list: ExperimentalPersistentPtyListOperation<E>
readonly create: ExperimentalPersistentPtyCreateOperation<E>
readonly shutdown: ExperimentalPersistentPtyShutdownOperation<E>
readonly handoff: ExperimentalPersistentPtyHandoffOperation<E>
readonly get: ExperimentalPersistentPtyGetOperation<E>
readonly update: ExperimentalPersistentPtyUpdateOperation<E>
readonly snapshot: ExperimentalPersistentPtySnapshotOperation<E>
@@ -203,6 +203,7 @@ import type {
ExperimentalPersistentPtyCreateInput,
ExperimentalPersistentPtyCreateOutput,
ExperimentalPersistentPtyShutdownOutput,
ExperimentalPersistentPtyHandoffOutput,
ExperimentalPersistentPtyGetInput,
ExperimentalPersistentPtyGetOutput,
ExperimentalPersistentPtyUpdateInput,
@@ -1265,6 +1266,11 @@ const EndpointExperimentalPersistentPtyShutdown = (raw: RawClient["server.experi
raw["persistentPty.shutdown"]({}).pipe(Effect.mapError(mapClientError)),
)
const EndpointExperimentalPersistentPtyHandoff = (raw: RawClient["server.experimental"]) => () =>
preserveEffect<ExperimentalPersistentPtyHandoffOutput>()(
raw["persistentPty.handoff"]({}).pipe(Effect.mapError(mapClientError)),
)
const EndpointExperimentalPersistentPtyGet =
(raw: RawClient["server.experimental"]) => (input: ExperimentalPersistentPtyGetInput) =>
preserveEffect<ExperimentalPersistentPtyGetOutput>()(
@@ -1318,6 +1324,7 @@ const adaptGroupExperimental = (raw: RawClient["server.experimental"]) => ({
list: EndpointExperimentalPersistentPtyList(raw),
create: EndpointExperimentalPersistentPtyCreate(raw),
shutdown: EndpointExperimentalPersistentPtyShutdown(raw),
handoff: EndpointExperimentalPersistentPtyHandoff(raw),
get: EndpointExperimentalPersistentPtyGet(raw),
update: EndpointExperimentalPersistentPtyUpdate(raw),
snapshot: EndpointExperimentalPersistentPtySnapshot(raw),
+19 -2
View File
@@ -11,6 +11,7 @@ import {
} 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"
export * from "../service.js"
/** Contents of the local service registration file. */
@@ -65,9 +66,10 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
const spawnContender = Effect.gen(function* () {
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
if (command === undefined) return yield* Effect.fail(new Error("Missing service command"))
const env = yield* Effect.tryPromise(() => PtyHandoff.environment(options.file ?? fallback(), options.env))
return yield* Effect.try({
try: () => {
return spawnServiceContender(command, args, options.env)
return spawnServiceContender(command, args, env)
},
catch: (cause) => new Error("Failed to start server", { cause }),
})
@@ -83,6 +85,8 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
}
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)
timeouts = undefined
lastSpawn = Date.now() - spawnDelay
@@ -91,11 +95,23 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
if (service !== undefined) {
spawnDelay = timing.spawnDelay
const compatible = !service.legacy && matchesVersion(service.version, options)
if (compatible && service.state === "ready") return Option.some(service)
if (compatible && service.state === "ready") {
yield* Effect.tryPromise(() => PtyHandoff.complete(options.file ?? fallback(), service.info))
return Option.some(service)
}
if (compatible && service.state === "failed")
return yield* Effect.fail(new Error("Background service failed to start"))
if (compatible) return Option.none<LocalService>()
yield* announce("version-mismatch", service.version)
if (!service.legacy && service.state === "ready")
yield* Effect.tryPromise(() =>
PtyHandoff.prepare(options.file ?? fallback(), service.info, timing.requestTimeout),
)
else {
if (!service.legacy)
yield* Effect.logWarning("Background service is not ready; replacement cannot preserve persistent terminals")
yield* Effect.tryPromise(() => PtyHandoff.clear(options.file ?? fallback()))
}
yield* terminate(service.info, options, timing).pipe(Effect.ignore)
lastSpawn = 0
return Option.none<LocalService>()
@@ -129,6 +145,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
/** Stop the registered local service. */
export const stop = Effect.fn("service.stop")(function* (options: StopOptions = {}) {
yield* Effect.tryPromise(() => PtyHandoff.clear(options.file ?? fallback()))
const info = yield* read(options.file)
if (info !== undefined) yield* terminate(info, options, defaultEnsureTiming)
})
@@ -199,6 +199,7 @@ import type {
ExperimentalPersistentPtyCreateInput,
ExperimentalPersistentPtyCreateOutput,
ExperimentalPersistentPtyShutdownOutput,
ExperimentalPersistentPtyHandoffOutput,
ExperimentalPersistentPtyGetInput,
ExperimentalPersistentPtyGetOutput,
ExperimentalPersistentPtyUpdateInput,
@@ -1723,6 +1724,17 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
handoff: (requestOptions?: RequestOptions) =>
request<ExperimentalPersistentPtyHandoffOutput>(
{
method: "POST",
path: `/api/experimental/persistent-pty/handoff`,
successStatus: 200,
declaredStatuses: [503, 401, 400],
empty: false,
},
requestOptions,
),
get: (input: ExperimentalPersistentPtyGetInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ExperimentalPersistentPtyGetOutput }>(
{
@@ -376,6 +376,8 @@ export type SessionStatus =
export type PtyTicketConnectToken = { ticket: string; expires_in: number }
export type PersistentPtyHandoff = { directory: string; instanceID: string; ticket: string; expiresAt: number }
export type ShellInfo1 = {
id: string
status: "running" | "exited" | "timeout" | "killed"
@@ -5807,6 +5809,8 @@ export type ExperimentalPersistentPtyCreateOutput = { data: PersistentPtyInfo }[
export type ExperimentalPersistentPtyShutdownOutput = void
export type ExperimentalPersistentPtyHandoffOutput = { handoff: PersistentPtyHandoff | null }
export type ExperimentalPersistentPtyGetInput = { readonly ptyID: { readonly ptyID: string }["ptyID"] }
export type ExperimentalPersistentPtyGetOutput = { data: PersistentPtyInfo }["data"]
+18 -4
View File
@@ -10,6 +10,7 @@ import {
} 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 type { ServiceHealth } from "./generated/types.js"
export * from "../service.js"
@@ -43,11 +44,11 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
announced = true
options.onStart?.(reason, previousVersion)
}
const spawnContender = () => {
const spawnContender = async () => {
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
if (command === undefined) throw new Error("Missing service command")
try {
return spawnServiceContender(command, args, options.env)
return spawnServiceContender(command, args, await PtyHandoff.environment(options.file ?? fallback(), options.env))
} catch (cause) {
throw new Error("Failed to start server", { cause })
}
@@ -64,6 +65,8 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
}
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)
timeouts = undefined
lastSpawn = Date.now() - spawnDelay
@@ -74,10 +77,20 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
spawnDelay = timing.spawnDelay
const service = registration.service
const compatible = !service.legacy && matchesVersion(service.version, options)
if (compatible && service.state === "ready") return service.endpoint
if (compatible && service.state === "ready") {
await PtyHandoff.complete(options.file ?? fallback(), service.info)
return service.endpoint
}
if (compatible && service.state === "failed") throw new Error("Background service failed to start")
if (!compatible) {
announce("version-mismatch", service.version)
if (!service.legacy && service.state === "ready")
await PtyHandoff.prepare(options.file ?? fallback(), service.info, timing.requestTimeout)
else {
if (!service.legacy)
console.warn("Background service is not ready; replacement cannot preserve persistent terminals")
await PtyHandoff.clear(options.file ?? fallback())
}
await terminate(service.info, options, timing).catch(() => undefined)
lastSpawn = 0
}
@@ -93,7 +106,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
// Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery.
if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
announce("missing")
contenders.add(spawnContender())
contenders.add(await spawnContender())
lastSpawn = Date.now()
}
}
@@ -106,6 +119,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
/** Stop the registered local service. */
export async function stop(options: StopOptions = {}) {
await PtyHandoff.clear(options.file ?? fallback())
const info = await read(options.file)
if (info !== undefined) await terminate(info, options, defaultEnsureTiming)
}
+139
View File
@@ -0,0 +1,139 @@
export * as PtyHandoff from "./pty-handoff.js"
import type { PersistentPty } from "@opencode-ai/schema/persistent-pty"
import { readFile, rename, rm, writeFile } from "node:fs/promises"
import type { Info } from "./service.js"
type Sidecar = {
readonly source: Pick<Info, "id" | "pid" | "url">
readonly handoff: PersistentPty.Handoff | null
readonly expiresAt: number
}
/** Publish the ticket before stopping its owner so every replacement contender can adopt it. */
export async function prepare(file: string, info: Info, timeout: number) {
const existing = await read(file)
if (existing !== undefined && existing.expiresAt > Date.now() && same(existing.source, info)) return
const { ClientError, OpenCode } = await import("./promise/index.js")
const client = OpenCode.make({
baseUrl: info.url,
headers:
info.password === undefined
? undefined
: { authorization: "Basic " + Buffer.from(`opencode:${info.password}`).toString("base64") },
})
const missing = (error: unknown) =>
error instanceof ClientError &&
error.reason === "UnexpectedStatus" &&
typeof error.cause === "object" &&
error.cause !== null &&
"status" in error.cause &&
error.cause.status === 404
const result = await client.experimental.persistentPty.handoff({ signal: AbortSignal.timeout(timeout) }).then(
(value) => ({ value }),
(cause: unknown) => ({ cause }),
)
if ("cause" in result) {
// Another caller may already have prepared and stopped this server.
const concurrent = await read(file)
if (concurrent !== undefined && concurrent.expiresAt > Date.now() && same(concurrent.source, info)) return
if (!missing(result.cause))
throw new Error("Failed to prepare persistent terminals for service replacement", { cause: result.cause })
console.warn("Background service cannot hand off persistent terminals; shutting them down before replacement")
await client.experimental.persistentPty
.shutdown({ signal: AbortSignal.timeout(timeout) })
.catch((cause: unknown) => {
if (missing(cause)) return
throw new Error("Failed to shut down persistent terminals before service replacement", { cause })
})
await publish(file, info, null)
return
}
const body: unknown = result.value
if (typeof body !== "object" || body === null || !("handoff" in body))
throw new Error("Invalid persistent terminal handoff response")
if (body.handoff === null) {
await publish(file, info, null)
return
}
if (!isHandoff(body.handoff) || body.handoff.expiresAt <= Date.now())
throw new Error("Invalid or expired persistent terminal handoff")
await publish(file, info, body.handoff)
}
async function publish(file: string, info: Info, handoff: PersistentPty.Handoff | null) {
const temporary = `${file}.pty-handoff.${crypto.randomUUID()}.tmp`
await writeFile(
temporary,
JSON.stringify({
source: { id: info.id, pid: info.pid, url: info.url },
handoff,
expiresAt: handoff?.expiresAt ?? Date.now() + 30_000,
} satisfies Sidecar),
{ mode: 0o600, flag: "wx" },
)
await rename(temporary, file + ".pty-handoff").finally(() => rm(temporary, { force: true }))
}
export async function environment(file: string, env?: Readonly<Record<string, string>>) {
const record = await read(file)
const current: Info | undefined = await readFile(file, "utf8")
.then((text) => JSON.parse(text))
.catch(() => undefined)
const handoff =
record !== undefined && record.expiresAt > Date.now() && (current === undefined || same(record.source, current))
? record.handoff
: undefined
return { ...env, OPENCODE_PTY_HANDOFF: handoff == null ? undefined : JSON.stringify(handoff) }
}
export async function complete(file: string, info: Info) {
const record = await read(file)
if (record !== undefined && !same(record.source, info)) await clear(file)
}
export async function clear(file: string) {
await rm(file + ".pty-handoff", { force: true })
}
async function read(file: string): Promise<Sidecar | undefined> {
const value: unknown = await readFile(file + ".pty-handoff", "utf8")
.then((text) => JSON.parse(text))
.catch(() => undefined)
if (typeof value !== "object" || value === null || !("source" in value) || !("handoff" in value)) return
if (typeof value.source !== "object" || value.source === null) return
if (!("pid" in value.source) || typeof value.source.pid !== "number") return
if (!("url" in value.source) || typeof value.source.url !== "string") return
if ("id" in value.source && typeof value.source.id !== "string") return
if (value.handoff !== null && !isHandoff(value.handoff)) return
if (!("expiresAt" in value) || typeof value.expiresAt !== "number" || !Number.isFinite(value.expiresAt)) return
return {
source: {
id: "id" in value.source && typeof value.source.id === "string" ? value.source.id : undefined,
pid: value.source.pid,
url: value.source.url,
},
handoff: value.handoff,
expiresAt: value.expiresAt,
}
}
function same(left: Sidecar["source"], right: Info) {
return left.id === right.id && left.pid === right.pid && left.url === right.url
}
function isHandoff(value: unknown): value is PersistentPty.Handoff {
return (
typeof value === "object" &&
value !== null &&
"directory" in value &&
typeof value.directory === "string" &&
"instanceID" in value &&
typeof value.instanceID === "string" &&
"ticket" in value &&
typeof value.ticket === "string" &&
"expiresAt" in value &&
typeof value.expiresAt === "number" &&
Number.isFinite(value.expiresAt)
)
}
+1 -1
View File
@@ -13,7 +13,7 @@ const stderrLimit = 8 * 1024
export function spawnServiceContender(
command: string,
args: ReadonlyArray<string>,
env?: Readonly<Record<string, string>>,
env?: Readonly<Record<string, string | undefined>>,
): ServiceContender {
const child = spawn(command, args, {
detached: true,
+18 -4
View File
@@ -11,8 +11,10 @@ if (mode === "record-start") {
await writeFile(registration + ".started", "")
process.exit(1)
}
if (mode === "environment")
if (mode === "environment") {
await writeFile(registration + ".environment", process.env.OPENCODE_SERVICE_ENV_TEST ?? "")
await writeFile(registration + ".handoff", process.env.OPENCODE_PTY_HANDOFF ?? "null")
}
if (mode === "signal") process.kill(process.pid, process.platform === "win32" ? "SIGTERM" : "SIGKILL")
if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated" || mode === "coordinated-failed-loser") {
@@ -30,14 +32,26 @@ if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated" ||
let requests = 0
let version = "test"
if (mode === "old") version = "old"
if (mode === "old" || mode === "handoff") version = "old"
if (mode === "incompatible") version = "1.9.0"
if (mode === "compatible" || mode === "delayed-compatible") version = "2.1.0-next.1"
const id = crypto.randomUUID()
const handoff = {
directory: registration + ".daemon",
instanceID: crypto.randomUUID(),
ticket: crypto.randomUUID(),
expiresAt: Date.now() + 30_000,
}
const server = Bun.serve({
port: 0,
async fetch(request) {
const pathname = new URL(request.url).pathname
if (pathname === "/api/experimental/persistent-pty/handoff" && mode === "handoff") {
if (request.method !== "POST" || request.headers.get("authorization") !== "Basic " + btoa("opencode:private"))
return new Response(null, { status: 401 })
await writeFile(registration + ".prepared", JSON.stringify(handoff))
return Response.json({ handoff })
}
if (pathname !== "/api/health") return new Response(null, { status: 404 })
requests += 1
if (mode === "starting") await writeFile(registration + ".health-request", "")
@@ -54,8 +68,7 @@ const server = Bun.serve({
if (mode === "starting" && !(await Bun.file(registration + ".release").exists()))
return Response.json({ healthy: true, version, pid: process.pid }, { status: 503 })
if (mode === "failed-owner") return Response.json({ healthy: true, version, pid: process.pid }, { status: 500 })
if (mode === "starting" || mode === "graceful")
return Response.json({ healthy: true, version, pid: process.pid })
if (mode === "starting" || mode === "graceful") return Response.json({ healthy: true, version, pid: process.pid })
return Response.json({ healthy: true, version, pid: process.pid })
},
})
@@ -67,6 +80,7 @@ await writeFile(
version: mode === "legacy" ? undefined : version,
url: server.url.toString(),
pid: process.pid,
password: "private",
}),
{ mode: 0o600 },
)
@@ -67,6 +67,24 @@ test("adds configured environment variables with native promises", async () => {
expect(await Bun.file(registration + ".environment").text()).toBe("configured")
})
test("passes the prepared handoff to the replacement server", async () => {
await using fixture = await serviceFixture()
const registration = fixture.registration
fixture.spawn("handoff")
await fixture.waitForFile()
await ensure({
file: registration,
version: "test",
command: fixture.command("environment"),
env: { OPENCODE_PTY_HANDOFF: "must-not-inherit" },
})
const replacement = await Bun.file(registration).json()
fixture.track(replacement.pid)
expect(await Bun.file(registration + ".handoff").json()).toEqual(await Bun.file(registration + ".prepared").json())
expect(await Bun.file(registration + ".pty-handoff").exists()).toBe(false)
})
test("waits for a live contender when another native contender fails", async () => {
await using fixture = await serviceFixture()
const registration = fixture.registration
+1 -1
View File
@@ -118,7 +118,7 @@
"@ff-labs/fff-node": "0.10.5",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/pty": "0.1.12",
"@opencode-ai/pty": "0.1.13",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/util": "workspace:*",
+142 -53
View File
@@ -3,8 +3,9 @@ import { readFile } from "node:fs/promises"
import net from "node:net"
import path from "node:path"
import { Data, Duration, Effect, Schema, Semaphore } from "effect"
import type { Handoff } from "@opencode-ai/schema/persistent-pty"
const ProtocolVersion = 6
const ProtocolVersion = 7
const MaxFrameBytes = 8 * 1024 * 1024
const Lifecycle = Schema.Union([
@@ -48,6 +49,8 @@ export const WireResponse = Schema.Union([
Schema.Struct({ type: Schema.Literal("created"), terminal: WireTerminal }),
Schema.Struct({ type: Schema.Literal("terminals"), terminals: Schema.Array(WireTerminal) }),
Schema.Struct({ type: Schema.Literal("ok") }),
Schema.Struct({ type: Schema.Literal("owned") }),
Schema.Struct({ type: Schema.Literal("handoff"), ticket: Schema.String, expires_at: Schema.Number }),
Schema.Struct({
type: Schema.Literal("snapshot"),
terminal: WireTerminal,
@@ -131,6 +134,7 @@ export interface DaemonTransport {
readonly request: (value: object, start?: boolean) => Effect.Effect<WireResponse, DaemonError>
readonly requestIfRunning: (value: object) => Effect.Effect<WireResponse | undefined, DaemonError>
readonly shutdown: Effect.Effect<WireResponse | undefined, DaemonError>
readonly handoff: Effect.Effect<Handoff | null, DaemonError>
readonly subscribe: (
id: number,
input: {
@@ -147,19 +151,51 @@ export interface DaemonTransport {
export const makeDaemonTransport = Effect.fn("PersistentPty.makeDaemonTransport")(function* (
directory: string,
binary: () => Promise<string> = () => Promise.resolve(process.env.OPENCODE_PTY_BIN || "opencode-pty"),
inherited?: Handoff,
) {
const startup = Semaphore.makeUnsafe(1)
let registration: Registration | undefined
let owner: Awaited<ReturnType<typeof openOwner>> | undefined
let closed = false
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
closed = true
owner?.socket.destroy()
}),
)
const claim = (current: Registration, ticket?: string) =>
Effect.tryPromise({
try: async (signal) => {
const connection = await openOwner(current, ticket, signal)
if (closed || signal.aborted) {
connection.socket.destroy()
throw new Error("PTY owner scope is closed")
}
owner = connection
registration = current
connection.socket.once("close", () => {
if (owner !== connection) return
owner = undefined
registration = undefined
})
},
catch: (cause) => failure("connect", cause),
}).pipe(
Effect.timeoutOrElse({
duration: Duration.seconds(5),
orElse: () => Effect.fail(new DaemonError({ kind: "connect", message: "PTY ownership claim timed out" })),
}),
)
const discover = Effect.fn("PersistentPty.daemon.discover")(function* () {
const value = yield* Effect.tryPromise({
try: () => readFile(path.join(directory, "service.json"), "utf8"),
catch: (cause) => failure("connect", cause),
})
const decoded = yield* Effect.try({
try: () => Schema.decodeUnknownSync(Registration)(JSON.parse(value)),
catch: (cause) => failure("protocol", cause),
})
const decoded = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Registration))(value).pipe(
Effect.mapError((cause) => failure("protocol", cause)),
)
if (decoded.protocol !== ProtocolVersion)
return yield* Effect.fail(
new DaemonError({
@@ -181,9 +217,9 @@ export const makeDaemonTransport = Effect.fn("PersistentPty.makeDaemonTransport"
const start = Effect.fn("PersistentPty.daemon.start")(function* () {
const executable = yield* Effect.tryPromise({ try: binary, catch: (cause) => failure("spawn", cause) })
yield* Effect.tryPromise({
const child = yield* Effect.tryPromise({
try: () =>
new Promise<void>((resolve, reject) => {
new Promise<ReturnType<typeof spawn>>((resolve, reject) => {
const child = spawn(executable, ["daemon"], {
detached: true,
stdio: "ignore",
@@ -191,43 +227,54 @@ export const makeDaemonTransport = Effect.fn("PersistentPty.makeDaemonTransport"
})
child.once("spawn", () => {
child.unref()
resolve()
resolve(child)
})
child.once("error", reject)
}),
catch: (cause) => failure("spawn", cause),
})
const deadline = Date.now() + 5_000
let last: DaemonError | undefined
while (Date.now() < deadline) {
const found = yield* discover().pipe(
Effect.map((value) => ({ value })),
Effect.catch((error) => {
last = error
return Effect.succeed(undefined)
}),
return yield* Effect.gen(function* () {
const deadline = Date.now() + 5_000
let last: DaemonError | undefined
while (Date.now() < deadline) {
const found = yield* discover().pipe(
Effect.map((value) => ({ value })),
Effect.catch((error) => {
last = error
return Effect.succeed(undefined)
}),
)
if (found) {
yield* claim(found.value)
return found.value
}
yield* Effect.sleep(50)
}
return yield* Effect.fail(
last ?? new DaemonError({ kind: "connect", message: "opencode-pty did not become ready" }),
)
if (found) return found.value
yield* Effect.sleep(50)
}
return yield* Effect.fail(
last ?? new DaemonError({ kind: "connect", message: "opencode-pty did not become ready" }),
}).pipe(
Effect.onError(() =>
Effect.sync(() => {
child.kill("SIGTERM")
}),
),
)
})
const connect = Effect.fn("PersistentPty.daemon.connect")(function* (shouldStart: boolean) {
if (closed) return yield* Effect.fail(new DaemonError({ kind: "connect", message: "PTY owner scope is closed" }))
if (registration) return registration
return yield* startup.withPermit(
Effect.gen(function* () {
if (registration) return registration
const found = yield* discover().pipe(
Effect.catch((error) => {
if (!shouldStart) return Effect.fail(error)
if (error.kind === "connect") return start()
if (error.kind !== "protocol" || error.pid === undefined) return Effect.fail(error)
return terminate(error.pid).pipe(Effect.andThen(start()))
if (shouldStart && error.kind === "connect") return start()
return Effect.fail(error)
}),
)
if (!owner) yield* claim(found)
registration = found
return found
}),
@@ -277,6 +324,27 @@ export const makeDaemonTransport = Effect.fn("PersistentPty.makeDaemonTransport"
return yield* Effect.fail(new DaemonError({ kind: "connect", message: "opencode-pty did not stop" }))
})
const handoff = startup.withPermit(
Effect.gen(function* () {
const current = owner
const registered = registration
if (!current || !registered) return null
const response = yield* Effect.tryPromise({
try: (signal) => current.exchange({ op: "prepare_handoff" }, signal),
catch: (cause) => failure("response", cause),
}).pipe(
Effect.timeoutOrElse({
duration: Duration.seconds(5),
orElse: () =>
Effect.fail(new DaemonError({ kind: "response", message: "PTY handoff preparation timed out" })),
}),
)
if (response.type !== "handoff")
return yield* Effect.fail(new DaemonError({ kind: "protocol", message: "Expected PTY handoff ticket" }))
return { directory, instanceID: registered.instance_id, ticket: response.ticket, expiresAt: response.expires_at }
}),
)
const subscribe = Effect.fn("PersistentPty.daemon.subscribe")(function* (
id: number,
input: Parameters<DaemonTransport["subscribe"]>[1],
@@ -296,9 +364,56 @@ export const makeDaemonTransport = Effect.fn("PersistentPty.makeDaemonTransport"
return yield* attempt.pipe(Effect.catch((error) => (error.kind === "registration" ? attempt : Effect.fail(error))))
})
return { request, requestIfRunning, shutdown, subscribe } satisfies DaemonTransport
// A replacement must own its inherited daemon before the server becomes ready.
if (inherited) {
if (inherited.expiresAt <= Date.now())
return yield* Effect.fail(new DaemonError({ kind: "registration", message: "PTY restart handoff expired" }))
const current = yield* discover()
if (current.instance_id !== inherited.instanceID)
return yield* Effect.fail(new DaemonError({ kind: "registration", message: "PTY restart daemon changed" }))
yield* claim(current, inherited.ticket)
}
return { request, requestIfRunning, shutdown, handoff, subscribe } satisfies DaemonTransport
})
async function openOwner(registration: Registration, ticket: string | undefined, signal: AbortSignal) {
const socket = net.createConnection({ path: registration.socket })
const frames = decoder(socket)
const abort = () => socket.destroy()
signal.addEventListener("abort", abort, { once: true })
const exchange = async (request: object, signal: AbortSignal) => {
if (signal.aborted) throw new Error("PTY ownership request interrupted")
signal.addEventListener("abort", abort, { once: true })
try {
socket.write(encode({ token: registration.token, request }))
const frame = await frames.next()
if (frame.done) throw new Error("PTY daemon closed its ownership connection")
const response = decode(frame.value)
if (response.type === "error") throw new Error(response.message)
return response
} finally {
signal.removeEventListener("abort", abort)
}
}
try {
await new Promise<void>((resolve, reject) => {
socket.once("connect", resolve)
socket.once("error", reject)
socket.once("close", () => reject(new Error("PTY ownership connection closed")))
})
const response = await exchange({ op: "own", instance_id: registration.instance_id, ticket }, signal)
if (response.type !== "owned") throw new Error("PTY daemon does not support server ownership")
socket.unref()
return { socket, exchange }
} catch (error) {
socket.destroy()
throw error
} finally {
signal.removeEventListener("abort", abort)
}
}
const oneShot = Effect.fn("PersistentPty.daemon.oneShot")(function* (registration: Registration, request: object) {
const payload = yield* Effect.try({
try: () => encode({ token: registration.token, request }),
@@ -507,29 +622,3 @@ function decode(payload: Uint8Array) {
function failure(kind: DaemonError["kind"], cause: unknown) {
return new DaemonError({ kind, message: cause instanceof Error ? cause.message : String(cause) })
}
const terminate = Effect.fn("PersistentPty.daemon.terminate-incompatible")(function* (pid: number) {
yield* Effect.logWarning("replacing incompatible opencode-pty daemon", { pid })
yield* Effect.try({ try: () => process.kill(pid, "SIGTERM"), catch: (cause) => failure("spawn", cause) }).pipe(
Effect.catch((error) => (isMissingProcess(error) ? Effect.void : Effect.fail(error))),
)
const deadline = Date.now() + 2_000
while (Date.now() < deadline && processRunning(pid)) yield* Effect.sleep(25)
if (!processRunning(pid)) return
yield* Effect.try({ try: () => process.kill(pid, "SIGKILL"), catch: (cause) => failure("spawn", cause) }).pipe(
Effect.catch((error) => (isMissingProcess(error) ? Effect.void : Effect.fail(error))),
)
})
function processRunning(pid: number) {
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
function isMissingProcess(error: DaemonError) {
return error.message.includes("ESRCH") || error.message.includes("no such process")
}
+228 -219
View File
@@ -1,14 +1,12 @@
export * as PersistentPty from "./index.js"
import { createHash } from "node:crypto"
import os from "node:os"
import path from "node:path"
import { Context, Effect, Layer, Schema } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Added, Removed } from "@opencode-ai/schema/persistent-pty"
import { Added, Handoff, Removed } from "@opencode-ai/schema/persistent-pty"
import { Session } from "@opencode-ai/schema/session"
import { Bus } from "../bus.js"
import { Database } from "../database/database.js"
import { Pty } from "@opencode-ai/schema/pty"
import { Global } from "@opencode-ai/util/global"
import { ShellSelect } from "../shell/select.js"
@@ -23,6 +21,10 @@ import {
import { resolveBinary } from "#persistent-pty-binary"
export type { Role, StreamEvent } from "./daemon.js"
export { Handoff } from "@opencode-ai/schema/persistent-pty"
export const Options = Schema.Struct({ handoff: Schema.optional(Handoff) })
export type Options = typeof Options.Type
export type Info = Pty.Info & {
readonly sessionID: Session.ID
@@ -103,6 +105,7 @@ export interface Interface {
readonly snapshot: (id: Pty.ID) => Effect.Effect<Snapshot, NotFoundError | UnavailableError>
readonly remove: (id: Pty.ID) => Effect.Effect<void, NotFoundError | UnavailableError>
readonly shutdown: () => Effect.Effect<void, UnavailableError>
readonly handoff: () => Effect.Effect<Handoff | null, UnavailableError>
readonly attach: (
id: Pty.ID,
input: {
@@ -118,218 +121,235 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/PersistentPty") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const database = yield* Database.Service
const global = yield* Global.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
let binary: Promise<string> | undefined
const daemon = yield* makeDaemonTransport(
runtimeDirectory(databasePath(database.db)),
() =>
(binary ??= resolveBinary(global.bin).catch((error) => {
binary = undefined
throw error
})),
)
const removing = new Set<Pty.ID>()
export const configured = (options: Options = {}) =>
Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const global = yield* Global.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
let binary: Promise<string> | undefined
const daemon = yield* makeDaemonTransport(
options.handoff?.directory ?? runtimeDirectory(),
() =>
(binary ??= resolveBinary(global.bin).catch((error) => {
binary = undefined
throw error
})),
options.handoff,
).pipe(Effect.mapError(unavailable))
const removing = new Set<Pty.ID>()
const list = Effect.fn("PersistentPty.list")(function* (sessionID?: Session.ID) {
const response = yield* optionalRequest(daemon, { op: "list" })
if (!response) return []
if (response.type !== "terminals") return yield* unexpected(response)
return response.terminals
.map(toInfo)
.filter((terminal) => sessionID === undefined || terminal.sessionID === sessionID)
})
const list = Effect.fn("PersistentPty.list")(function* (sessionID?: Session.ID) {
const response = yield* optionalRequest(daemon, { op: "list" })
if (!response) return []
if (response.type !== "terminals") return yield* unexpected(response)
return response.terminals
.map(toInfo)
.filter((terminal) => sessionID === undefined || terminal.sessionID === sessionID)
})
const get = Effect.fn("PersistentPty.get")(function* (id: Pty.ID) {
const found = (yield* list()).find((terminal) => terminal.id === id)
if (!found) return yield* new NotFoundError({ ptyID: id })
return found
})
const get = Effect.fn("PersistentPty.get")(function* (id: Pty.ID) {
const found = (yield* list()).find((terminal) => terminal.id === id)
if (!found) return yield* new NotFoundError({ ptyID: id })
return found
})
const create = Effect.fn("PersistentPty.create")(function* (
sessionID: Session.ID,
input: {
readonly command?: string
readonly args: readonly string[]
readonly cwd?: string
readonly title: string
readonly env: Readonly<Record<string, string>>
readonly cols?: number
readonly rows?: number
},
) {
const response = yield* request(
daemon,
{
op: "create",
program: input.command ?? ShellSelect.environment(global.bin),
args: input.args,
cwd: input.cwd ?? path.resolve("/"),
title: input.title,
group_id: sessionID,
env: input.env,
cols: input.cols ?? 80,
rows: input.rows ?? 24,
const create = Effect.fn("PersistentPty.create")(function* (
sessionID: Session.ID,
input: {
readonly command?: string
readonly args: readonly string[]
readonly cwd?: string
readonly title: string
readonly env: Readonly<Record<string, string>>
readonly cols?: number
readonly rows?: number
},
true,
)
if (response.type !== "created") return yield* unexpected(response)
const terminal = toInfo(response.terminal)
yield* bus.publish(Added, { sessionID, terminal })
return terminal
})
const write = Effect.fn("PersistentPty.write")(function* (id: Pty.ID, data: string, attachmentID?: string) {
yield* get(id)
const response = yield* request(daemon, {
op: "write",
id: fromID(id),
attachment_id: attachmentID ?? null,
data_base64: Buffer.from(data).toString("base64"),
})
if (response.type !== "ok") return yield* unexpected(response)
return undefined
})
const resize = Effect.fn("PersistentPty.resize")(function* (
id: Pty.ID,
cols: number,
rows: number,
attachmentID?: string,
) {
yield* get(id)
const response = yield* request(daemon, {
op: "resize",
id: fromID(id),
attachment_id: attachmentID ?? null,
cols,
rows,
})
if (response.type !== "ok") return yield* unexpected(response)
return undefined
})
const control = Effect.fn("PersistentPty.control")(function* (
id: Pty.ID,
attachmentID: string,
cols: number,
rows: number,
) {
yield* get(id)
const response = yield* request(daemon, {
op: "control",
id: fromID(id),
attachment_id: attachmentID,
cols,
rows,
})
if (response.type !== "ok") return yield* unexpected(response)
return undefined
})
const input = Effect.fn("PersistentPty.input")(function* (
id: Pty.ID,
attachmentID: string,
cols: number,
rows: number,
data: Uint8Array,
) {
yield* get(id)
const response = yield* request(daemon, {
op: "input",
id: fromID(id),
attachment_id: attachmentID,
cols,
rows,
data_base64: Buffer.from(data).toString("base64"),
})
if (response.type !== "ok") return yield* unexpected(response)
return undefined
})
const snapshot = Effect.fn("PersistentPty.snapshot")(function* (id: Pty.ID) {
yield* get(id)
const response = yield* request(daemon, { op: "snapshot", id: fromID(id) })
if (response.type !== "snapshot") return yield* unexpected(response)
return {
info: toInfo(response.terminal),
text: response.text,
checkpoint: Buffer.from(response.checkpoint_base64, "base64"),
cursor: { x: response.cursor_x, y: response.cursor_y },
}
})
const remove = Effect.fn("PersistentPty.remove")(function* (id: Pty.ID) {
const terminal = yield* get(id)
const response = yield* request(daemon, { op: "terminate", id: fromID(id) })
if (response.type !== "ok") return yield* unexpected(response)
yield* bus.publish(Removed, { sessionID: terminal.sessionID, ptyID: id })
return undefined
})
const shutdown = Effect.fn("PersistentPty.shutdown")(function* () {
const response = yield* daemon.shutdown.pipe(Effect.mapError(unavailable))
if (!response) return
if (response.type !== "ok") return yield* unexpected(response)
})
const removeVisibleExit = (id: Pty.ID) => {
if (removing.has(id)) return
removing.add(id)
runFork(
remove(id).pipe(
Effect.catchTags({
"PersistentPty.NotFoundError": () => Effect.void,
"PersistentPty.UnavailableError": (error) =>
Effect.logWarning("failed to remove visible exited terminal", { id, error: error.message }),
}),
Effect.ensuring(Effect.sync(() => removing.delete(id))),
),
)
}
const attach = Effect.fn("PersistentPty.attach")(function* (
id: Pty.ID,
input: {
readonly cursor: number
readonly attachmentID: string
readonly role: Role
readonly takeover?: boolean
readonly onEvent: (event: StreamEvent) => void
readonly onEnd: () => void
},
) {
yield* get(id)
const attachment = yield* daemon
.subscribe(fromID(id), {
...input,
onEvent: (event) => {
if (event.type === "exited") removeVisibleExit(id)
input.onEvent(event)
) {
const response = yield* request(
daemon,
{
op: "create",
program: input.command ?? ShellSelect.environment(global.bin),
args: input.args,
cwd: input.cwd ?? path.resolve("/"),
title: input.title,
group_id: sessionID,
env: input.env,
cols: input.cols ?? 80,
rows: input.rows ?? 24,
},
true,
)
if (response.type !== "created") return yield* unexpected(response)
const terminal = toInfo(response.terminal)
yield* bus.publish(Added, { sessionID, terminal })
return terminal
})
const write = Effect.fn("PersistentPty.write")(function* (id: Pty.ID, data: string, attachmentID?: string) {
yield* get(id)
const response = yield* request(daemon, {
op: "write",
id: fromID(id),
attachment_id: attachmentID ?? null,
data_base64: Buffer.from(data).toString("base64"),
})
.pipe(Effect.mapError(unavailable))
return {
info: toInfo(attachment.terminal),
role: attachment.role,
generation: attachment.generation,
replay: attachment.replay,
activate: attachment.activate,
detach: attachment.detach,
if (response.type !== "ok") return yield* unexpected(response)
return undefined
})
const resize = Effect.fn("PersistentPty.resize")(function* (
id: Pty.ID,
cols: number,
rows: number,
attachmentID?: string,
) {
yield* get(id)
const response = yield* request(daemon, {
op: "resize",
id: fromID(id),
attachment_id: attachmentID ?? null,
cols,
rows,
})
if (response.type !== "ok") return yield* unexpected(response)
return undefined
})
const control = Effect.fn("PersistentPty.control")(function* (
id: Pty.ID,
attachmentID: string,
cols: number,
rows: number,
) {
yield* get(id)
const response = yield* request(daemon, {
op: "control",
id: fromID(id),
attachment_id: attachmentID,
cols,
rows,
})
if (response.type !== "ok") return yield* unexpected(response)
return undefined
})
const input = Effect.fn("PersistentPty.input")(function* (
id: Pty.ID,
attachmentID: string,
cols: number,
rows: number,
data: Uint8Array,
) {
yield* get(id)
const response = yield* request(daemon, {
op: "input",
id: fromID(id),
attachment_id: attachmentID,
cols,
rows,
data_base64: Buffer.from(data).toString("base64"),
})
if (response.type !== "ok") return yield* unexpected(response)
return undefined
})
const snapshot = Effect.fn("PersistentPty.snapshot")(function* (id: Pty.ID) {
yield* get(id)
const response = yield* request(daemon, { op: "snapshot", id: fromID(id) })
if (response.type !== "snapshot") return yield* unexpected(response)
return {
info: toInfo(response.terminal),
text: response.text,
checkpoint: Buffer.from(response.checkpoint_base64, "base64"),
cursor: { x: response.cursor_x, y: response.cursor_y },
}
})
const remove = Effect.fn("PersistentPty.remove")(function* (id: Pty.ID) {
const terminal = yield* get(id)
const response = yield* request(daemon, { op: "terminate", id: fromID(id) })
if (response.type !== "ok") return yield* unexpected(response)
yield* bus.publish(Removed, { sessionID: terminal.sessionID, ptyID: id })
return undefined
})
const shutdown = Effect.fn("PersistentPty.shutdown")(function* () {
const response = yield* daemon.shutdown.pipe(Effect.mapError(unavailable))
if (!response) return
if (response.type !== "ok") return yield* unexpected(response)
})
const handoff = Effect.fn("PersistentPty.handoff")(() => daemon.handoff.pipe(Effect.mapError(unavailable)))
const removeVisibleExit = (id: Pty.ID) => {
if (removing.has(id)) return
removing.add(id)
runFork(
remove(id).pipe(
Effect.catchTags({
"PersistentPty.NotFoundError": () => Effect.void,
"PersistentPty.UnavailableError": (error) =>
Effect.logWarning("failed to remove visible exited terminal", { id, error: error.message }),
}),
Effect.ensuring(Effect.sync(() => removing.delete(id))),
),
)
}
})
return Service.of({ list, get, create, write, resize, control, input, snapshot, remove, shutdown, attach })
}),
)
const attach = Effect.fn("PersistentPty.attach")(function* (
id: Pty.ID,
input: {
readonly cursor: number
readonly attachmentID: string
readonly role: Role
readonly takeover?: boolean
readonly onEvent: (event: StreamEvent) => void
readonly onEnd: () => void
},
) {
yield* get(id)
const attachment = yield* daemon
.subscribe(fromID(id), {
...input,
onEvent: (event) => {
if (event.type === "exited") removeVisibleExit(id)
input.onEvent(event)
},
})
.pipe(Effect.mapError(unavailable))
return {
info: toInfo(attachment.terminal),
role: attachment.role,
generation: attachment.generation,
replay: attachment.replay,
activate: attachment.activate,
detach: attachment.detach,
}
})
export const node = makeGlobalNode({ service: Service, layer, deps: [Bus.node, Database.node, Global.node] })
return Service.of({
list,
get,
create,
write,
resize,
control,
input,
snapshot,
remove,
shutdown,
handoff,
attach,
})
}),
)
export const layer = configured()
export const node = makeGlobalNode({ service: Service, layer, deps: [Bus.node, Global.node] })
const request = (daemon: DaemonTransport, value: object, start = false) =>
daemon.request(value, start).pipe(Effect.mapError(unavailable))
@@ -343,17 +363,7 @@ const unexpected = (response: WireResponse) =>
const unavailable = (error: unknown) =>
new UnavailableError({ message: error instanceof Error ? error.message : String(error) })
function databasePath(db: Database.Interface["db"]) {
const client: unknown = db.$client
if ((typeof client !== "object" && typeof client !== "function") || client === null || !("config" in client))
return undefined
const config = client.config
if (typeof config !== "object" || config === null || !("filename" in config)) return undefined
if (typeof config.filename !== "string" || config.filename === ":memory:") return undefined
return path.resolve(config.filename)
}
const runtimeDirectory = (databasePath?: string) => {
const runtimeDirectory = () => {
const root =
process.env.OPENCODE_PTY_RUNTIME_DIR ??
(process.env.XDG_RUNTIME_DIR
@@ -362,8 +372,7 @@ const runtimeDirectory = (databasePath?: string) => {
os.tmpdir(),
`opencode-pty-${typeof process.getuid === "function" ? process.getuid() : process.env.USER || "unknown"}`,
))
const identity = databasePath ?? `memory:${crypto.randomUUID()}`
return path.join(root, createHash("sha256").update(identity).digest("hex").slice(0, 16))
return path.join(root, crypto.randomUUID())
}
function toInfo(value: WireTerminal): Info {
+159 -149
View File
@@ -1,4 +1,4 @@
import { expect, test } from "bun:test"
import { expect } from "bun:test"
import { spawn } from "node:child_process"
import { mkdtemp, rm, writeFile } from "node:fs/promises"
import net from "node:net"
@@ -6,198 +6,208 @@ import os from "node:os"
import path from "node:path"
import { Effect } from "effect"
import { makeDaemonTransport } from "../src/persistent-pty/daemon"
import { it } from "./lib/effect"
const pong = { type: "pong", instance_id: "test", pid: process.pid, protocol: 6 }
const pong = { type: "pong", instance_id: "test", pid: process.pid, protocol: 7 }
test("rediscovers a same-protocol daemon after its registration rotates", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-pty-registration-"))
const socketPath = path.join(directory, "daemon.sock")
let token = "old-token"
let instance = "old-instance"
let creates = 0
const server = await listen(socketPath, (_socket, request, receivedToken) => {
if (receivedToken !== token) return { type: "error", message: "authentication failed" }
if (request.op === "ping") return { ...pong, instance_id: instance }
if (request.op === "create") creates++
return request.op === "list" ? { type: "terminals", terminals: [] } : { type: "ok" }
})
try {
await writeRegistration(directory, socketPath, instance, token)
const daemon = await Effect.runPromise(makeDaemonTransport(directory))
await Effect.runPromise(daemon.request({ op: "list" }))
it.live("rediscovers a same-protocol daemon after its registration rotates", () =>
Effect.gen(function* () {
const directory = yield* temporaryDirectory()
const socketPath = path.join(directory, "daemon.sock")
let token = "old-token"
let instance = "old-instance"
let creates = 0
yield* listen(socketPath, (_socket, request, receivedToken) => {
if (receivedToken !== token) return { type: "error", message: "authentication failed" }
if (request.op === "ping") return { ...pong, instance_id: instance }
if (request.op === "create") creates++
return request.op === "list" ? { type: "terminals", terminals: [] } : { type: "ok" }
})
yield* Effect.promise(() => writeRegistration(directory, socketPath, instance, token))
const daemon = yield* makeDaemonTransport(directory)
yield* daemon.request({ op: "list" })
token = "new-token"
instance = "new-instance"
await writeRegistration(directory, socketPath, instance, token)
yield* Effect.promise(() => writeRegistration(directory, socketPath, instance, token))
const running = await Effect.runPromise(daemon.requestIfRunning({ op: "list" }))
const running = yield* daemon.requestIfRunning({ op: "list" })
expect(running).toEqual({ type: "terminals", terminals: [] })
token = "newest-token"
instance = "newest-instance"
await writeRegistration(directory, socketPath, instance, token)
await Effect.runPromise(daemon.request({ op: "create" }, true))
yield* Effect.promise(() => writeRegistration(directory, socketPath, instance, token))
yield* daemon.request({ op: "create" }, true)
expect(creates).toBe(1)
} finally {
await close(server)
await rm(directory, { recursive: true, force: true })
}
})
}),
)
test("rediscovers a rotated registration when acquiring a subscription", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-pty-subscription-"))
const socketPath = path.join(directory, "daemon.sock")
let token = "old-token"
let instance = "old-instance"
let subscriptions = 0
const server = await listen(socketPath, (_socket, request, receivedToken) => {
if (receivedToken !== token) return { type: "error", message: "authentication failed" }
if (request.op === "ping") return { ...pong, instance_id: instance }
if (request.op !== "subscribe") return { type: "terminals", terminals: [] }
subscriptions++
return {
type: "attached",
terminal: terminal(1),
role: "observer",
generation: 1,
requested_offset: 0,
available_offset: 0,
end_offset: 0,
truncated: false,
replay_base64: "",
}
})
try {
await writeRegistration(directory, socketPath, instance, token)
const daemon = await Effect.runPromise(makeDaemonTransport(directory))
await Effect.runPromise(daemon.request({ op: "list" }))
it.live("rediscovers a rotated registration when acquiring a subscription", () =>
Effect.gen(function* () {
const directory = yield* temporaryDirectory()
const socketPath = path.join(directory, "daemon.sock")
let token = "old-token"
let instance = "old-instance"
let subscriptions = 0
yield* listen(socketPath, (_socket, request, receivedToken) => {
if (receivedToken !== token) return { type: "error", message: "authentication failed" }
if (request.op === "ping") return { ...pong, instance_id: instance }
if (request.op !== "subscribe") return { type: "terminals", terminals: [] }
subscriptions++
return {
type: "attached",
terminal: terminal(1),
role: "observer",
generation: 1,
requested_offset: 0,
available_offset: 0,
end_offset: 0,
truncated: false,
replay_base64: "",
}
})
yield* Effect.promise(() => writeRegistration(directory, socketPath, instance, token))
const daemon = yield* makeDaemonTransport(directory)
yield* daemon.request({ op: "list" })
token = "new-token"
instance = "new-instance"
await writeRegistration(directory, socketPath, instance, token)
yield* Effect.promise(() => writeRegistration(directory, socketPath, instance, token))
const attachment = await Effect.runPromise(
daemon.subscribe(1, {
cursor: 0,
attachmentID: "attachment",
role: "observer",
onEvent: () => {},
onEnd: () => {},
}),
)
const attachment = yield* daemon.subscribe(1, {
cursor: 0,
attachmentID: "attachment",
role: "observer",
onEvent: () => {},
onEnd: () => {},
})
expect(attachment.terminal.id).toBe(1)
expect(subscriptions).toBe(1)
attachment.detach()
} finally {
await close(server)
await rm(directory, { recursive: true, force: true })
}
})
}),
)
test("retries a start-required request when connection fails before dispatch", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-pty-retry-"))
const firstSocket = path.join(directory, "first.sock")
const secondSocket = path.join(directory, "second.sock")
const first = await listen(firstSocket, (_socket, request) => {
if (request.op === "ping") return pong
return { type: "terminals", terminals: [] }
})
try {
await writeRegistration(directory, firstSocket)
const daemon = await Effect.runPromise(makeDaemonTransport(directory))
await Effect.runPromise(daemon.request({ op: "list" }))
await close(first)
it.live("retries a start-required request when connection fails before dispatch", () =>
Effect.gen(function* () {
const directory = yield* temporaryDirectory()
const firstSocket = path.join(directory, "first.sock")
const secondSocket = path.join(directory, "second.sock")
const first = yield* listen(firstSocket, (_socket, request) => {
if (request.op === "ping") return pong
return { type: "terminals", terminals: [] }
})
yield* Effect.promise(() => writeRegistration(directory, firstSocket))
const daemon = yield* makeDaemonTransport(directory)
yield* daemon.request({ op: "list" })
yield* Effect.promise(first.close)
let creates = 0
const second = await listen(secondSocket, (_socket, request) => {
yield* listen(secondSocket, (_socket, request) => {
if (request.op === "ping") return pong
creates++
return { type: "ok" }
})
try {
await writeRegistration(directory, secondSocket)
await Effect.runPromise(daemon.request({ op: "create" }, true))
expect(creates).toBe(1)
} finally {
await close(second)
}
} finally {
await close(first)
await rm(directory, { recursive: true, force: true })
}
})
yield* Effect.promise(() => writeRegistration(directory, secondSocket))
yield* daemon.request({ op: "create" }, true)
expect(creates).toBe(1)
}),
)
test("does not replay a dispatched mutating request when its response is lost", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-pty-response-"))
const socketPath = path.join(directory, "daemon.sock")
let creates = 0
const server = await listen(socketPath, (socket, request) => {
if (request.op === "ping") return pong
creates++
socket.destroy()
return undefined
})
try {
await writeRegistration(directory, socketPath)
const daemon = await Effect.runPromise(makeDaemonTransport(directory))
const error = await Effect.runPromise(Effect.flip(daemon.request({ op: "create" }, true)))
it.live("does not replay a dispatched mutating request when its response is lost", () =>
Effect.gen(function* () {
const directory = yield* temporaryDirectory()
const socketPath = path.join(directory, "daemon.sock")
let creates = 0
yield* listen(socketPath, (socket, request) => {
if (request.op === "ping") return pong
creates++
socket.destroy()
return undefined
})
yield* Effect.promise(() => writeRegistration(directory, socketPath))
const daemon = yield* makeDaemonTransport(directory)
const error = yield* Effect.flip(daemon.request({ op: "create" }, true))
expect(error.kind).toBe("response")
expect(creates).toBe(1)
} finally {
await close(server)
await rm(directory, { recursive: true, force: true })
}
})
}),
)
test("reports protocol mismatches until a start-required request replaces the daemon", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-pty-mismatch-"))
const existing = spawn("sleep", ["30"])
const exited = new Promise<void>((resolve) => existing.once("exit", () => resolve()))
try {
it.live("rejects incompatible daemons without replacing or killing them", () =>
Effect.gen(function* () {
const directory = yield* temporaryDirectory()
const existing = yield* Effect.acquireRelease(
Effect.sync(() => spawn("sleep", ["30"])),
(child) =>
Effect.promise(async () => {
if (child.exitCode !== null || child.signalCode !== null) return
const exited = new Promise<void>((resolve) => child.once("exit", () => resolve()))
child.kill("SIGKILL")
await exited
}),
)
if (existing.pid === undefined) throw new Error("Expected fixture process PID")
await writeFile(
path.join(directory, "service.json"),
JSON.stringify({ instance_id: "old", pid: existing.pid, protocol: 5, socket: "/unused", token: "old" }),
)
const daemon = await Effect.runPromise(
makeDaemonTransport(directory, () => Promise.resolve("/missing/opencode-pty")),
yield* Effect.promise(() =>
writeFile(
path.join(directory, "service.json"),
JSON.stringify({ instance_id: "old", pid: existing.pid, protocol: 6, socket: "/unused", token: "old" }),
),
)
const daemon = yield* makeDaemonTransport(directory, () => Promise.resolve("/missing/opencode-pty"))
const optional = await Effect.runPromise(Effect.flip(daemon.requestIfRunning({ op: "list" })))
const optional = yield* Effect.flip(daemon.requestIfRunning({ op: "list" }))
expect(optional).toMatchObject({
kind: "protocol",
message: "opencode-pty protocol mismatch: daemon=5, client=6",
message: "opencode-pty protocol mismatch: daemon=6, client=7",
pid: existing.pid,
})
expect(existing.exitCode).toBeNull()
const starting = await Effect.runPromise(Effect.flip(daemon.request({ op: "create" }, true)))
await exited
expect(starting).toMatchObject({ kind: "spawn" })
expect(existing.signalCode).toBe(process.platform === "win32" ? null : "SIGTERM")
} finally {
existing.kill("SIGKILL")
await rm(directory, { recursive: true, force: true })
}
})
const starting = yield* Effect.flip(daemon.request({ op: "create" }, true))
expect(starting).toEqual(optional)
expect(existing.exitCode).toBeNull()
expect(existing.signalCode).toBeNull()
expect(process.kill(existing.pid, 0)).toBeTrue()
}),
)
function temporaryDirectory() {
return Effect.acquireRelease(
Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "opencode-pty-test-"))),
(directory) => Effect.promise(() => rm(directory, { recursive: true, force: true })),
)
}
function listen(
socketPath: string,
handle: (socket: net.Socket, request: Record<string, unknown>, token: string) => object | undefined,
) {
const server = net.createServer((socket) => {
void readRequest(socket)
.then((envelope) => {
const response = handle(socket, envelope.request, envelope.token)
if (response) socket.write(frame(response))
return Effect.acquireRelease(
Effect.promise(async () => {
const sockets = new Set<net.Socket>()
const server = net.createServer((socket) => {
sockets.add(socket)
socket.once("close", () => sockets.delete(socket))
void readRequest(socket)
.then((envelope) => {
const response =
envelope.request.op === "own" ? { type: "owned" } : handle(socket, envelope.request, envelope.token)
if (response) socket.write(frame(response))
})
.catch(() => socket.destroy())
})
.catch(() => socket.destroy())
})
return new Promise<net.Server>((resolve, reject) => {
server.once("error", reject)
server.listen(socketPath, () => resolve(server))
})
await new Promise<void>((resolve, reject) => {
server.once("error", reject)
server.listen(socketPath, resolve)
})
return {
close: () => {
sockets.forEach((socket) => socket.destroy())
return close(server)
},
}
}),
(server) => Effect.promise(server.close),
)
}
function readRequest(socket: net.Socket) {
@@ -246,7 +256,7 @@ function frame(value: object) {
function writeRegistration(directory: string, socket: string, instance = "test", token = "test") {
return writeFile(
path.join(directory, "service.json"),
JSON.stringify({ instance_id: instance, pid: process.pid, protocol: 6, socket, token }),
JSON.stringify({ instance_id: instance, pid: process.pid, protocol: 7, socket, token }),
)
}
@@ -40,6 +40,12 @@ export const PersistentPtyGroup = HttpApiGroup.make("server.experimental")
error: [ServiceUnavailableError],
}),
)
.add(
HttpApiEndpoint.post("persistentPty.handoff", "/api/experimental/persistent-pty/handoff", {
success: Schema.Struct({ handoff: Schema.NullOr(PersistentPty.Handoff) }),
error: [ServiceUnavailableError],
}),
)
.add(
HttpApiEndpoint.get("persistentPty.get", "/api/experimental/persistent-pty/:ptyID", {
params: { ptyID: Pty.ID },
+8
View File
@@ -15,6 +15,14 @@ export const Info = Schema.Struct({
}).annotate({ identifier: "PersistentPty.Info" })
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const Handoff = Schema.Struct({
directory: Schema.String,
instanceID: Schema.String,
ticket: Schema.String,
expiresAt: Schema.Number,
}).annotate({ identifier: "PersistentPty.Handoff" })
export interface Handoff extends Schema.Schema.Type<typeof Handoff> {}
export const CreateInput = Schema.Struct({
command: optional(Schema.String),
args: Schema.Array(Schema.String),
@@ -51,6 +51,12 @@ export const PersistentPtyHandler = HttpApiBuilder.group(Api, "server.experiment
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"persistentPty.handoff",
Effect.fn(function* () {
return { handoff: yield* pty.handoff().pipe(mapUnavailable) }
}),
)
.handle(
"persistentPty.get",
Effect.fn(function* (ctx) {
+2
View File
@@ -1,5 +1,6 @@
import { Database } from "@opencode-ai/core/database/database"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { PersistentPty } from "@opencode-ai/core/persistent-pty"
import { Schema } from "effect"
export const ServerOptions = Schema.Struct({
@@ -15,6 +16,7 @@ export const ServerOptions = Schema.Struct({
password: Schema.optional(Schema.String),
simulation: Schema.optional(Schema.Boolean),
database: Schema.optional(Database.Options),
pty: Schema.optional(PersistentPty.Options),
events: Schema.optional(
Schema.Struct({
persist: Schema.optional(Schema.Boolean),
+1
View File
@@ -100,6 +100,7 @@ function makeRoutes<AuthError, AuthServices>(
const pluginRuntimeCell = PluginRuntime.makeCell()
const standard: LayerNode.Replacements = [
[Database.node, Database.configured(options.database)],
[PersistentPty.node, PersistentPty.configured(options.pty)],
[Bus.node, Bus.configured({ persist: options.events?.persist })],
[App.node, App.configured(options.app)],
[ModelsDev.node, ModelsDev.configured(options.models)],
+276 -199
View File
@@ -1,12 +1,11 @@
import { existsSync } from "node:fs"
import fs from "node:fs/promises"
import { createHash } from "node:crypto"
import os from "node:os"
import path from "node:path"
import { expect } from "bun:test"
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
import { Session } from "@opencode-ai/schema/session"
import { Effect, Schema } from "effect"
import { Effect, Exit, Schema, Scope } from "effect"
import { HttpServer } from "effect/unstable/http"
import { it } from "../../core/test/lib/effect"
import { ServerProcess } from "../src/process"
@@ -17,210 +16,288 @@ const smoke = existsSync(binary) ? it.live : it.live.skip
smoke(
"creates two persistent terminals for one session through the client API",
() =>
Effect.acquireUseRelease(
Effect.promise(async () => {
const environment = {
binary: process.env.OPENCODE_PTY_BIN,
runtime: process.env.OPENCODE_PTY_RUNTIME_DIR,
xdg: process.env.XDG_RUNTIME_DIR,
shell: process.env.SHELL,
Effect.gen(function* () {
const fixture = yield* testDirectory("xdg")
const server = yield* ServerProcess.start<never, never>({
hostname: "127.0.0.1",
port: 0,
password: "secret",
app: { version: "test-version" },
database: { path: fixture.database },
fs: { filewatcher: false },
})
const base = HttpServer.formatAddress(server.address)
const sessionID = Session.ID.make("ses_persistent_pty_test")
expect(existsSync(fixture.directory)).toBeFalse()
expect(yield* request(base, "POST", "/api/experimental/persistent-pty/handoff")).toEqual({
handoff: null,
})
expect((yield* request(base, "GET", `/api/experimental/session/${sessionID}/terminal`)).data).toEqual([])
expect(existsSync(fixture.directory)).toBeFalse()
const defaults = {
args: [],
cwd: fixture.root,
title: "default shell",
env: { SHELL: "/missing/client/zsh" },
}
const createPath = `/api/experimental/session/${sessionID}/terminal`
const terminal = Schema.decodeUnknownSync(PersistentPty.Info)(
(yield* request(base, "POST", createPath, defaults)).data,
)
expect(terminal.command).toBe("/bin/sh")
expect(terminal.cwd).toBe(fixture.root)
expect(terminal.cwd).not.toBe(process.cwd())
yield* request(base, "DELETE", `/api/experimental/persistent-pty/${terminal.id}`)
const root = Schema.decodeUnknownSync(PersistentPty.Info)(
(yield* request(base, "POST", createPath, {
args: ["-c", "printf 'root-cwd:%s\\n' \"$PWD\"; cat"],
title: "root directory",
env: {},
})).data,
)
expect(root.cwd).toBe(path.parse(fixture.root).root)
expect(root.cwd).not.toBe(process.cwd())
expect(yield* waitForText(base, root.id, `root-cwd:${root.cwd}`)).toContain(`root-cwd:${root.cwd}`)
yield* request(base, "DELETE", `/api/experimental/persistent-pty/${root.id}`)
const events = yield* Effect.promise(() => openEventStream(base))
const first = Schema.decodeUnknownSync(PersistentPty.Info)(
(yield* request(base, "POST", `/api/experimental/session/${sessionID}/terminal`, {
command: "/usr/bin/env",
args: ["/bin/sh", "-c", "stty -echo; printf terminal-one; cat"],
cwd: process.cwd(),
title: "first",
env: {},
})).data,
)
expect(first.command).toBe("/usr/bin/env")
expect(first.args).toEqual(["/bin/sh", "-c", "stty -echo; printf terminal-one; cat"])
expect(first.cwd).toBe(process.cwd())
expect(yield* Effect.promise(() => events.next("persistent-pty.added"))).toMatchObject({
data: { sessionID, terminal: { id: first.id } },
})
expect(first.size).toEqual({ cols: 80, rows: 24 })
const directories = yield* Effect.promise(() => fs.readdir(fixture.directory))
expect(directories).toHaveLength(1)
expect(directories[0]).toMatch(/^[0-9a-f-]{36}$/)
if (!directories[0]) throw new Error("Missing daemon directory")
expect(existsSync(path.join(fixture.directory, directories[0], "service.json"))).toBeTrue()
const second = Schema.decodeUnknownSync(PersistentPty.Info)(
(yield* request(base, "POST", `/api/experimental/session/${sessionID}/terminal`, {
command: "/bin/sh",
args: ["-c", "printf terminal-two; sleep 30"],
cwd: process.cwd(),
title: "second",
env: {},
})).data,
)
const terminals = Schema.decodeUnknownSync(Schema.Array(PersistentPty.Info))(
(yield* request(base, "GET", `/api/experimental/session/${sessionID}/terminal`)).data,
)
expect(terminals.map((terminal) => terminal.id).sort()).toEqual([first.id, second.id].sort())
expect(yield* waitForText(base, first.id, "terminal-one")).toContain("terminal-one")
expect(yield* waitForText(base, second.id, "terminal-two")).toContain("terminal-two")
yield* Effect.promise(() => verifySharedControl(base, first.id))
const snapshot = yield* request(base, "GET", `/api/experimental/persistent-pty/${first.id}/snapshot`)
if (
!isRecord(snapshot.data) ||
typeof snapshot.data.checkpoint !== "string" ||
!isRecord(snapshot.data.info) ||
!isRecord(snapshot.data.info.output) ||
typeof snapshot.data.info.output.tail !== "number"
)
throw new Error("Persistent PTY snapshot response was invalid")
expect(Buffer.from(snapshot.data.checkpoint, "base64").byteLength).toBeGreaterThan(0)
expect(snapshot.data.info.output.tail).toBeGreaterThan(0)
const ticket = yield* request(
base,
"POST",
`/api/experimental/persistent-pty/${first.id}/connect-token`,
undefined,
{
"x-opencode-ticket": "1",
},
)
if (!isRecord(ticket.data) || typeof ticket.data.ticket !== "string") throw new Error("Invalid connect ticket")
const connectTicket = ticket.data.ticket
yield* request(base, "DELETE", `/api/experimental/persistent-pty/${first.id}`)
yield* Effect.promise(async () => {
const url = new URL(`/api/experimental/persistent-pty/${first.id}/connect`, base)
url.searchParams.set("ticket", "invalid")
expect((await fetch(url)).status).toBe(403)
url.protocol = "ws:"
url.searchParams.set("ticket", connectTicket)
const socket = new WebSocket(url)
try {
const closed = await new Promise<CloseEvent>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error("Removed terminal socket did not close")), 5_000)
socket.addEventListener("close", (event) => {
clearTimeout(timeout)
resolve(event)
})
socket.addEventListener("error", () => {
clearTimeout(timeout)
reject(new Error("Valid ticket should upgrade before the missing terminal is reported"))
})
})
expect(closed.code).toBe(4404)
expect(closed.reason).toBe("terminal unavailable")
} finally {
socket.close()
}
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-pty-server-test-"))
const database = path.join(root, "opencode.db")
const runtime = path.join(root, "runtime")
process.env.OPENCODE_PTY_BIN = binary
delete process.env.OPENCODE_PTY_RUNTIME_DIR
process.env.XDG_RUNTIME_DIR = runtime
process.env.SHELL = "/bin/sh"
return {
database,
directory: path.join(
runtime,
"opencode-pty",
createHash("sha256").update(database).digest("hex").slice(0, 16),
),
environment,
root,
}
}),
(fixture) =>
Effect.gen(function* () {
const server = yield* ServerProcess.start<never, never>({
hostname: "127.0.0.1",
port: 0,
password: "secret",
app: { version: "test-version" },
database: { path: fixture.database },
fs: { filewatcher: false },
})
const base = HttpServer.formatAddress(server.address)
const sessionID = Session.ID.make("ses_persistent_pty_test")
expect(existsSync(path.join(fixture.directory, "service.json"))).toBeFalse()
expect((yield* request(base, "GET", `/api/experimental/session/${sessionID}/terminal`)).data).toEqual([])
expect(existsSync(path.join(fixture.directory, "service.json"))).toBeFalse()
const defaults = {
args: [],
cwd: fixture.root,
title: "default shell",
env: { SHELL: "/missing/client/zsh" },
}
const createPath = `/api/experimental/session/${sessionID}/terminal`
const terminal = Schema.decodeUnknownSync(PersistentPty.Info)(
(yield* request(base, "POST", createPath, defaults)).data,
)
expect(terminal.command).toBe("/bin/sh")
expect(terminal.cwd).toBe(fixture.root)
expect(terminal.cwd).not.toBe(process.cwd())
yield* request(base, "DELETE", `/api/experimental/persistent-pty/${terminal.id}`)
const root = Schema.decodeUnknownSync(PersistentPty.Info)(
(yield* request(base, "POST", createPath, {
args: ["-c", "printf 'root-cwd:%s\\n' \"$PWD\"; cat"],
title: "root directory",
env: {},
})).data,
)
expect(root.cwd).toBe(path.parse(fixture.root).root)
expect(root.cwd).not.toBe(process.cwd())
expect(yield* waitForText(base, root.id, `root-cwd:${root.cwd}`)).toContain(`root-cwd:${root.cwd}`)
yield* request(base, "DELETE", `/api/experimental/persistent-pty/${root.id}`)
const events = yield* Effect.promise(() => openEventStream(base))
const first = Schema.decodeUnknownSync(PersistentPty.Info)(
(yield* request(base, "POST", createPath, {
command: "/usr/bin/env",
args: ["/bin/sh", "-c", "stty -echo; printf terminal-one; cat"],
cwd: process.cwd(),
title: "first",
env: {},
})).data,
)
expect(first.command).toBe("/usr/bin/env")
expect(first.args).toEqual(["/bin/sh", "-c", "stty -echo; printf terminal-one; cat"])
expect(first.cwd).toBe(process.cwd())
expect(yield* Effect.promise(() => events.next("persistent-pty.added"))).toMatchObject({
data: { sessionID, terminal: { id: first.id } },
})
expect(first.size).toEqual({ cols: 80, rows: 24 })
expect(existsSync(path.join(fixture.directory, "service.json"))).toBeTrue()
const second = Schema.decodeUnknownSync(PersistentPty.Info)(
(yield* request(base, "POST", `/api/experimental/session/${sessionID}/terminal`, {
command: "/bin/sh",
args: ["-c", "printf terminal-two; sleep 30"],
cwd: process.cwd(),
title: "second",
env: {},
})).data,
)
})
expect(yield* Effect.promise(() => events.next("persistent-pty.removed"))).toMatchObject({
data: { sessionID, ptyID: first.id },
})
yield* request(base, "DELETE", `/api/experimental/persistent-pty/${second.id}`)
expect((yield* request(base, "GET", `/api/experimental/session/${sessionID}/terminal`)).data).toEqual([])
const terminals = Schema.decodeUnknownSync(Schema.Array(PersistentPty.Info))(
(yield* request(base, "GET", `/api/experimental/session/${sessionID}/terminal`)).data,
)
expect(terminals.map((terminal) => terminal.id).sort()).toEqual([first.id, second.id].sort())
expect(yield* waitForText(base, first.id, "terminal-one")).toContain("terminal-one")
expect(yield* waitForText(base, second.id, "terminal-two")).toContain("terminal-two")
yield* Effect.promise(() => verifySharedControl(base, first.id))
const snapshot = yield* request(base, "GET", `/api/experimental/persistent-pty/${first.id}/snapshot`)
if (
!isRecord(snapshot.data) ||
typeof snapshot.data.checkpoint !== "string" ||
!isRecord(snapshot.data.info) ||
!isRecord(snapshot.data.info.output) ||
typeof snapshot.data.info.output.tail !== "number"
)
throw new Error("Persistent PTY snapshot response was invalid")
expect(Buffer.from(snapshot.data.checkpoint, "base64").byteLength).toBeGreaterThan(0)
expect(snapshot.data.info.output.tail).toBeGreaterThan(0)
yield* request(base, "POST", "/api/experimental/persistent-pty/shutdown")
const ticket = yield* request(
base,
"POST",
`/api/experimental/persistent-pty/${first.id}/connect-token`,
undefined,
{
"x-opencode-ticket": "1",
},
)
if (!isRecord(ticket.data) || typeof ticket.data.ticket !== "string")
throw new Error("Invalid connect ticket")
const connectTicket = ticket.data.ticket
yield* request(base, "DELETE", `/api/experimental/persistent-pty/${first.id}`)
yield* Effect.promise(async () => {
const url = new URL(`/api/experimental/persistent-pty/${first.id}/connect`, base)
url.searchParams.set("ticket", "invalid")
expect((await fetch(url)).status).toBe(403)
url.protocol = "ws:"
url.searchParams.set("ticket", connectTicket)
const socket = new WebSocket(url)
try {
const closed = await new Promise<CloseEvent>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error("Removed terminal socket did not close")), 5_000)
socket.addEventListener("close", (event) => {
clearTimeout(timeout)
resolve(event)
})
socket.addEventListener("error", () => {
clearTimeout(timeout)
reject(new Error("Valid ticket should upgrade before the missing terminal is reported"))
})
})
expect(closed.code).toBe(4404)
expect(closed.reason).toBe("terminal unavailable")
} finally {
socket.close()
}
})
expect(yield* Effect.promise(() => events.next("persistent-pty.removed"))).toMatchObject({
data: { sessionID, ptyID: first.id },
})
yield* request(base, "DELETE", `/api/experimental/persistent-pty/${second.id}`)
expect((yield* request(base, "GET", `/api/experimental/session/${sessionID}/terminal`)).data).toEqual([])
const unattended = Schema.decodeUnknownSync(PersistentPty.Info)(
(yield* request(base, "POST", `/api/experimental/session/${sessionID}/terminal`, {
command: "/bin/sh",
args: ["-c", "exit 7"],
cwd: process.cwd(),
title: "unattended",
env: {},
})).data,
)
yield* waitForStatus(base, unattended.id, "exited")
expect((yield* request(base, "GET", `/api/experimental/session/${sessionID}/terminal`)).data).toMatchObject([
{ id: unattended.id, status: "exited" },
])
yield* request(base, "DELETE", `/api/experimental/persistent-pty/${unattended.id}`)
yield* request(base, "POST", "/api/experimental/persistent-pty/shutdown")
const unattended = Schema.decodeUnknownSync(PersistentPty.Info)(
(yield* request(base, "POST", `/api/experimental/session/${sessionID}/terminal`, {
command: "/bin/sh",
args: ["-c", "exit 7"],
cwd: process.cwd(),
title: "unattended",
env: {},
})).data,
)
yield* waitForStatus(base, unattended.id, "exited")
expect((yield* request(base, "GET", `/api/experimental/session/${sessionID}/terminal`)).data).toMatchObject([
{ id: unattended.id, status: "exited" },
])
yield* request(base, "DELETE", `/api/experimental/persistent-pty/${unattended.id}`)
const visible = Schema.decodeUnknownSync(PersistentPty.Info)(
(yield* request(base, "POST", `/api/experimental/session/${sessionID}/terminal`, {
command: "/bin/sh",
args: ["-c", "read value"],
cwd: process.cwd(),
title: "visible",
env: {},
})).data,
)
yield* attachAndExit(base, visible.id)
yield* waitForTerminals(base, sessionID, [])
yield* Effect.promise(() => events.close())
}),
(fixture) =>
Effect.promise(async () => {
await Bun.spawn([binary, "stop"], {
env: { ...process.env, OPENCODE_PTY_RUNTIME_DIR: fixture.directory },
stdout: "ignore",
stderr: "ignore",
}).exited
await fs.rm(fixture.root, { recursive: true, force: true })
restore("OPENCODE_PTY_BIN", fixture.environment.binary)
restore("OPENCODE_PTY_RUNTIME_DIR", fixture.environment.runtime)
restore("XDG_RUNTIME_DIR", fixture.environment.xdg)
restore("SHELL", fixture.environment.shell)
}),
),
const visible = Schema.decodeUnknownSync(PersistentPty.Info)(
(yield* request(base, "POST", `/api/experimental/session/${sessionID}/terminal`, {
command: "/bin/sh",
args: ["-c", "read value"],
cwd: process.cwd(),
title: "visible",
env: {},
})).data,
)
yield* attachAndExit(base, visible.id)
yield* waitForTerminals(base, sessionID, [])
yield* Effect.promise(() => events.close())
}),
20_000,
)
smoke(
"isolates servers sharing a database and preserves terminals only through explicit restart handoff",
() =>
Effect.gen(function* () {
const fixture = yield* testDirectory("override")
const scope = yield* Scope.Scope
const originalScope = yield* Scope.fork(scope)
const options = {
hostname: "127.0.0.1",
port: 0,
password: "secret",
app: { version: "test-version" },
database: { path: fixture.database },
fs: { filewatcher: false },
}
const original = yield* ServerProcess.start<never, never>(options).pipe(
Effect.provideService(Scope.Scope, originalScope),
)
const base = HttpServer.formatAddress(original.address)
const sessionID = Session.ID.make("ses_persistent_pty_restart")
const first = Schema.decodeUnknownSync(PersistentPty.Info)(
(yield* request(base, "POST", `/api/experimental/session/${sessionID}/terminal`, {
command: "/bin/sh",
args: ["-c", "stty -echo; printf before-restart; exec cat"],
cwd: process.cwd(),
title: "survivor",
env: {},
})).data,
)
expect(yield* waitForText(base, first.id, "before-restart")).toContain("before-restart")
const independent = yield* ServerProcess.start<never, never>(options)
const otherBase = HttpServer.formatAddress(independent.address)
expect((yield* request(otherBase, "GET", `/api/experimental/session/${sessionID}/terminal`)).data).toEqual([])
const handoff = Schema.decodeUnknownSync(PersistentPty.Handoff)(
(yield* request(base, "POST", "/api/experimental/persistent-pty/handoff")).handoff,
)
const registration = Schema.decodeUnknownSync(Schema.Struct({ pid: Schema.Number }))(
yield* Effect.promise(() => Bun.file(path.join(handoff.directory, "service.json")).json()),
)
yield* Scope.close(originalScope, Exit.void)
expect(process.kill(registration.pid, 0)).toBeTrue()
expect(process.kill(first.pid, 0)).toBeTrue()
const replacementScope = yield* Scope.fork(scope)
const replacement = yield* ServerProcess.start<never, never>({ ...options, pty: { handoff } }).pipe(
Effect.provideService(Scope.Scope, replacementScope),
)
const replacementBase = HttpServer.formatAddress(replacement.address)
expect(
(yield* request(replacementBase, "GET", `/api/experimental/session/${sessionID}/terminal`)).data,
).toMatchObject([{ id: first.id, pid: first.pid }])
expect(yield* waitForText(replacementBase, first.id, "before-restart")).toContain("before-restart")
yield* Scope.close(replacementScope, Exit.void)
yield* waitForExit(registration.pid)
yield* waitForExit(first.pid)
expect(existsSync(path.join(handoff.directory, "service.json"))).toBeFalse()
}),
30_000,
)
function testDirectory(mode: "xdg" | "override") {
return Effect.acquireRelease(
Effect.promise(async () => {
const environment = {
binary: process.env.OPENCODE_PTY_BIN,
runtime: process.env.OPENCODE_PTY_RUNTIME_DIR,
xdg: process.env.XDG_RUNTIME_DIR,
shell: process.env.SHELL,
}
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-pty-server-test-"))
const runtime = path.join(root, "runtime")
process.env.OPENCODE_PTY_BIN = binary
delete process.env.OPENCODE_PTY_RUNTIME_DIR
process.env.XDG_RUNTIME_DIR = runtime
process.env.SHELL = "/bin/sh"
if (mode === "override") process.env.OPENCODE_PTY_RUNTIME_DIR = runtime
return {
database: path.join(root, "opencode.db"),
directory: mode === "override" ? runtime : path.join(runtime, "opencode-pty"),
environment,
root,
}
}),
(fixture) =>
Effect.promise(async () => {
await fs.rm(fixture.root, { recursive: true, force: true })
restore("OPENCODE_PTY_BIN", fixture.environment.binary)
restore("OPENCODE_PTY_RUNTIME_DIR", fixture.environment.runtime)
restore("XDG_RUNTIME_DIR", fixture.environment.xdg)
restore("SHELL", fixture.environment.shell)
}),
)
}
function waitForExit(pid: number) {
return Effect.promise(async () => {
for (let attempt = 0; attempt < 100; attempt++) {
try {
process.kill(pid, 0)
} catch (error) {
if (isRecord(error) && error.code === "ESRCH") return
throw error
}
await Bun.sleep(20)
}
throw new Error(`Process ${pid} survived its server scope`)
})
}
function request(base: string, method: string, pathname: string, body?: unknown, headers?: Record<string, string>) {
return Effect.tryPromise({
try: async () => {