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

This commit is contained in:
Shoubhit Dash
2026-06-03 15:17:46 +05:30
committed by GitHub
parent c6f684366a
commit 932fb6c9ec
28 changed files with 504 additions and 576 deletions

View File

@@ -1,33 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { Pty } from "../../src/pty"
// Windows ConPTY (via @lydell/node-pty >= 1.2.0-beta.12) assigns the child pid
// asynchronously: `proc.pid` reads back as 0 at the synchronous spawn point and
// only resolves to the real pid a tick later. `Pty.create` snapshots `proc.pid`
// while building `Info`, so `Info.pid` legitimately carries 0 right after spawn.
// `Pty.Info` must be able to represent that, otherwise every `pty.create` on
// Windows fails to encode/decode and the terminal feature is unusable.
const sample = (pid: number) => ({
id: "pty_01J5Y5H0AH4Q4NXJ6P4C3P5V2K",
title: "demo",
command: "cmd.exe",
args: [],
cwd: "C:\\",
status: "running",
pid,
})
describe("Pty.Info", () => {
test("accepts pid 0 (Windows ConPTY assigns the pid asynchronously)", () => {
expect(Schema.decodeUnknownSync(Pty.Info)(sample(0)).pid).toBe(0)
})
test("accepts a positive pid", () => {
expect(Schema.decodeUnknownSync(Pty.Info)(sample(48012)).pid).toBe(48012)
})
test("rejects a negative pid", () => {
expect(() => Schema.decodeUnknownSync(Pty.Info)(sample(-1))).toThrow()
})
})

View File

@@ -1,162 +0,0 @@
import { describe, expect } from "bun:test"
import { EventV2Bridge } from "../../src/event-v2-bridge"
import { Config } from "../../src/config/config"
import { Plugin } from "../../src/plugin"
import { Pty } from "../../src/pty"
import { Duration, Effect, Layer, Queue } from "effect"
import { testEffect } from "../lib/effect"
type Socket = Parameters<Pty.Interface["connect"]>[1]
const it = testEffect(
Pty.layer.pipe(
Layer.provideMerge(EventV2Bridge.defaultLayer),
Layer.provideMerge(Config.defaultLayer),
Layer.provideMerge(Plugin.defaultLayer),
),
)
const ptyTest = process.platform === "win32" ? it.instance.skip : it.instance
const createPty = Effect.fn("PtyOutputIsolationTest.createPty")(function* (input: Pty.CreateInput) {
const pty = yield* Pty.Service
return yield* Effect.acquireRelease(pty.create(input), (info) => pty.remove(info.id).pipe(Effect.ignore))
})
const decodeOutput = (data: string | Uint8Array | ArrayBuffer) =>
typeof data === "string"
? data
: Buffer.from(data instanceof Uint8Array ? data : new Uint8Array(data)).toString("utf8")
const makeSocket = Effect.fn("PtyOutputIsolationTest.makeSocket")(function* (data: unknown) {
const output = yield* Queue.unbounded<string>()
const chunks: string[] = []
const socket: Socket = {
readyState: 1,
data,
send: (data) => {
const text = decodeOutput(data)
chunks.push(text)
Queue.offerUnsafe(output, text)
},
close: () => {
// no-op (simulate abrupt drop)
},
}
return { socket, output, chunks }
})
const waitForOutput = (output: Queue.Queue<string>, text: string, duration: Duration.Input = "5 seconds") =>
Effect.gen(function* () {
let received = ""
while (!received.includes(text)) {
received += yield* Queue.take(output)
}
return received
}).pipe(
Effect.timeoutOrElse({
duration,
orElse: () => Effect.fail(new Error(`timeout waiting for output containing ${JSON.stringify(text)}`)),
}),
)
const waitForLeakedOutput = (output: Queue.Queue<string>, text: string) =>
Effect.gen(function* () {
let received = ""
while (!received.includes(text)) {
received += yield* Queue.take(output)
}
return received
}).pipe(
Effect.timeoutOrElse({
duration: "100 millis",
orElse: () => Effect.succeed(undefined),
}),
)
describe("pty", () => {
ptyTest(
"does not leak output when websocket objects are reused",
() =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const a = yield* createPty({ command: "cat", title: "a" })
const b = yield* createPty({ command: "cat", title: "b" })
const connectionA = yield* makeSocket({ events: { connection: "a" } })
const connectionB = { events: { connection: "b" } }
yield* pty.connect(a.id, connectionA.socket)
const outBQueue = yield* Queue.unbounded<string>()
const outB: string[] = []
connectionA.socket.data = connectionB
connectionA.socket.send = (data) => {
const text = decodeOutput(data)
outB.push(text)
Queue.offerUnsafe(outBQueue, text)
}
yield* pty.connect(b.id, connectionA.socket)
connectionA.chunks.length = 0
outB.length = 0
yield* pty.write(a.id, "AAA\n")
const verifyA = yield* makeSocket({ events: { connection: "verify-a" } })
yield* pty.connect(a.id, verifyA.socket)
yield* waitForOutput(verifyA.output, "AAA")
expect(outB.join("")).not.toContain("AAA")
expect(yield* waitForLeakedOutput(outBQueue, "AAA")).toBeUndefined()
}),
{ git: true },
)
ptyTest(
"does not leak output when Bun recycles websocket objects before re-connect",
() =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const a = yield* createPty({ command: "cat", title: "a" })
const outA = yield* makeSocket({ events: { connection: "a" } })
const outB = yield* Queue.unbounded<string>()
yield* pty.connect(a.id, outA.socket)
outA.chunks.length = 0
const connectionB = { events: { connection: "b" } }
outA.socket.data = connectionB
outA.socket.send = (data) => {
Queue.offerUnsafe(outB, decodeOutput(data))
}
yield* pty.write(a.id, "AAA\n")
const verifyA = yield* makeSocket({ events: { connection: "verify-a" } })
yield* pty.connect(a.id, verifyA.socket)
yield* waitForOutput(verifyA.output, "AAA")
expect(yield* waitForLeakedOutput(outB, "AAA")).toBeUndefined()
}),
{ git: true },
)
ptyTest(
"treats in-place socket data mutation as the same connection",
() =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const a = yield* createPty({ command: "cat", title: "a" })
const ctx = { connId: 1 }
const out = yield* makeSocket(ctx)
yield* pty.connect(a.id, out.socket)
out.chunks.length = 0
ctx.connId = 2
yield* pty.write(a.id, "AAA\n")
expect(yield* waitForOutput(out.output, "AAA")).toContain("AAA")
}),
{ git: true },
)
})

View File

@@ -1,140 +0,0 @@
import { describe, expect } from "bun:test"
import { EventV2Bridge } from "../../src/event-v2-bridge"
import { Config } from "../../src/config/config"
import { Plugin } from "../../src/plugin"
import { Pty } from "../../src/pty"
import type { PtyID } from "../../src/pty/schema"
import { Cause, Effect, Exit, Layer, Queue } from "effect"
import { testEffect } from "../lib/effect"
type PtyEvent = { type: "created" | "exited" | "deleted"; id: PtyID }
const it = testEffect(
Pty.layer.pipe(
Layer.provideMerge(EventV2Bridge.defaultLayer),
Layer.provideMerge(Config.defaultLayer),
Layer.provideMerge(Plugin.defaultLayer),
),
)
const ptyTest = process.platform === "win32" ? it.instance.skip : it.instance
const subscribePtyEvents = Effect.fn("PtySessionTest.subscribePtyEvents")(function* () {
const source = yield* EventV2Bridge.Service
const events = yield* Queue.unbounded<PtyEvent>()
const unsubscribe = yield* source.listen((event) => {
if (event.type === Pty.Event.Created.type)
Queue.offerUnsafe(events, { type: "created", id: (event.data as typeof Pty.Event.Created.data.Type).info.id })
if (event.type === Pty.Event.Exited.type)
Queue.offerUnsafe(events, { type: "exited", id: (event.data as typeof Pty.Event.Exited.data.Type).id })
if (event.type === Pty.Event.Deleted.type)
Queue.offerUnsafe(events, { type: "deleted", id: (event.data as typeof Pty.Event.Deleted.data.Type).id })
return Effect.void
})
yield* Effect.addFinalizer(() => unsubscribe)
return events
})
const createPty = Effect.fn("PtySessionTest.createPty")(function* (input: Pty.CreateInput) {
const pty = yield* Pty.Service
return yield* Effect.acquireRelease(pty.create(input), (info) => pty.remove(info.id).pipe(Effect.ignore))
})
const waitForEvents = (events: Queue.Queue<PtyEvent>, id: PtyID, count: number) => {
return Effect.gen(function* () {
const picked: Array<PtyEvent["type"]> = []
while (picked.length < count) {
const evt = yield* Queue.take(events)
if (evt.id === id) picked.push(evt.type)
}
return picked
}).pipe(
Effect.timeoutOrElse({
duration: "5 seconds",
orElse: () => Effect.fail(new Error("timeout waiting for pty events")),
}),
)
}
describe("pty", () => {
it.instance(
"returns typed not found errors for missing sessions",
() =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const id = "pty_missing" as PtyID
let closed = false
const socket = {
readyState: 1,
send: () => {},
close: () => {
closed = true
},
}
const get = yield* pty.get(id).pipe(Effect.exit)
expect(Exit.isFailure(get)).toBe(true)
if (Exit.isFailure(get)) expect(Cause.squash(get.cause)).toMatchObject({ _tag: "Pty.NotFoundError", ptyID: id })
const update = yield* pty.update(id, { title: "missing" }).pipe(Effect.exit)
expect(Exit.isFailure(update)).toBe(true)
if (Exit.isFailure(update))
expect(Cause.squash(update.cause)).toMatchObject({ _tag: "Pty.NotFoundError", ptyID: id })
const remove = yield* pty.remove(id).pipe(Effect.exit)
expect(Exit.isFailure(remove)).toBe(true)
if (Exit.isFailure(remove))
expect(Cause.squash(remove.cause)).toMatchObject({ _tag: "Pty.NotFoundError", ptyID: id })
const resize = yield* pty.resize(id, 80, 24).pipe(Effect.exit)
expect(Exit.isFailure(resize)).toBe(true)
if (Exit.isFailure(resize))
expect(Cause.squash(resize.cause)).toMatchObject({ _tag: "Pty.NotFoundError", ptyID: id })
const write = yield* pty.write(id, "input").pipe(Effect.exit)
expect(Exit.isFailure(write)).toBe(true)
if (Exit.isFailure(write))
expect(Cause.squash(write.cause)).toMatchObject({ _tag: "Pty.NotFoundError", ptyID: id })
const connect = yield* pty.connect(id, socket).pipe(Effect.exit)
expect(Exit.isFailure(connect)).toBe(true)
if (Exit.isFailure(connect))
expect(Cause.squash(connect.cause)).toMatchObject({ _tag: "Pty.NotFoundError", ptyID: id })
expect(closed).toBe(true)
}),
{ git: true },
)
ptyTest(
"publishes created, exited, deleted in order for a short-lived process",
() =>
Effect.gen(function* () {
const events = yield* subscribePtyEvents()
const info = yield* createPty({
command: "/usr/bin/env",
args: ["sh", "-c", "sleep 0.1"],
title: "sleep",
})
expect(yield* waitForEvents(events, info.id, 3)).toEqual(["created", "exited", "deleted"])
}),
{ git: true },
)
ptyTest(
"publishes created, exited, deleted in order for /bin/sh + remove",
() =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const events = yield* subscribePtyEvents()
const info = yield* createPty({ command: "/bin/sh", title: "sh" })
expect(yield* waitForEvents(events, info.id, 1)).toEqual(["created"])
yield* pty.write(info.id, "exit\n")
expect(yield* waitForEvents(events, info.id, 2)).toEqual(["exited", "deleted"])
yield* pty.remove(info.id).pipe(Effect.ignore)
}),
{ git: true },
)
})

View File

@@ -1,22 +1,34 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Pty } from "../../src/pty"
import { Effect, Layer } from "effect"
import { Config } from "../../src/config/config"
import { Plugin } from "../../src/plugin"
import { PtyPreparation } from "../../src/pty-preparation"
import { Pty } from "@opencode-ai/core/pty"
import { Shell } from "../../src/shell/shell"
import { testEffect } from "../lib/effect"
Shell.preferred.reset()
const it = testEffect(Pty.defaultLayer)
const createPty = (input: Pty.CreateInput) =>
Effect.acquireRelease(
Effect.gen(function* () {
const pty = yield* Pty.Service
const info = yield* pty.create(input)
return { pty, info }
const it = testEffect(Layer.mergeAll(Config.defaultLayer, Plugin.defaultLayer))
const preparationIt = testEffect(
Layer.mergeAll(
Layer.mock(Config.Service)({ get: () => Effect.succeed({}) }),
Layer.mock(Plugin.Service)({
trigger: <Name extends string, Input, Output>(_name: Name, _input: Input, output: Output) =>
Effect.sync(() => {
const result = output as { env: Record<string, string> }
result.env.INPUT = "plugin"
result.env.FROM_PLUGIN = "plugin"
result.env.TERM = "plugin"
return output
}),
list: () => Effect.succeed([]),
init: () => Effect.void,
}),
({ pty, info }) => pty.remove(info.id).pipe(Effect.ignore),
).pipe(Effect.map(({ info }) => info))
),
)
const preparePty = (input: Pty.CreateInput) => PtyPreparation.prepareCreate(input)
describe("pty shell args", () => {
if (process.platform !== "win32") return
@@ -27,7 +39,7 @@ describe("pty shell args", () => {
"does not add login args to pwsh",
() =>
Effect.gen(function* () {
const info = yield* createPty({ command: ps, title: "pwsh" })
const info = yield* preparePty({ command: ps, title: "pwsh" })
expect(info.args).toEqual([])
}),
{ timeout: 30000 },
@@ -44,7 +56,7 @@ describe("pty shell args", () => {
"adds login args to bash",
() =>
Effect.gen(function* () {
const info = yield* createPty({ command: bash, title: "bash" })
const info = yield* preparePty({ command: bash, title: "bash" })
expect(info.args).toEqual(["-l"])
}),
{ timeout: 30000 },
@@ -61,7 +73,7 @@ describe("pty configured shell", () => {
Effect.gen(function* () {
if (!configured) return
const info = yield* createPty({ title: "configured" })
const info = yield* preparePty({ title: "configured" })
if (process.platform === "win32") {
expect(info.command.toLowerCase()).toBe(configured.toLowerCase())
} else {
@@ -73,3 +85,18 @@ describe("pty configured shell", () => {
{ timeout: 30000 },
)
})
describe("pty environment preparation", () => {
preparationIt.instance("merges plugin environment before forced PTY values", () =>
Effect.gen(function* () {
const input = { command: "/bin/sh", args: [] as string[], env: { INPUT: "caller" } }
const prepared = yield* preparePty(input)
expect(input.args).toEqual([])
expect(prepared.env.INPUT).toBe("plugin")
expect(prepared.env.FROM_PLUGIN).toBe("plugin")
expect(prepared.env.TERM).toBe("xterm-256color")
expect(prepared.env.OPENCODE_TERMINAL).toBe("1")
}),
)
})

View File

@@ -1,59 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { PtyID } from "../../src/pty/schema"
import { PtyTicket } from "../../src/pty/ticket"
import { testEffect } from "../lib/effect"
const it = testEffect(PtyTicket.layer)
const itExpiring = testEffect(Layer.effect(PtyTicket.Service, PtyTicket.make(5)))
describe("PTY websocket tickets", () => {
it.live("consumes tickets once", () =>
Effect.gen(function* () {
const tickets = yield* PtyTicket.Service
const scope = { ptyID: PtyID.ascending(), directory: "/tmp/a" }
const issued = yield* tickets.issue(scope)
expect(yield* tickets.consume({ ...scope, ticket: issued.ticket })).toBe(true)
expect(yield* tickets.consume({ ...scope, ticket: issued.ticket })).toBe(false)
}),
)
it.live("rejects tickets scoped to a different request", () =>
Effect.gen(function* () {
const tickets = yield* PtyTicket.Service
const ptyID = PtyID.ascending()
const issued = yield* tickets.issue({ ptyID, directory: "/tmp/a" })
expect(yield* tickets.consume({ ptyID, directory: "/tmp/b", ticket: issued.ticket })).toBe(false)
expect(yield* tickets.consume({ ptyID, directory: "/tmp/a", ticket: issued.ticket })).toBe(true)
}),
)
itExpiring.live("rejects tickets after the TTL elapses", () =>
Effect.gen(function* () {
const tickets = yield* PtyTicket.Service
const ptyID = PtyID.ascending()
const issued = yield* tickets.issue({ ptyID })
yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 25)))
expect(yield* tickets.consume({ ptyID, ticket: issued.ticket })).toBe(false)
}),
)
it.live("rejects tickets scoped to a different workspace", () =>
Effect.gen(function* () {
const tickets = yield* PtyTicket.Service
const ptyID = PtyID.ascending()
const workspaceID = WorkspaceV2.ID.ascending()
const issued = yield* tickets.issue({ ptyID, workspaceID })
expect(yield* tickets.consume({ ptyID, workspaceID: WorkspaceV2.ID.ascending(), ticket: issued.ticket })).toBe(
false,
)
expect(yield* tickets.consume({ ptyID, workspaceID, ticket: issued.ticket })).toBe(true)
}),
)
})