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

Abierto
#161,620 3 comentarios 0 reacciones 0 asignados Ver en GitHub

Los mantenedores suelen responder en 1 día

Nadie ha tomado este issue todavía.

Evaluación

Dificultad
5/5
Tiempo estimado
Más de una semana
Aptitud para principiantes
38/100
Tipo de issue
Error
Claridad
Bastante claro
Estado de actividad
Activo
Stack tecnológico
python
Área
desktop

Línea de trabajo

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.

Escrito por el modelo de indexación a partir del texto del issue.

Descripción

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).

Lenguaje dominante
Python
Estrellas
78.2k
Forks
36.6k
Merge medio
19 h 57 min
PR fusionados (30 d)
630

Preparar el entorno

Abrir en Codespaces

Inicia el contenedor de desarrollo del proyecto en tu navegador, con tu propia cuenta de GitHub.

Primeros pasos

  1. Lee el issue completo y luego la guía de contribución del proyecto.
  2. Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
  3. Haz un fork del repositorio y trabaja en una rama.
  4. Abre un pull request que haga referencia al número del issue.

Más de python/cpython

Todos los issues de python/cpython

Issues similares

Más issues de Python

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.