pyshell.py, run.py, rpc.py, CPython main 993a0c65a7b.
Tracked in the umbrella issue python/cpython#154760 — one of 69 idlelib findings from a two-pass review with code-review-toolkit.
Each numbered heading below is one finding and has its own anchor, so the umbrella table links straight to it. The numbers are row numbers in that table, not identifiers — when referring to one of these, cite #154760 and the row, not the bare number.
Do not start work on a finding until a maintainer has cleared it in #154760. Every row there starts at Under analysis, which means not yet cleared.
AI disclaimer: these reports were drafted with Claude Code. Every cited line was re-read in the tree, every "Verified" block was produced by running the code, and the prior-art search was run against the full python/cpython tracker before publishing.
How to read a finding. Where is the line as of the commit above and will drift. Guarded twin is a place in idlelib that already handles the same situation correctly — it is both the evidence that this is a defect by the project's own standard and the shape of the fix. Prior art records what the tracker already says.
FIX · confirmed by reading · shape flag-not-reset-on-early-exit
Where: pyshell.py:499-540
What happens. self.restarting is set at 488 and reset only at 526. A TimeoutError from rpcclt.accept() returns at 508, skipping the reset. Afterwards Run Module, Restart Shell, the debugger restart and poll_subprocess's auto-restart all hit the guard at 486 and return instantly, doing nothing and showing no error. IDLE looks alive but can never run code again; only quitting fixes it.
Guarded twin. debugger.py:153-156 — try: self.interacting = True ... finally: self.interacting = False. Four such twins exist in idlelib.
Suggested fix. Wrap the body from 489 onward in try/finally: self.restarting = False.
Prior art — no matching report found. Searched the cached python/cpython tracker corpus (1626 topic-IDLE issues + 281 unlabelled idlelib issues + 573 PRs + 86 idle.rst items; 2503 unique) for this defect's terms and for its file/function names. No matching report.
FIX · reproduced · shape attribute-created-outside-init
Where: run.py:635-639,695
What happens. Executive.init never sets user_exc_info; it is assigned as the first line of runcode's try. Start IDLE, run nothing, choose Debug -> Stack Viewer (always enabled per mainmenu.py:96) and the Shell prints '** IDLE Internal Exception:' with AttributeError instead of a friendly dialog.
Guarded twin. pyshell.py:1370-1376 handles 'no exception yet' for the -n path with a 'There is no stack trace yet' box; the RPC path has no equivalent.
Suggested fix. Initialise self.user_exc_info = None in init.
Prior art — no matching report found. Searched the cached python/cpython tracker corpus (1626 topic-IDLE issues + 281 unlabelled idlelib issues + 573 PRs + 86 idle.rst items; 2503 unique) for this defect's terms and for its file/function names. No matching report.
CONSIDER · confirmed by reading · shape handler-reads-a-name-the-try-may-not-have-bound
Where: run.py:190-197
What happens. seq is bound only inside if request:, but the bare except at 183 puts (seq, None) on the response queue. On the first iteration seq has never been bound -> NameError -> the inner bare except sends the original traceback to sys.stderr (which the GUI does not display) and exit()s the subprocess: the user sees 'connection lost' instead of their actual error. On later iterations seq is STALE, so a response is emitted under a completed request's sequence number and delivered to the wrong waiter.
Guarded twin. None in idlelib — no sibling does this correctly.
Suggested fix. Initialise seq = None before the loop; guard line 187 with if seq is not None:.
Prior art — no matching report found. Searched the cached python/cpython tracker corpus (1626 topic-IDLE issues + 281 unlabelled idlelib issues + 573 PRs + 86 idle.rst items; 2503 unique) for this defect's terms and for its file/function names. No matching report.
CONSIDER · confirmed by reading · shape raise-without-from-in-except
Where: rpc.py:358-361
What happens. except OSError: raise EOFError with no errno check and no from. ECONNRESET -> EOF is right, but ENOMEM/ENOBUFS under memory pressure and EAGAIN from the select-says-readable race are all conflated into the same signal, and handle_EOF then os._exit(0)s the subprocess or triggers a full GUI restart. The missing from e discards the errno, so there is no record of whether IDLE restarted because the peer exited or because the kernel ran out of buffers.
Guarded twin. None in idlelib — no sibling does this correctly.
Suggested fix. except OSError as e: raise EOFError from e, and let unexpected errnos propagate.
Prior art — no matching report found. Searched the cached python/cpython tracker corpus (1626 topic-IDLE issues + 281 unlabelled idlelib issues + 573 PRs + 86 idle.rst items; 2503 unique) for this defect's terms and for its file/function names. No matching report.
Note. All six raise-inside-except sites in idlelib lack from: config.py:211, pyshell.py:12, rpc.py:345, rpc.py:361, run.py:360, zoomheight.py:74.
CONSIDER · confirmed by reading · shape cleanup-only-on-success-path
Where: pyshell.py:804-821
What happens. The finally at 802 explicitly excludes the use_subprocess path — the default configuration. If showtraceback() raises (it writes to a Tk-backed stream that raises TclError once the shell widget is being torn down), endexecuting() never runs, self.executing stays True, the prompt never returns, and cancel_callback takes the 'still executing' branch forever. The shell is frozen with no error shown.
Guarded twin. The non-subprocess branch of the same finally.
Suggested fix. Hoist endexecuting() into the unconditional finally, guarded by the same except AttributeError already used at 806.
Prior art — no matching report found. Searched the cached python/cpython tracker corpus (1626 topic-IDLE issues + 281 unlabelled idlelib issues + 573 PRs + 86 idle.rst items; 2503 unique) for this defect's terms and for its file/function names. No matching report.
CONSIDER · confirmed by reading · shape error-reported-below-warning
Where: rpc.py:258-260
What happens. SocketIO.debug returns immediately unless self.debugging, and that flag is False at both class definitions with no way to enable it short of editing source. An EXCEPTION response is therefore reported nowhere and remotecall returns None — indistinguishable from a successful call returning None. autocomplete.fetch_completions and calltip.fetch_tip both unpack that result, so the real cause surfaces later as an unrelated TypeError in the GUI thread.
Guarded twin. The sibling ERROR branch at rpc.py:265 raises RuntimeError.
Suggested fix. Report through sys.stderr rather than the disabled-by-default debug().
Prior art — no matching report found. Searched the cached python/cpython tracker corpus (1626 topic-IDLE issues + 281 unlabelled idlelib issues + 573 PRs + 86 idle.rst items; 2503 unique) for this defect's terms and for its file/function names. No matching report.
CONSIDER · candidate (not reproduced) · shape reinitializer-resets-a-subset-of-its-state
Where: rpc.py:130-141
What happens. init resets self.responses and self.cvars — the author's clear intent that per-connection state resets there — but buff/bufneed/bufstate are class-level, mutated per instance, and reset by neither init nor close(). RPCClient.accept re-runs SocketIO.init on the same object. Kill the subprocess mid-packet (easy for output over 8 KB) and the reconnect starts with a stale buffer tail, so the new subprocess's first message concatenates onto it -> UnpicklingError, uncaught by pollresponse and by poll_subprocess; since the reschedule is after the try, the Shell permanently stops polling.
Guarded twin. The responses/cvars reset in the same init.
Suggested fix. Reset buff/bufneed/bufstate in init alongside responses and cvars.
Prior art — no matching report found. Searched the cached python/cpython tracker corpus (1626 topic-IDLE issues + 281 unlabelled idlelib issues + 573 PRs + 86 idle.rst items; 2503 unique) for this defect's terms and for its file/function names. No matching report.
Note. Not reproduced — requires precise timing. test_rpc.py is 29 lines covering only CodePickler.
CONSIDER · confirmed by reading · shape except-exception-too-broad
Where: pyshell.py:564-574
What happens. The comment says 'process already terminated', which matches ProcessLookupError. PermissionError (a hardened container, or a subprocess that changed credentials) does not: the child is still running, wait() is skipped, and restart_subprocess immediately spawns a second one. subprocess's _active reaping limits the zombie damage.
Guarded twin. None in idlelib — no sibling does this correctly.
Suggested fix. except ProcessLookupError:
Prior art — no matching report found. Searched the cached python/cpython tracker corpus (1626 topic-IDLE issues + 281 unlabelled idlelib issues + 573 PRs + 86 idle.rst items; 2503 unique) for this defect's terms and for its file/function names. No matching report.
Note. One of two sites where a comment narrows what the code does not — the mechanism by which a broad catch survives 25 years of review.
FIX · reproduced · shape structured-index-tested-by-string-suffix
Where: pyshell.py:1034
What happens. sellast[-1] != '0' is a string-suffix test standing in for column == 0, and it is equivalent only for single-digit columns. At column 10, 20, 30 or 100 the last character IS '0', so the index is left unextended, get_prompt_text builds one fewer prompt than there are lines, and the final line is dropped from the clipboard. Verified against real Tk on this tree: sel.last=4.5 -> 4 prompts / 4 lines ok; sel.last=4.10 and 4.20 -> 3 prompts / 4 lines, last line dropped. The docstring at pyshell.py:1012-1013 promises the opposite: 'This always copies entire lines'.
Verified:
# Real Tk, this tree's own build. 4-line Text, selection ending at various columns.
sel.last=4.5 guard->5.0 prompts=4 lines=4 ok
sel.last=4.10 guard->4.10 prompts=3 lines=4 DROPS LAST LINE
sel.last=4.20 guard->4.20 prompts=3 lines=4 DROPS LAST LINE
Guarded twin. idle_test/test_sidebar.py:764 pairs prompts with lines using zip(..., strict=True). The test demands strict pairing that the production code does not provide — and never catches it, because its fixture selects to 'end-1c', which lands on column 0.
Suggested fix. if not sellast.endswith('.0'):
Prior art — novel; the change that introduced it is identified. #95511 [CLOSED] 'IDLE: fix Shell context menu copy-with-prompts bug' is where this guard came from. Terry Reedy's own statement of the fix — 'test sel.last and only move it to the beginning of the next line when it is not already at the beginning of a line' — is correct; the implementation of that test is not.
Note. Fix residue: News3.txt:109 records gh-95511, 'Fix the Shell context menu copy-with-prompts bug of copying an extra line'. That fix introduced this guard. Reachable from the Edit menu, the shell context menu (sidebar.py:450) and the <> binding.
FIX · reproduced · shape zip-truncates-on-length-mismatch
Where: pyshell.py:1016, iomenu.py:288
What happens. zip(prompts, text.splitlines()) pairs a list derived from arithmetic over Tk indices (range(int(float(first)), int(float(last)))) against a list derived from the text itself, so any disagreement silently truncates to the shorter with no error and no log line. This is the mechanism by which 0027 loses its line, and it has a second, independent caller: iomenu.py:261 save-shell-contents passes index('end-1c'), so saving the shell drops the unsubmitted prompt line — which is the shell's normal resting state.
Guarded twin. idle_test/test_sidebar.py:764 — zip(..., strict=True).
Suggested fix. zip(..., strict=True), which converts both truncations into a loud failure.
Prior art — novel; the change that introduced it is identified. #95191 [CLOSED] 'IDLE: usually include prompts when save or copy shell' added the second caller (iomenu save-shell), which is why one zip now serves two features. No report of either truncating.
FIX · confirmed by reading · shape one-concern-implemented-per-backend
Where: pyshell.py:792-803, pyshell.py:779, pyshell.py:925, pyshell.py:1226, run.py:611, run.py:653-658
What happens. use_subprocess (pyshell.py:48) selects between two interchangeable execution backends, and every protection lives inside the RPC backend rather than in a shared driver. SystemExit is the sharpest cell and the two sides are opposites: run.py:593-598 CONTAINS it and returns to the prompt, while pyshell.py:778-789 asks 'Do you want to exit altogether?' with default='yes' and, on Enter, raises out of root.mainloop() (pyshell.py:1691) — no controlled shutdown, so unsaved editor buffers get NO save prompt. Other empty cells: Ctrl-C has no interrupt path at all under -n (the guard at pyshell.py:1212 requires self.interp.rpcclt); Restart is never bound (pyshell.py:911 is inside if use_subprocess); recursion headroom (run.py:551) is never installed. Plus a hard crash: runcode calls restart_subprocess() unconditionally at pyshell.py:765, which dereferences self.rpcclt.close() — None under -n.
Guarded twin. run.py:593-598 is the correct half of the SystemExit cell, and gh-112936 already fixed ONE cell of this matrix. The rest were never swept.
Suggested fix. Hoist the cross-cutting concerns into the shared driver so a backend cannot silently omit one, or make -n a thin wrapper over the same code path.
Prior art — no matching report found. Searched the cached python/cpython tracker corpus (1626 topic-IDLE issues + 281 unlabelled idlelib issues + 573 PRs + 86 idle.rst items; 2503 unique) for this defect's terms and for its file/function names. No matching report.
FIX · reproduced · shape one-concern-implemented-per-backend
Where: pyshell.py:680
What happens. execfile prepends the __file__ = ... preamble only under if use_subprocess, so with -n the name is not absent — it is left over from IDLE's own startup. Measured: idle -n -r probe.py gives file == '.../idlelib/main.py'. A script computing paths relative to file therefore reads and writes in the wrong directory with nothing raised.
Verified:
$ cat demo_pkg/__main__.py
import sys
locals_ = sys.modules['__main__'].__dict__ # exactly pyshell.py:412
src = "print('user code sees __file__ =', repr(globals().get('__file__', '<ABSENT>')))"
exec(compile(src, '<pyshell#0>', 'exec'), locals_) # exactly pyshell.py:777 under -n
$ python -m demo_pkg
user code sees __file__ = '.../demo_pkg/__main__.py'
# and the RPC backend's namespace, run.py:578, for contrast:
run.py locals __file__ = '<ABSENT>'
Guarded twin. runscript.py:149 sets file in both modes.
Suggested fix. Move the preamble outside the use_subprocess guard.
Prior art — novel; the change that introduced it is identified. IMPORTANT — the exclusion is DELIBERATE. Commit 22c82be5df70 (bpo-32984, #77165, Terry Reedy 2018-03-05) says in its own message: 'IDLE now does the same when run normally, WITHOUT THE -N OPTION.' So report only what the commit did not consider: under -n the name is not absent, it is IDLE's own entry point, because ModifiedInterpreter executes user code in sys.modules['main'].dict (pyshell.py:412) and IDLE is started as python -m idlelib. Absence would be loud (NameError); a stale value is silent.
Note. One cell of the 0032 matrix, recorded separately because it has its own measured evidence.
51 — restore_file_breaks appends without clearing, so Save As carries the old file's breakpoints over
FIX · confirmed by reading · shape reinitializer-resets-a-subset-of-its-state
Where: pyshell.py:268
What happens. restore_file_breaks() calls set_breakpoint() for each stored line without first removing the BREAK tags already on the widget, so after Save As the new file shows the union of its own breakpoints and the previous file's — and update_breakpoints() then persists that union to breakpoints.lst, making it permanent.
Guarded twin. clear_file_breaks() exists at pyshell.py:209 and is called from _close (pyshell.py:309); the Save As path never calls it, and pyshell.py:304 is a commented-out call to exactly that.
Suggested fix. Call clear_file_breaks() at the top of restore_file_breaks().
Prior art — no matching report found. Searched the cached python/cpython tracker corpus (1626 topic-IDLE issues + 281 unlabelled idlelib issues + 573 PRs + 86 idle.rst items; 2503 unique) for this defect's terms and for its file/function names. No matching report.
FIX · confirmed by reading · shape two-sides-of-a-comparison-normalized-differently
Where: pyshell.py:244, pyshell.py:273, filelist.py:85
What happens. PyShellEditorWindow uses self.io.filename verbatim as the breakpoints.lst key, while FileList keys the same file as os.path.normcase(canonize(filename)). On a case-insensitive filesystem, opening the same file under a different spelling yields one window (the normalized key matches) but a different breakpoint key: the breakpoints silently disappear and a duplicate entry is appended to breakpoints.lst forever.
Guarded twin. filelist.py:85 — the normalizing side of the same identity.
Suggested fix. Normalize the breakpoint key the same way FileList does.
Prior art — no matching report found. Searched the cached python/cpython tracker corpus (1626 topic-IDLE issues + 281 unlabelled idlelib issues + 573 PRs + 86 idle.rst items; 2503 unique) for this defect's terms and for its file/function names. No matching report.
FIX · confirmed by reading · shape serialize-and-parse-use-different-grammars
Where: pyshell.py:259, pyshell.py:280-283
What happens. The file is written as filename + '=' + str(breaks) and read back with a startswith(filename + '=') scan plus eval() of the remainder. A file named a=b.py therefore matches the prefix of another file's line and silently drops its breakpoints, and the eval path can be fed a fragment that is not a list literal — eval('b.py=[3,5]') raises an uncaught SyntaxError inside the open path, so opening the file fails with a traceback to an invisible stderr.
Guarded twin. The recent-files list and the config files both use a real parser rather than a hand-split line format.
Suggested fix. Use json or configparser; at minimum stop using eval on the value.
Prior art — no matching report found. Searched the cached python/cpython tracker corpus (1626 topic-IDLE issues + 281 unlabelled idlelib issues + 573 PRs + 86 idle.rst items; 2503 unique) for this defect's terms and for its file/function names. No matching report.