tkinter: os.dup2() on fds 1/2 breaks every subsequent Tcl interpreter on Windows

Aperta
#161,620 3 commenti 0 reazioni 0 assegnatari Vedi su GitHub

I maintainer di solito rispondono entro 1 giorno

Nessuno ha ancora preso questa issue.

Valutazione

Difficoltà
5/5
Tempo stimato
Più di una settimana
Idoneità per principianti
38/100
Tipo di issue
Bug
Chiarezza
Abbastanza chiara
Stato di attività
Attiva
Stack tecnologico
python
Ambito
desktop

Direzione di ricerca

Start with Modules/_tkinter.c and the Tcl ticket linked in the issue; trace how Tkapp_New() initializes Tcl and its standard channels, then reproduce the Windows failure with the provided script. The issue proposes installing channels from duplicated handles via Tcl_SetStdChannel(), but notes that this direction has not been validated in C. Done means subsequent Tk() and Tcl() interpreters initialize successfully after fd 1 and 2 are redirected and restored.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Descrizione

extension-modules OS-windows topic-tkinter type-bug

Bug report

Bug description:

On Windows, once a Tcl interpreter has built its standard channels, a later os.dup2() onto fds 1 and 2 can make every subsequent tkinter.Tk() or tkinter.Tcl() fail:

_tkinter.TclError: Can't find a usable init.tcl in the following directories:
    {C:\Users\...\Python314\tcl\tcl8.6}

C:/Users/.../tcl/tcl8.6/init.tcl: couldn't read file "C:/Users/.../tcl/tcl8.6/init.tcl": no such file or directory

The file is present and readable the whole time. At the instant of the failure Python can os.stat() and open() that same path, and a Tcl interpreter created before the redirection can still source it. The errno is not real.

This is not GUI-specific — tkinter.Tcl() is affected too — and it does not need threads, subprocesses or a window.

Reproducer

Fails 10/10 runs here, with stdout going to a console, a pipe, a file or NUL, for both Tk() and Tcl():

import os, sys, tempfile, tkinter

saved = {fd: os.dup(fd) for fd in (1, 2)}
tmp = {fd: tempfile.TemporaryFile() for fd in (1, 2)}
use_tcl = "--tcl" in sys.argv

def new_interpreter():
    if use_tcl:
        ip = tkinter.Tcl()                 # no GUI at all
        ip.eval("fconfigure stdout")       # make Tcl build its std channels
        del ip
    else:
        tkinter.Tk().destroy()

for i in range(30):
    for fd in (1, 2):                      # redirect fds 1 and 2 ...
        os.dup2(tmp[fd].fileno(), fd)
    try:
        new_interpreter()
    except Exception as exc:
        for fd in (1, 2):
            os.dup2(saved[fd], fd)
        print(f"interpreter {i} failed:\n{exc}")
        sys.exit(1)
    for fd in (1, 2):                      # ... and put them back
        os.dup2(saved[fd], fd)
print("30 interpreters, no failure")

It usually fails at interpreter 1 or 2. Two interpreters and two dup2() calls are enough to trigger it, but that minimal form is sensitive to how handle values happen to be laid out — it fails 18/20 when the real stdout is NUL and 0/20 when it is a pipe. The loop above is stable everywhere, so it is the better reproducer.

What is happening
  1. Tcl builds its stdout/stderr channels on first use out of the values GetStdHandle() returns, and keeps them for the life of the process.
  2. os.dup2() on fds 1 and 2 goes through the UCRT's _dup2, which for fds 0/1/2 closes the handle currently installed and publishes the new one with SetStdHandle(). The values Tcl cached are now closed handles.
  3. Windows recycles those values immediately. Traced across one redirect/restore cycle (#fd:handle/type):
start              fd1=#1:184/PIPE   fd2=#2:188/PIPE     stdout=184  stderr=188
after redirect     fd1=#1:700/DISK   fd2=#2:184/DISK     stdout=700  stderr=184
after restore      fd1=#1:724/PIPE   fd2=#2:700/PIPE     stdout=724  stderr=700

Handle 184 is the real stdout pipe at startup and a temporary file one dup2 later. Handle 700 is what Tcl cached as stdout for the first interpreter, and one dup2 later it belongs to fd 2.

  1. Creating a further Tcl interpreter then fails while reading its own init.tcl.

Step 1 is the necessary condition, and it is testable on its own. A bare Tcl interpreter that never touches its standard channels never fails; add one fconfigure and it fails like the rest:

60 interpreters per run, same dup2 pattern runs failed
tkinter.Tcl(), standard channels never used 0/10
same, plus fconfigure stdout; fconfigure stderr 10/10

Tk matters only because Tk_Init creates those channels.

The converse holds too. If the handles Tcl caches are private duplicates that nothing else can close, the failure disappears. Done from Python with ctypes — DuplicateHandle() the current fd 1/2 handles, SetStdHandle() the copies, create one interpreter so Tcl caches them, then never close them:

runs failed
as above 15/15
identical, but std handles pinned to private duplicates first 0/15
Why this looks like a _tkinter issue

This is Tcl ticket 91c9bc1c45, "Standard channels (stdin, stdout, stderr) on Windows may get broken by dup2()", filed 2018-11-09 against Tcl 8.6.8. It names the same mechanism — TclpGetDefaultStdChannel() in win/tclWinChan.c, broken by _dup2 on fds 0/1/2 — and proposes DuplicateHandle() on the value from GetStdHandle() before handing it to Tcl_MakeFileChannel().

It was closed Invalid on 2026-10-15, with the stated reason that there had been no reply since 2018. The 2018 objection from a Tcl maintainer was that third-party code should call Tcl_SetStdChannel() when it changes the standard handles. In this situation _tkinter is the only component in a position to do that: os.dup2() on fds 0/1/2 is ordinary, supported Python, and the code calling it has no idea Tcl is embedded in the process.

So the equivalent of the upstream patch, on this side, would be for _tkinter to install the standard channels itself from duplicated handles via Tcl_SetStdChannel() — once per process, before Tcl_Init() runs in Tkapp_New() (Modules/_tkinter.c). I have only verified the ctypes equivalent above, not a C patch, so I may well be missing a constraint; I am happy to put a patch together if this direction seems right.

Why it matters in practice

pytest's default --capture=fd does exactly this dup2 pattern around every test. Any Windows test suite that creates a Tcl/Tk interpreter per test hits it:

pytest configuration, 30 tests creating one Tk() each runs failed
--capture=fd (the default) 16/20
-s / --capture=sys / --capture=tee-sys 0/20
one test creating 30 interpreters in a loop, --capture=fd 0/20

That is pytest-dev/pytest#10364, open since 2022. Applying the pin described above from a conftest.py takes that suite from 10/20 runs failing to 0/20.

What I have not established

I have not traced the step from "Tcl is holding a stale standard handle" to the specific couldn't read file ...: no such file or directory. The reported errno is clearly not the real one, and the upstream ticket describes a different surface symptom from the same cause (failing writes, EBADF) — so I assume it is stale rather than meaningful, but I have not proved it.

Two things that did not pan out, in case they save someone time: Tcl does not close standard handles it does not own (a planted sentinel handle survives interpreter create + destroy), and creating a Tk root before the redirection does not help, because the first dup2 closes the original stdout handle too.

CPython versions tested on:

3.14 (3.14.4, Windows installer build, Tcl/Tk 8.6.15). I do not have other versions on this machine, so I have not checked whether main behaves differently — nothing in the mechanism looks version-specific.

Operating systems tested on:

Windows (Windows 11 Pro, build 26200).

Lingua principale
Python
Stelle
78.2k
Fork
36.6k
Merge medio
19h 57m
PR unite (30g)
630

Preparare l'ambiente

Apri in Codespaces

Avvia il container di sviluppo del progetto nel browser, con il tuo account GitHub.

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di python/cpython

Tutte le issue di python/cpython

Issue simili

Altre issue su Python

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.