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

未关闭
#161,620 3 条评论 0 个 reaction 已指派 0 人 在 GitHub 查看

维护者通常 1 天内回复

还没有人认领这个 Issue。

评估

难度
5/5
预计耗时
一周以上
新手友好度
38/100
Issue 类型
缺陷
描述清晰度
基本清楚
活跃度
活跃
技术栈
python
领域
desktop

调研方向

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.

由索引模型根据 Issue 内容生成。

描述

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

主要语言
Python
星标
78.2k
派生
36.6k
平均合并
19 小时 57 分钟
30 天内合并 PR
630

环境准备

在 Codespaces 中打开

在浏览器里用你自己的 GitHub 账号启动这个项目的开发容器。

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

python/cpython 的其他 Issue

查看 python/cpython 的全部 Issue

相似的 Issue

更多 Python Issue

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。