Skip to content

Commit 74aa735

Browse files
authored
fix(tui): guard prompt submit against concurrent invocation (#26972)
1 parent 8030a6c commit 74aa735

2 files changed

Lines changed: 115 additions & 0 deletions

File tree

packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -989,7 +989,24 @@ export function Prompt(props: PromptProps) {
989989
}
990990
})
991991

992+
let submitting = false
992993
async function submit() {
994+
// Prevent overlapping invocations (e.g. a double-pressed Enter, or the
995+
// input's native onSubmit racing another dispatch). Without this guard,
996+
// a second call slips past the empty-input check before the first call
997+
// clears `store.prompt.input`, then awaits its own `session.create` and
998+
// ultimately reads the now-empty store — sending a phantom empty prompt
999+
// to a freshly created session.
1000+
if (submitting) return false
1001+
submitting = true
1002+
try {
1003+
return await submitInner()
1004+
} finally {
1005+
submitting = false
1006+
}
1007+
}
1008+
1009+
async function submitInner() {
9931010
setWarpNotice(undefined)
9941011

9951012
// IME: double-defer may fire before onContentChange flushes the last
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { describe, expect, test } from "bun:test"
2+
3+
// Regression test for the prompt submit race in
4+
// packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx (`submit`).
5+
//
6+
// Before the fix, two concurrent `submit()` calls (e.g. a double-pressed
7+
// Enter, or the input's native onSubmit racing another dispatch) each
8+
// passed the `if (!store.prompt.input) return false` guard, each
9+
// `await sdk.client.session.create(...)`, and each only captured
10+
// `inputText = store.prompt.input` AFTER that await. The first invocation
11+
// finished, sent the prompt, and cleared the store; the second invocation,
12+
// now past its await, read the cleared store and sent an empty prompt to a
13+
// second freshly-created session - leaving an orphaned session with the
14+
// user's actual text and a phantom session visible to the user containing
15+
// only an assistant reply.
16+
//
17+
// `submitMirror` below has the exact shape of the production `submit()`
18+
// after the fix: an in-flight `submitting` guard wraps the original body.
19+
// Two concurrent invocations must result in exactly one submission carrying
20+
// the user's text, with no empty-text submission.
21+
22+
type Store = { input: string }
23+
24+
type SubmitResult = { sessionID: string; text: string }
25+
26+
type Harness = {
27+
store: Store
28+
submissions: SubmitResult[]
29+
createSession(): Promise<string>
30+
sendPrompt(sessionID: string, text: string): Promise<void>
31+
}
32+
33+
function createHarness(opts: { sessionCreateDelayMs: number }): Harness {
34+
let sessionCounter = 0
35+
const submissions: SubmitResult[] = []
36+
37+
return {
38+
store: { input: "" },
39+
submissions,
40+
async createSession() {
41+
sessionCounter += 1
42+
const id = `ses_${sessionCounter}`
43+
await Bun.sleep(opts.sessionCreateDelayMs)
44+
return id
45+
},
46+
async sendPrompt(sessionID, text) {
47+
submissions.push({ sessionID, text })
48+
},
49+
}
50+
}
51+
52+
function createSubmit() {
53+
let submitting = false
54+
return async function submit(h: Harness) {
55+
if (submitting) return false
56+
submitting = true
57+
try {
58+
if (!h.store.input) return false
59+
const sessionID = await h.createSession()
60+
const inputText = h.store.input
61+
await h.sendPrompt(sessionID, inputText)
62+
h.store.input = ""
63+
return true
64+
} finally {
65+
submitting = false
66+
}
67+
}
68+
}
69+
70+
describe("Prompt.submit race", () => {
71+
test("concurrent submits must not lose the user's text", async () => {
72+
const submit = createSubmit()
73+
const h = createHarness({ sessionCreateDelayMs: 5 })
74+
h.store.input = "Hello there."
75+
76+
// Two invocations back-to-back, mimicking a double-Enter.
77+
await Promise.all([submit(h), submit(h)])
78+
79+
// Every submission that did make it through must carry the actual user
80+
// text, and no submission may have an empty text payload.
81+
expect(h.submissions.every((s) => s.text === "Hello there.")).toBe(true)
82+
expect(h.submissions.some((s) => s.text === "")).toBe(false)
83+
})
84+
85+
test("a sequential second submit after clear is a no-op, not a phantom session", async () => {
86+
const submit = createSubmit()
87+
const h = createHarness({ sessionCreateDelayMs: 1 })
88+
h.store.input = "Hello there."
89+
90+
await submit(h)
91+
// After the first submission completes, the store is cleared; a second
92+
// Enter on an empty input must not create a phantom session.
93+
await submit(h)
94+
95+
expect(h.submissions).toHaveLength(1)
96+
expect(h.submissions[0].text).toBe("Hello there.")
97+
})
98+
})

0 commit comments

Comments
 (0)