Skip to content

Commit 332fd28

Browse files
kikuchandemostanis
authored andcommitted
fix(pty): pty session handle leak (anomalyco#15599)
1 parent 291bc94 commit 332fd28

2 files changed

Lines changed: 92 additions & 17 deletions

File tree

packages/opencode/src/pty/index.ts

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@ export namespace Pty {
239239
session.bufferCursor += excess
240240
})
241241
ptyProcess.onExit(({ exitCode }) => {
242+
if (session.info.status === "exited") return
242243
log.info("session exited", { id, exitCode })
243244
const wasKilled = session.info.status === "killed"
244245
if (!wasKilled) {
@@ -271,15 +272,8 @@ export namespace Pty {
271272
Bus.publish(Event.Output, { id, chunk: msg, cursor: session.cursor })
272273
}
273274

274-
for (const [key, ws] of session.subscribers.entries()) {
275-
try {
276-
if (ws.data === key) ws.close()
277-
} catch {
278-
// ignore
279-
}
280-
}
281-
session.subscribers.clear()
282275
Bus.publish(Event.Exited, { id, exitCode })
276+
remove(id)
283277
})
284278
Bus.publish(Event.Created, { info })
285279
return info
@@ -460,6 +454,7 @@ export namespace Pty {
460454
session.bufferCursor += excess
461455
})
462456
ptyProcess.onExit(({ exitCode }) => {
457+
if (session.info.status === "exited") return
463458
log.info("session exited after restart", { id, exitCode })
464459
const wasKilled = session.info.status === "killed"
465460
if (!wasKilled) {
@@ -492,21 +487,15 @@ export namespace Pty {
492487
Bus.publish(Event.Output, { id, chunk: msg, cursor: session.cursor })
493488
}
494489

495-
for (const [key, ws] of session.subscribers.entries()) {
496-
try {
497-
if (ws.data === key) ws.close()
498-
} catch {
499-
// ignore
500-
}
501-
}
502-
session.subscribers.clear()
503490
Bus.publish(Event.Exited, { id, exitCode })
491+
remove(id)
504492
})
505493
}
506494

507495
export async function remove(id: string) {
508496
const session = state().get(id)
509497
if (!session) return
498+
state().delete(id)
510499
log.info("removing session", { id })
511500
try {
512501
session.process.kill()
@@ -519,7 +508,6 @@ export namespace Pty {
519508
}
520509
}
521510
session.subscribers.clear()
522-
state().delete(id)
523511
Bus.publish(Event.Deleted, { id })
524512
}
525513

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { Bus } from "../../src/bus"
3+
import { Instance } from "../../src/project/instance"
4+
import { Pty } from "../../src/pty"
5+
import { tmpdir } from "../fixture/fixture"
6+
import { setTimeout as sleep } from "node:timers/promises"
7+
8+
const wait = async (fn: () => boolean, ms = 2000) => {
9+
const end = Date.now() + ms
10+
while (Date.now() < end) {
11+
if (fn()) return
12+
await sleep(25)
13+
}
14+
throw new Error("timeout waiting for pty events")
15+
}
16+
17+
const pick = (log: Array<{ type: "created" | "exited" | "deleted"; id: string }>, id: string) => {
18+
return log.filter((evt) => evt.id === id).map((evt) => evt.type)
19+
}
20+
21+
describe("pty", () => {
22+
test("publishes created, exited, deleted in order for /bin/ls + remove", async () => {
23+
if (process.platform === "win32") return
24+
25+
await using dir = await tmpdir({ git: true })
26+
27+
await Instance.provide({
28+
directory: dir.path,
29+
fn: async () => {
30+
const log: Array<{ type: "created" | "exited" | "deleted"; id: string }> = []
31+
const off = [
32+
Bus.subscribe(Pty.Event.Created, (evt) => log.push({ type: "created", id: evt.properties.info.id })),
33+
Bus.subscribe(Pty.Event.Exited, (evt) => log.push({ type: "exited", id: evt.properties.id })),
34+
Bus.subscribe(Pty.Event.Deleted, (evt) => log.push({ type: "deleted", id: evt.properties.id })),
35+
]
36+
37+
let id = ""
38+
try {
39+
const info = await Pty.create({ command: "/bin/ls", title: "ls" })
40+
id = info.id
41+
42+
await wait(() => pick(log, id).includes("exited"))
43+
44+
await Pty.remove(id)
45+
await wait(() => pick(log, id).length >= 3)
46+
expect(pick(log, id)).toEqual(["created", "exited", "deleted"])
47+
} finally {
48+
off.forEach((x) => x())
49+
if (id) await Pty.remove(id)
50+
}
51+
},
52+
})
53+
})
54+
55+
test("publishes created, exited, deleted in order for /bin/sh + remove", async () => {
56+
if (process.platform === "win32") return
57+
58+
await using dir = await tmpdir({ git: true })
59+
60+
await Instance.provide({
61+
directory: dir.path,
62+
fn: async () => {
63+
const log: Array<{ type: "created" | "exited" | "deleted"; id: string }> = []
64+
const off = [
65+
Bus.subscribe(Pty.Event.Created, (evt) => log.push({ type: "created", id: evt.properties.info.id })),
66+
Bus.subscribe(Pty.Event.Exited, (evt) => log.push({ type: "exited", id: evt.properties.id })),
67+
Bus.subscribe(Pty.Event.Deleted, (evt) => log.push({ type: "deleted", id: evt.properties.id })),
68+
]
69+
70+
let id = ""
71+
try {
72+
const info = await Pty.create({ command: "/bin/sh", title: "sh" })
73+
id = info.id
74+
75+
await sleep(100)
76+
77+
await Pty.remove(id)
78+
await wait(() => pick(log, id).length >= 3)
79+
expect(pick(log, id)).toEqual(["created", "exited", "deleted"])
80+
} finally {
81+
off.forEach((x) => x())
82+
if (id) await Pty.remove(id)
83+
}
84+
},
85+
})
86+
})
87+
})

0 commit comments

Comments
 (0)