GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — tkinter: os.dup2() on fds 1/2 breaks every subsequent Tcl interpreter on Windows · Issue #161620 · python/cpython

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

New issue
Copy link
New issue
Copy link
Open
Open
tkinter: os.dup2() on fds 1/2 breaks every subsequent Tcl interpreter on Windows#161620
Copy link
Labels
OS-windowsextension-modulesC modules in the Modules dirC modules in the Modules dirtopic-tkintertype-bugAn unexpected behavior, bug, or errorAn unexpected behavior, bug, or error

Description

@kakakakacat
kakakakacat
opened on Nov 8, 2026
Issue body actions

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

Activity

  1. picnixz
    added
    extension-modulesC modules in the Modules dirC modules in the Modules dir
    topic-tkinter
    on Nov 8, 2026
  2. picnixz commented on Nov 8, 2026

    @picnixz
    picnixz
    on Nov 8, 2026
    Member
    More actions

    cc @serhiy-storchaka

  3. picnixz
    added
    OS-windows
    type-bugAn unexpected behavior, bug, or errorAn unexpected behavior, bug, or error
    on Nov 8, 2026
  4. ThingamabobsCoding commented on Nov 9, 2026

    @ThingamabobsCoding
    ThingamabobsCoding
    on Nov 9, 2026
    More actions

    It isn't common to build the std-channels. What you normally do in python is to use pythons stdout etc. and redirect any tcl proc, e.g puts & gets. As in the linked ticket stated, how should the TCL be aware of any changes, if you build them then you are responsible for them, no?

    Further reading:
    https://wiki.tcl-lang.org/page/redefining+stdout
    see discussion in this wiki: https://wiki.tcl-lang.org/page/stdout
    redirecting could be done like this https://stackoverflow.com/a/74578994/13629335

  5. ThingamabobsCoding commented on Nov 9, 2026

    @ThingamabobsCoding
    ThingamabobsCoding
    on Nov 9, 2026
    More actions

    Cannot reproduce on Win11 with python 3.15.2 and tkinter 9.0; ran the script 10 times.
    Just wanted to check if you call exit on the TCL interpreter if that solves your issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    OS-windowsextension-modulesC modules in the Modules dirC modules in the Modules dirtopic-tkintertype-bugAn unexpected behavior, bug, or errorAn unexpected behavior, bug, or error

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      You are visitor 01402503 — GeezerVison does not store the pages you look up.
      Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.