Skip to content

Commit 9d7a8fe

Browse files
authored
fix: stdin handling for empty submissions (#9556)
## 📝 Summary <!-- If this PR closes any issues, list them here by number (e.g., Closes #123). Detail the specific changes made in this pull request. Explain the problem addressed and how it was resolved. If applicable, provide before and after comparisons, screenshots, or any relevant details to help reviewers understand the changes easily. --> - Pressing Enter with no input in marimo's stdin box was silently swallowed by the frontend, so any CLI calling `input("Press Enter to continue:")` (e.g. `logfire`, `click.prompt`, `rich.prompt`, `pdb`) would hang forever. - Once unblocked, a second bug surfaced: `builtins.input()` raised `EOFError` on blank submissions because `ThreadSafeStdin.readline()` returned `""` — which CPython's `input()` interprets as EOF rather than a blank line. ### Fix **Frontend** (`ConsoleOutput.tsx`) - `StdInput` now submits on Enter even when empty (history skip preserved). - `StdInputWithResponse` renders past responses with a `>` chevron, sky-11 color, and an `(empty)` placeholder so user-typed responses are visually distinct from stdout — even when the prompt half is empty (which happens when CLIs `print()` the prompt themselves before calling `input()`). <img width="501" height="191" alt="image" src="https://github.com/user-attachments/assets/59999aad-09fe-4174-9408-b009981130be" /> **Backend** (`marimo/_messaging/types.py`, `streams.py`, `_pyodide/streams.py`) - `Stdin.readline()` now appends `"\n"` so blank submissions surface as `"\n"` rather than `""`. `readlines()` simplified to `[readline()]`. - Lifted `readline`/`readlines` to the `Stdin` base class — both subclasses had identical implementations. - `_readline_with_prompt` unchanged: still returns the bare response, so `input_override` continues to satisfy Python's `input()` contract. ## 📋 Pre-Review Checklist <!-- These checks need to be completed before a PR is reviewed --> - [x] For large changes, or changes that affect the public API: this change was discussed or approved through an issue, on [Discord](https://marimo.io/discord?ref=pr), or the community [discussions](https://github.com/marimo-team/marimo/discussions) (Please provide a link if applicable). - [x] Any AI generated code has been reviewed line-by-line by the human PR author, who stands by it. - [x] Video or media evidence is provided for any visual changes (optional). <!-- PR is more likely to be merged if evidence is provided for changes made --> ## ✅ Merge Checklist - [x] I have read the [contributor guidelines](https://github.com/marimo-team/marimo/blob/main/CONTRIBUTING.md). - [ ] Documentation has been updated where applicable, including docstrings for API changes. - [x] Tests have been added for the changes made.
1 parent 917b863 commit 9d7a8fe

11 files changed

Lines changed: 210 additions & 58 deletions

File tree

frontend/src/components/editor/notebook-cell.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -740,6 +740,7 @@ const EditableCellComponent = ({
740740
<ConsoleOutput
741741
consoleOutputs={cellRuntime.consoleOutputs}
742742
stale={consoleOutputStale}
743+
interrupted={cellRuntime.interrupted}
743744
// Empty name if serialization triggered
744745
cellName={cellRuntime.serialization ? "_" : cellData.name}
745746
onRefactorWithAI={handleRefactorWithAI}
@@ -1204,6 +1205,7 @@ const SetupCellComponent = ({
12041205
<ConsoleOutput
12051206
consoleOutputs={cellRuntime.consoleOutputs}
12061207
stale={consoleOutputStale}
1208+
interrupted={cellRuntime.interrupted}
12071209
// Don't show name
12081210
cellName={"_"}
12091211
onRefactorWithAI={handleRefactorWithAI}

frontend/src/components/editor/output/console/ConsoleOutput.tsx

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ interface Props {
8585
className?: string;
8686
consoleOutputs: WithResponse<OutputMessage>[];
8787
stale: boolean;
88+
interrupted: boolean;
8889
debuggerActive: boolean;
8990
onRefactorWithAI?: OnRefactorWithAI;
9091
onClear?: () => void;
@@ -111,6 +112,7 @@ const ConsoleOutputInternal = (props: Props): React.ReactNode => {
111112
const {
112113
consoleOutputs: rawConsoleOutputs,
113114
stale,
115+
interrupted,
114116
cellName,
115117
cellId,
116118
onSubmitDebugger,
@@ -280,6 +282,7 @@ const ConsoleOutputInternal = (props: Props): React.ReactNode => {
280282
output={output.data}
281283
response={output.response}
282284
isPassword={isPassword}
285+
interrupted={interrupted}
283286
/>
284287
);
285288
}
@@ -359,9 +362,9 @@ const StdInput = (props: {
359362
if (e.key === "Enter" && !e.shiftKey) {
360363
if (value) {
361364
addToHistory(value);
362-
onSubmit(value);
363-
setValue("");
364365
}
366+
onSubmit(value);
367+
setValue("");
365368
e.preventDefault();
366369
e.stopPropagation();
367370
}
@@ -382,12 +385,27 @@ const StdInputWithResponse = (props: {
382385
output: string;
383386
response?: string;
384387
isPassword?: boolean;
388+
interrupted?: boolean;
385389
}) => {
390+
const { output, response, isPassword, interrupted } = props;
391+
const hasResponse = response != null && response !== "";
392+
const wasInterruptedWithoutResponse = interrupted && !hasResponse;
393+
386394
return (
387395
<div className="flex gap-2 items-center">
388-
{renderText(props.output)}
389-
{!props.isPassword && (
390-
<span className="text-(--sky-11)">{props.response}</span>
396+
{renderText(output)}
397+
{!isPassword && !wasInterruptedWithoutResponse && (
398+
<span
399+
className="inline-flex items-center gap-1 text-(--sky-11)"
400+
aria-label="stdin response"
401+
>
402+
<ChevronRightIcon className="w-4 h-4 shrink-0 opacity-70" />
403+
{hasResponse ? (
404+
response
405+
) : (
406+
<span className="italic opacity-70">(empty)</span>
407+
)}
408+
</span>
391409
)}
392410
</div>
393411
);

frontend/src/components/editor/output/console/__tests__/ConsoleOutput.test.tsx

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ describe("ConsoleOutput integration", () => {
2828
cellName: "test_cell",
2929
consoleOutputs: [] as WithResponse<OutputMessage>[],
3030
stale: false,
31+
interrupted: false,
3132
debuggerActive: false,
3233
onSubmitDebugger: () => {
3334
// noop
@@ -59,6 +60,7 @@ describe("ConsoleOutput pdb history", () => {
5960
cellName: "test_cell",
6061
consoleOutputs: [] as WithResponse<OutputMessage>[],
6162
stale: false,
63+
interrupted: false,
6264
debuggerActive: false,
6365
onSubmitDebugger: vi.fn(),
6466
};
@@ -118,6 +120,82 @@ describe("ConsoleOutput pdb history", () => {
118120
expect(newInput).toHaveValue("next");
119121
});
120122

123+
it("should submit an empty string when Enter is pressed with no input", () => {
124+
// Many CLIs prompt "Press Enter to continue" and expect "" back.
125+
const onSubmitDebugger = vi.fn();
126+
const outputs: WithResponse<OutputMessage>[] = [
127+
stdinPrompt("Press Enter to continue: "),
128+
];
129+
130+
renderWithProvider(
131+
<ConsoleOutput
132+
{...defaultProps}
133+
consoleOutputs={outputs}
134+
onSubmitDebugger={onSubmitDebugger}
135+
/>,
136+
);
137+
138+
const input = screen.getByTestId("console-input");
139+
fireEvent.keyDown(input, { key: "Enter" });
140+
141+
expect(onSubmitDebugger).toHaveBeenCalledWith("", 0);
142+
});
143+
144+
it("should not record empty submissions in input history", () => {
145+
const onSubmitDebugger = vi.fn();
146+
const outputs1: WithResponse<OutputMessage>[] = [stdinPrompt("(Pdb) ")];
147+
148+
const { rerender } = renderWithProvider(
149+
<ConsoleOutput
150+
{...defaultProps}
151+
consoleOutputs={outputs1}
152+
onSubmitDebugger={onSubmitDebugger}
153+
/>,
154+
);
155+
156+
let input = screen.getByTestId("console-input");
157+
fireEvent.change(input, { target: { value: "step" } });
158+
fireEvent.keyDown(input, { key: "Enter" });
159+
160+
const outputs2: WithResponse<OutputMessage>[] = [
161+
stdinPrompt("(Pdb) ", "step"),
162+
stdinPrompt("(Pdb) "),
163+
];
164+
rerender(
165+
<TooltipProvider>
166+
<ConsoleOutput
167+
{...defaultProps}
168+
consoleOutputs={outputs2}
169+
onSubmitDebugger={onSubmitDebugger}
170+
/>
171+
</TooltipProvider>,
172+
);
173+
174+
// Submit an empty value; this should NOT enter the history stack.
175+
input = screen.getByTestId("console-input");
176+
fireEvent.keyDown(input, { key: "Enter" });
177+
178+
const outputs3: WithResponse<OutputMessage>[] = [
179+
stdinPrompt("(Pdb) ", "step"),
180+
stdinPrompt("(Pdb) ", ""),
181+
stdinPrompt("(Pdb) "),
182+
];
183+
rerender(
184+
<TooltipProvider>
185+
<ConsoleOutput
186+
{...defaultProps}
187+
consoleOutputs={outputs3}
188+
onSubmitDebugger={onSubmitDebugger}
189+
/>
190+
</TooltipProvider>,
191+
);
192+
193+
// ArrowUp should jump back to "step", skipping the empty submission.
194+
input = screen.getByTestId("console-input");
195+
fireEvent.keyDown(input, { key: "ArrowUp" });
196+
expect(input).toHaveValue("step");
197+
});
198+
121199
it("should navigate through multiple history entries across remounts", () => {
122200
const onSubmitDebugger = vi.fn();
123201

@@ -192,6 +270,41 @@ describe("ConsoleOutput pdb history", () => {
192270
fireEvent.keyDown(input, { key: "ArrowDown" });
193271
expect(input).toHaveValue("");
194272
});
273+
274+
it("should distinguish an interrupted prompt from a bare-Enter submission", () => {
275+
// After interrupt, cell.ts coerces pending stdin prompts to response: "".
276+
// We must render that case differently from a real bare-Enter response,
277+
// so the user isn't told they "submitted" a blank value.
278+
const interruptedOutputs: WithResponse<OutputMessage>[] = [
279+
stdinPrompt("Press Enter to continue: ", ""),
280+
];
281+
282+
const { rerender } = renderWithProvider(
283+
<ConsoleOutput
284+
{...defaultProps}
285+
consoleOutputs={interruptedOutputs}
286+
interrupted={true}
287+
/>,
288+
);
289+
290+
// No response chunk should be rendered for an interrupted pending prompt.
291+
expect(screen.queryByLabelText("stdin response")).not.toBeInTheDocument();
292+
293+
// Same outputs, but the cell isn't interrupted -- this is a real
294+
// bare-Enter submission, so we should render the (empty) placeholder.
295+
rerender(
296+
<TooltipProvider>
297+
<ConsoleOutput
298+
{...defaultProps}
299+
consoleOutputs={interruptedOutputs}
300+
interrupted={false}
301+
/>
302+
</TooltipProvider>,
303+
);
304+
305+
expect(screen.getByLabelText("stdin response")).toBeInTheDocument();
306+
expect(screen.getByText("(empty)")).toBeInTheDocument();
307+
});
195308
});
196309

197310
describe("ConsoleOutput debounced clearing", () => {
@@ -219,6 +332,7 @@ describe("ConsoleOutput debounced clearing", () => {
219332
cellName: "test_cell",
220333
consoleOutputs: [] as WithResponse<OutputMessage>[],
221334
stale: false,
335+
interrupted: false,
222336
debuggerActive: false,
223337
onSubmitDebugger: vi.fn(),
224338
};

frontend/src/components/editor/renderers/vertical-layout/vertical-layout.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,7 @@ const VerticalCell = memo(
405405
<ConsoleOutput
406406
consoleOutputs={consoleOutputs}
407407
stale={outputStale}
408+
interrupted={interrupted}
408409
cellName={name}
409410
onSubmitDebugger={() => null}
410411
cellId={cellId}

frontend/src/components/scratchpad/scratchpad.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,7 @@ export const ScratchPad: React.FC = () => {
289289
consoleOutputs={consoleOutputs}
290290
className="overflow-auto"
291291
stale={false}
292+
interrupted={false}
292293
cellName={DEFAULT_CELL_NAME}
293294
onSubmitDebugger={Functions.NOOP}
294295
cellId={cellId}

marimo/_messaging/streams.py

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -466,20 +466,6 @@ def _readline_with_prompt(
466466

467467
return self._stream.input_queue.get()
468468

469-
def readline(self, size: int | None = -1) -> str: # type: ignore[override]
470-
# size only included for compatibility with sys.stdin.readline API;
471-
# we don't support it.
472-
del size
473-
return self._readline_with_prompt(prompt="")
474-
475-
def readlines(self, hint: int | None = -1) -> list[str]: # type: ignore[override]
476-
# Just an alias for readline.
477-
#
478-
# hint only included for compatibility with sys.stdin.readlines API;
479-
# we don't support it.
480-
del hint
481-
return self._readline_with_prompt(prompt="").split("\n")
482-
483469

484470
@contextlib.contextmanager
485471
def redirect(standard_stream: Stdout | Stderr) -> Iterator[None]:

marimo/_messaging/types.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ def _ensure_plain_str(s: str) -> str:
6363

6464

6565
# These streams are not stoppable by users (we don't implement stop).
66-
class Stdout(io.TextIOBase):
66+
class Stdout(io.TextIOBase, abc.ABC):
6767
name = "stdout"
6868

6969
@abc.abstractmethod
@@ -81,7 +81,7 @@ def _stop(self) -> None:
8181
"""Tear down resources, if any."""
8282

8383

84-
class Stderr(io.TextIOBase):
84+
class Stderr(io.TextIOBase, abc.ABC):
8585
name = "stderr"
8686

8787
@abc.abstractmethod
@@ -99,9 +99,32 @@ def _stop(self) -> None:
9999
"""Tear down resources, if any."""
100100

101101

102-
class Stdin(io.TextIOBase):
102+
class Stdin(io.TextIOBase, abc.ABC):
103103
name = "stdin"
104104

105+
@abc.abstractmethod
106+
def _readline_with_prompt(
107+
self, prompt: str = "", password: bool = False
108+
) -> str:
109+
"""Send a prompt to the frontend and return the user's bare response.
110+
111+
Subclasses implement the transport. Returns the user-typed text
112+
without a trailing newline (matches Python's input() contract).
113+
"""
114+
115+
# `size` and `hint` are accepted for sys.stdin API compatibility but
116+
# ignored: marimo's stdin is a single-prompt pseudofile.
117+
118+
def readline(self, size: int | None = -1) -> str: # type: ignore[override]
119+
del size
120+
# Trailing "\n" is required so a blank submission doesn't look like
121+
# EOF to builtin input() (used by rich, click, getpass, pdb, etc.).
122+
return self._readline_with_prompt(prompt="") + "\n"
123+
124+
def readlines(self, hint: int | None = -1) -> list[str]: # type: ignore[override]
125+
del hint
126+
return [self.readline()]
127+
105128
def _stop(self) -> None:
106129
"""Tear down resources, if any."""
107130

marimo/_pyodide/streams.py

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -179,17 +179,3 @@ def _readline_with_prompt(
179179
def _get_response(self) -> str:
180180
loop = asyncio.get_event_loop()
181181
return loop.run_until_complete(self.stream.input_queue.get())
182-
183-
def readline(self, size: int | None = -1) -> str: # type: ignore[override]
184-
# size only included for compatibility with sys.stdin.readline API;
185-
# we don't support it.
186-
del size
187-
return self._readline_with_prompt(prompt="")
188-
189-
def readlines(self, hint: int | None = -1) -> list[str]: # type: ignore[override]
190-
# Just an alias for readline.
191-
#
192-
# hint only included for compatibility with sys.stdin.readlines API;
193-
# we don't support it.
194-
del hint
195-
return self._readline_with_prompt(prompt="").split("\n")

tests/_messaging/test_streams.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ async def test_readline_installed(
2525
await k.run(
2626
[exec_req.get("import sys; output = sys.stdin.readline()")]
2727
)
28-
assert k.globals["output"] == ""
28+
assert k.globals["output"] == "\n"
2929

3030
@staticmethod
3131
async def test_readlines_installed(
@@ -34,7 +34,19 @@ async def test_readlines_installed(
3434
await k.run(
3535
[exec_req.get("import sys; output = sys.stdin.readlines()")]
3636
)
37-
assert k.globals["output"] == [""]
37+
assert k.globals["output"] == ["\n"]
38+
39+
@staticmethod
40+
async def test_builtin_input_with_empty_response(
41+
k: Kernel, exec_req: ExecReqProvider
42+
) -> None:
43+
# Bypasses the cell-scoped input override, exercising the same
44+
# readline()-based path that rich/click/getpass/pdb hit. Must not
45+
# raise EOFError on an empty submission.
46+
await k.run(
47+
[exec_req.get("import builtins; output = builtins.input()")]
48+
)
49+
assert k.globals["output"] == ""
3850

3951
@staticmethod
4052
async def test_getpass_installed(

tests/_messaging/test_types.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,12 +146,19 @@ def test_not_stoppable(self) -> None:
146146

147147

148148
class TestStdin:
149+
class MockStdin(Stdin):
150+
def _readline_with_prompt(
151+
self, prompt: str = "", password: bool = False
152+
) -> str:
153+
del prompt, password
154+
return ""
155+
149156
def test_stdin_name(self) -> None:
150-
stdin = Stdin()
157+
stdin = self.MockStdin()
151158
assert stdin.name == "stdin"
152159

153160
def test_not_stoppable(self) -> None:
154-
stdin = Stdin()
161+
stdin = self.MockStdin()
155162
assert not hasattr(stdin, "stop")
156163

157164

0 commit comments

Comments
 (0)