effectify Pty service (#18572)

This commit is contained in:
Kit Langton
2026-03-22 01:17:13 +00:00
committed by GitHub
parent fe53af4819
commit 13bac9c91a
2 changed files with 306 additions and 220 deletions
+150 -74
View File
@@ -1,13 +1,16 @@
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service"
import { Instance } from "@/project/instance"
import { type IPty } from "bun-pty" import { type IPty } from "bun-pty"
import z from "zod" import z from "zod"
import { Log } from "../util/log" import { Log } from "../util/log"
import { Instance } from "../project/instance"
import { lazy } from "@opencode-ai/util/lazy" import { lazy } from "@opencode-ai/util/lazy"
import { Shell } from "@/shell/shell" import { Shell } from "@/shell/shell"
import { Plugin } from "@/plugin" import { Plugin } from "@/plugin"
import { PtyID } from "./schema" import { PtyID } from "./schema"
import { Effect, Layer, ServiceMap } from "effect"
export namespace Pty { export namespace Pty {
const log = Log.create({ service: "pty" }) const log = Log.create({ service: "pty" })
@@ -23,6 +26,20 @@ export namespace Pty {
close: (code?: number, reason?: string) => void close: (code?: number, reason?: string) => void
} }
type Active = {
info: Info
process: IPty
buffer: string
bufferCursor: number
cursor: number
subscribers: Map<unknown, Socket>
}
type State = {
dir: string
sessions: Map<PtyID, Active>
}
// WebSocket control frame: 0x00 + UTF-8 JSON. // WebSocket control frame: 0x00 + UTF-8 JSON.
const meta = (cursor: number) => { const meta = (cursor: number) => {
const json = JSON.stringify({ cursor }) const json = JSON.stringify({ cursor })
@@ -81,43 +98,81 @@ export namespace Pty {
Deleted: BusEvent.define("pty.deleted", z.object({ id: PtyID.zod })), Deleted: BusEvent.define("pty.deleted", z.object({ id: PtyID.zod })),
} }
interface ActiveSession { export interface Interface {
info: Info readonly list: () => Effect.Effect<Info[]>
process: IPty readonly get: (id: PtyID) => Effect.Effect<Info | undefined>
buffer: string readonly create: (input: CreateInput) => Effect.Effect<Info>
bufferCursor: number readonly update: (id: PtyID, input: UpdateInput) => Effect.Effect<Info | undefined>
cursor: number readonly remove: (id: PtyID) => Effect.Effect<void>
subscribers: Map<unknown, Socket> readonly resize: (id: PtyID, cols: number, rows: number) => Effect.Effect<void>
readonly write: (id: PtyID, data: string) => Effect.Effect<void>
readonly connect: (
id: PtyID,
ws: Socket,
cursor?: number,
) => Effect.Effect<{ onMessage: (message: string | ArrayBuffer) => void; onClose: () => void } | undefined>
} }
const state = Instance.state( export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Pty") {}
() => new Map<PtyID, ActiveSession>(),
async (sessions) => { export const layer = Layer.effect(
for (const session of sessions.values()) { Service,
Effect.gen(function* () {
function teardown(session: Active) {
try { try {
session.process.kill() session.process.kill()
} catch {} } catch {}
for (const [key, ws] of session.subscribers.entries()) { for (const [key, ws] of session.subscribers.entries()) {
try { try {
if (ws.data === key) ws.close() if (ws.data === key) ws.close()
} catch { } catch {}
// ignore
} }
session.subscribers.clear()
} }
const cache = yield* InstanceState.make<State>(
Effect.fn("Pty.state")(function* (ctx) {
const state = {
dir: ctx.directory,
sessions: new Map<PtyID, Active>(),
} }
sessions.clear()
}, yield* Effect.addFinalizer(() =>
Effect.sync(() => {
for (const session of state.sessions.values()) {
teardown(session)
}
state.sessions.clear()
}),
) )
export function list() { return state
return Array.from(state().values()).map((s) => s.info) }),
} )
export function get(id: PtyID) { const remove = Effect.fn("Pty.remove")(function* (id: PtyID) {
return state().get(id)?.info const state = yield* InstanceState.get(cache)
} const session = state.sessions.get(id)
if (!session) return
state.sessions.delete(id)
log.info("removing session", { id })
teardown(session)
void Bus.publish(Event.Deleted, { id: session.info.id })
})
export async function create(input: CreateInput) { const list = Effect.fn("Pty.list")(function* () {
const state = yield* InstanceState.get(cache)
return Array.from(state.sessions.values()).map((session) => session.info)
})
const get = Effect.fn("Pty.get")(function* (id: PtyID) {
const state = yield* InstanceState.get(cache)
return state.sessions.get(id)?.info
})
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
const state = yield* InstanceState.get(cache)
return yield* Effect.promise(async () => {
const id = PtyID.ascending() const id = PtyID.ascending()
const command = input.command || Shell.preferred() const command = input.command || Shell.preferred()
const args = input.args || [] const args = input.args || []
@@ -125,7 +180,7 @@ export namespace Pty {
args.push("-l") args.push("-l")
} }
const cwd = input.cwd || Instance.directory const cwd = input.cwd || state.dir
const shellEnv = await Plugin.trigger("shell.env", { cwd }, { env: {} }) const shellEnv = await Plugin.trigger("shell.env", { cwd }, { env: {} })
const env = { const env = {
...process.env, ...process.env,
@@ -143,7 +198,7 @@ export namespace Pty {
log.info("creating session", { id, cmd: command, args, cwd }) log.info("creating session", { id, cmd: command, args, cwd })
const spawn = await pty() const spawn = await pty()
const ptyProcess = spawn(command, args, { const proc = spawn(command, args, {
name: "xterm-256color", name: "xterm-256color",
cwd, cwd,
env, env,
@@ -156,18 +211,18 @@ export namespace Pty {
args, args,
cwd, cwd,
status: "running", status: "running",
pid: ptyProcess.pid, pid: proc.pid,
} as const } as const
const session: ActiveSession = { const session: Active = {
info, info,
process: ptyProcess, process: proc,
buffer: "", buffer: "",
bufferCursor: 0, bufferCursor: 0,
cursor: 0, cursor: 0,
subscribers: new Map(), subscribers: new Map(),
} }
state().set(id, session) state.sessions.set(id, session)
ptyProcess.onData( proc.onData(
Instance.bind((chunk) => { Instance.bind((chunk) => {
session.cursor += chunk.length session.cursor += chunk.length
@@ -176,12 +231,10 @@ export namespace Pty {
session.subscribers.delete(key) session.subscribers.delete(key)
continue continue
} }
if (ws.data !== key) { if (ws.data !== key) {
session.subscribers.delete(key) session.subscribers.delete(key)
continue continue
} }
try { try {
ws.send(chunk) ws.send(chunk)
} catch { } catch {
@@ -196,21 +249,23 @@ export namespace Pty {
session.bufferCursor += excess session.bufferCursor += excess
}), }),
) )
ptyProcess.onExit( proc.onExit(
Instance.bind(({ exitCode }) => { Instance.bind(({ exitCode }) => {
if (session.info.status === "exited") return if (session.info.status === "exited") return
log.info("session exited", { id, exitCode }) log.info("session exited", { id, exitCode })
session.info.status = "exited" session.info.status = "exited"
Bus.publish(Event.Exited, { id, exitCode }) void Bus.publish(Event.Exited, { id, exitCode })
remove(id) Effect.runFork(remove(id))
}), }),
) )
Bus.publish(Event.Created, { info }) await Bus.publish(Event.Created, { info })
return info return info
} })
})
export async function update(id: PtyID, input: UpdateInput) { const update = Effect.fn("Pty.update")(function* (id: PtyID, input: UpdateInput) {
const session = state().get(id) const state = yield* InstanceState.get(cache)
const session = state.sessions.get(id)
if (!session) return if (!session) return
if (input.title) { if (input.title) {
session.info.title = input.title session.info.title = input.title
@@ -218,45 +273,29 @@ export namespace Pty {
if (input.size) { if (input.size) {
session.process.resize(input.size.cols, input.size.rows) session.process.resize(input.size.cols, input.size.rows)
} }
Bus.publish(Event.Updated, { info: session.info }) yield* Effect.promise(() => Bus.publish(Event.Updated, { info: session.info }))
return session.info return session.info
} })
export async function remove(id: PtyID) { const resize = Effect.fn("Pty.resize")(function* (id: PtyID, cols: number, rows: number) {
const session = state().get(id) const state = yield* InstanceState.get(cache)
if (!session) return const session = state.sessions.get(id)
state().delete(id)
log.info("removing session", { id })
try {
session.process.kill()
} catch {}
for (const [key, ws] of session.subscribers.entries()) {
try {
if (ws.data === key) ws.close()
} catch {
// ignore
}
}
session.subscribers.clear()
Bus.publish(Event.Deleted, { id: session.info.id })
}
export function resize(id: PtyID, cols: number, rows: number) {
const session = state().get(id)
if (session && session.info.status === "running") { if (session && session.info.status === "running") {
session.process.resize(cols, rows) session.process.resize(cols, rows)
} }
} })
export function write(id: PtyID, data: string) { const write = Effect.fn("Pty.write")(function* (id: PtyID, data: string) {
const session = state().get(id) const state = yield* InstanceState.get(cache)
const session = state.sessions.get(id)
if (session && session.info.status === "running") { if (session && session.info.status === "running") {
session.process.write(data) session.process.write(data)
} }
} })
export function connect(id: PtyID, ws: Socket, cursor?: number) { const connect = Effect.fn("Pty.connect")(function* (id: PtyID, ws: Socket, cursor?: number) {
const session = state().get(id) const state = yield* InstanceState.get(cache)
const session = state.sessions.get(id)
if (!session) { if (!session) {
ws.close() ws.close()
return return
@@ -265,19 +304,17 @@ export namespace Pty {
// Use ws.data as the unique key for this connection lifecycle. // Use ws.data as the unique key for this connection lifecycle.
// If ws.data is undefined, fallback to ws object. // If ws.data is undefined, fallback to ws object.
const connectionKey = ws.data && typeof ws.data === "object" ? ws.data : ws const key = ws.data && typeof ws.data === "object" ? ws.data : ws
// Optionally cleanup if the key somehow exists // Optionally cleanup if the key somehow exists
session.subscribers.delete(connectionKey) session.subscribers.delete(key)
session.subscribers.set(connectionKey, ws) session.subscribers.set(key, ws)
const cleanup = () => { const cleanup = () => {
session.subscribers.delete(connectionKey) session.subscribers.delete(key)
} }
const start = session.bufferCursor const start = session.bufferCursor
const end = session.cursor const end = session.cursor
const from = const from =
cursor === -1 ? end : typeof cursor === "number" && Number.isSafeInteger(cursor) ? Math.max(0, cursor) : 0 cursor === -1 ? end : typeof cursor === "number" && Number.isSafeInteger(cursor) ? Math.max(0, cursor) : 0
@@ -308,6 +345,7 @@ export namespace Pty {
ws.close() ws.close()
return return
} }
return { return {
onMessage: (message: string | ArrayBuffer) => { onMessage: (message: string | ArrayBuffer) => {
session.process.write(String(message)) session.process.write(String(message))
@@ -317,5 +355,43 @@ export namespace Pty {
cleanup() cleanup()
}, },
} }
})
return Service.of({ list, get, create, update, remove, resize, write, connect })
}),
)
const runPromise = makeRunPromise(Service, layer)
export async function list() {
return runPromise((svc) => svc.list())
}
export async function get(id: PtyID) {
return runPromise((svc) => svc.get(id))
}
export async function resize(id: PtyID, cols: number, rows: number) {
return runPromise((svc) => svc.resize(id, cols, rows))
}
export async function write(id: PtyID, data: string) {
return runPromise((svc) => svc.write(id, data))
}
export async function connect(id: PtyID, ws: Socket, cursor?: number) {
return runPromise((svc) => svc.connect(id, ws, cursor))
}
export async function create(input: CreateInput) {
return runPromise((svc) => svc.create(input))
}
export async function update(id: PtyID, input: UpdateInput) {
return runPromise((svc) => svc.update(id, input))
}
export async function remove(id: PtyID) {
return runPromise((svc) => svc.remove(id))
} }
} }
+17 -7
View File
@@ -28,7 +28,7 @@ export const PtyRoutes = lazy(() =>
}, },
}), }),
async (c) => { async (c) => {
return c.json(Pty.list()) return c.json(await Pty.list())
}, },
) )
.post( .post(
@@ -75,7 +75,7 @@ export const PtyRoutes = lazy(() =>
}), }),
validator("param", z.object({ ptyID: PtyID.zod })), validator("param", z.object({ ptyID: PtyID.zod })),
async (c) => { async (c) => {
const info = Pty.get(c.req.valid("param").ptyID) const info = await Pty.get(c.req.valid("param").ptyID)
if (!info) { if (!info) {
throw new NotFoundError({ message: "Session not found" }) throw new NotFoundError({ message: "Session not found" })
} }
@@ -150,7 +150,7 @@ export const PtyRoutes = lazy(() =>
}, },
}), }),
validator("param", z.object({ ptyID: PtyID.zod })), validator("param", z.object({ ptyID: PtyID.zod })),
upgradeWebSocket((c) => { upgradeWebSocket(async (c) => {
const id = PtyID.zod.parse(c.req.param("ptyID")) const id = PtyID.zod.parse(c.req.param("ptyID"))
const cursor = (() => { const cursor = (() => {
const value = c.req.query("cursor") const value = c.req.query("cursor")
@@ -159,8 +159,8 @@ export const PtyRoutes = lazy(() =>
if (!Number.isSafeInteger(parsed) || parsed < -1) return if (!Number.isSafeInteger(parsed) || parsed < -1) return
return parsed return parsed
})() })()
let handler: ReturnType<typeof Pty.connect> let handler: Awaited<ReturnType<typeof Pty.connect>>
if (!Pty.get(id)) throw new Error("Session not found") if (!(await Pty.get(id))) throw new Error("Session not found")
type Socket = { type Socket = {
readyState: number readyState: number
@@ -176,17 +176,27 @@ export const PtyRoutes = lazy(() =>
return typeof (value as { readyState?: unknown }).readyState === "number" return typeof (value as { readyState?: unknown }).readyState === "number"
} }
const pending: string[] = []
let ready = false
return { return {
onOpen(_event, ws) { async onOpen(_event, ws) {
const socket = ws.raw const socket = ws.raw
if (!isSocket(socket)) { if (!isSocket(socket)) {
ws.close() ws.close()
return return
} }
handler = Pty.connect(id, socket, cursor) handler = await Pty.connect(id, socket, cursor)
ready = true
for (const msg of pending) handler?.onMessage(msg)
pending.length = 0
}, },
onMessage(event) { onMessage(event) {
if (typeof event.data !== "string") return if (typeof event.data !== "string") return
if (!ready) {
pending.push(event.data)
return
}
handler?.onMessage(event.data) handler?.onMessage(event.data)
}, },
onClose() { onClose() {