refactor(core): consolidate pty service (#30537)

This commit is contained in:
Shoubhit Dash 2026-06-03 15:17:46 +05:30 committed by GitHub
commit 932fb6c9ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 504 additions and 576 deletions

View file

@ -0,0 +1,24 @@
import { Effect } from "effect"
const inputDecoder = new TextDecoder("utf-8", { fatal: true })
export function handlePtyInput(
handler: { onMessage: (message: string | ArrayBuffer) => void },
message: string | Uint8Array,
) {
if (typeof message === "string") {
handler.onMessage(message)
return Effect.void
}
return Effect.try({
try: () => inputDecoder.decode(message),
catch: () => new Error("invalid PTY websocket input"),
}).pipe(
Effect.catch(() => Effect.succeed(undefined)),
Effect.flatMap((decoded) => {
if (decoded === undefined) return Effect.void
handler.onMessage(decoded)
return Effect.void
}),
)
}

View file

@ -0,0 +1,26 @@
import { spawn as create } from "bun-pty"
import type { Opts, Proc } from "./pty"
export type { Disp, Exit, Opts, Proc } from "./pty"
export function spawn(file: string, args: string[], opts: Opts): Proc {
const pty = create(file, args, opts)
return {
pid: pty.pid,
onData(listener) {
return pty.onData(listener)
},
onExit(listener) {
return pty.onExit(listener)
},
write(data) {
pty.write(data)
},
resize(cols, rows) {
pty.resize(cols, rows)
},
kill(signal) {
pty.kill(signal)
},
}
}

View file

@ -0,0 +1,26 @@
import * as pty from "@lydell/node-pty"
import type { Opts, Proc } from "./pty"
export type { Disp, Exit, Opts, Proc } from "./pty"
export function spawn(file: string, args: string[], opts: Opts): Proc {
const proc = pty.spawn(file, args, opts)
return {
pid: proc.pid,
onData(listener) {
return proc.onData(listener)
},
onExit(listener) {
return proc.onExit(listener)
},
write(data) {
proc.write(data)
},
resize(cols, rows) {
proc.resize(cols, rows)
},
kill(signal) {
proc.kill(signal)
},
}
}

View file

@ -0,0 +1,25 @@
export type Disp = {
dispose(): void
}
export type Exit = {
exitCode: number
signal?: number | string
}
export type Opts = {
name: string
cols?: number
rows?: number
cwd?: string
env?: Record<string, string>
}
export type Proc = {
pid: number
onData(listener: (data: string) => void): Disp
onExit(listener: (event: Exit) => void): Disp
write(data: string): void
resize(cols: number, rows: number): void
kill(signal?: string): void
}

View file

@ -0,0 +1,13 @@
import { Schema } from "effect"
import { Identifier } from "../id/id"
import { withStatics } from "../schema"
const ptyIdSchema = Schema.String.check(Schema.isStartsWith("pty")).pipe(Schema.brand("PtyID"))
export type PtyID = typeof ptyIdSchema.Type
export const PtyID = ptyIdSchema.pipe(
withStatics((schema: typeof ptyIdSchema) => ({
ascending: (id?: string) => schema.make(Identifier.ascending("pty", id)),
})),
)

View file

@ -0,0 +1,58 @@
export * as PtyTicket from "./ticket"
import { WorkspaceV2 } from "../workspace"
import { PositiveInt } from "../schema"
import { PtyID } from "./schema"
import { Cache, Context, Duration, Effect, Layer, Schema } from "effect"
const DEFAULT_TTL = Duration.seconds(60)
const CAPACITY = 10_000
export const ConnectToken = Schema.Struct({
ticket: Schema.String,
expires_in: PositiveInt,
})
export type Scope = {
readonly ptyID: PtyID
readonly directory?: string
readonly workspaceID?: WorkspaceV2.ID
}
export interface Interface {
issue(input: Scope): Effect.Effect<typeof ConnectToken.Type>
consume(input: Scope & { readonly ticket: string }): Effect.Effect<boolean>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PtyTicket") {}
function matches(record: Scope, input: Scope) {
return (
record.ptyID === input.ptyID && record.directory === input.directory && record.workspaceID === input.workspaceID
)
}
// Tickets are inserted via Cache.set and removed atomically via invalidateWhen. The lookup is
// never invoked; it dies if it ever is, which would signal a misuse of the Service interface.
const noLookup = () => Effect.die("PtyTicket cache must be used via set/invalidateWhen, never get")
// Visible for tests so the TTL can be shortened. Production uses `layer` with the default TTL.
export const make = (ttl: Duration.Input = DEFAULT_TTL) =>
Effect.gen(function* () {
const cache = yield* Cache.make<string, Scope>({ capacity: CAPACITY, lookup: noLookup, timeToLive: ttl })
const expiresIn = Math.max(1, Math.round(Duration.toSeconds(Duration.fromInputUnsafe(ttl))))
return Service.of({
issue: Effect.fn("PtyTicket.issue")(function* (input) {
const ticket = crypto.randomUUID()
yield* Cache.set(cache, ticket, input)
return { ticket, expires_in: expiresIn }
}),
consume: Effect.fn("PtyTicket.consume")(function* (input) {
return yield* Cache.invalidateWhen(cache, input.ticket, (stored) => matches(stored, input))
}),
})
})
export const layer = Layer.effect(Service, make())
export const defaultLayer = layer