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

オープン
#161,620 コメント 3 件 リアクション 0 件 担当者 0 名 GitHub で見る

メンテナーはふだん 1 日以内に返信

まだ誰も着手していません。

評価

難易度
5/5
見積もり時間
1週間以上
初心者へのやさしさ
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分
マージ済み PR(30日)
630

環境構築

Codespaces で開く

このプロジェクトの開発コンテナを、あなたの GitHub アカウントでブラウザ上に起動します。

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

python/cpython のほかの issue

python/cpython の issue をすべて見る

似ている issue

Python の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。