internal_close() frees self->stat_atopen after the if (self->fd >= 0) block instead of inside it, so two threads closing the same unbuffered FileIO can both read the same pointer and free it. _io_FileIO_truncate_impl() does the same. GIL build isn't affected.
Fixed with an atomic exchange so only one thread gets the pointer. PyMem_Free(NULL) is a no-op so the NULL check in truncate() goes too. I didn't bother with #ifdef Py_GIL_DISABLED, it's one atomic next to a close(2), but I can add it.
Left alone: readall(), _isatty_open_only() and _blksize read the pointer unsynchronised and can see a freed block. Not new, and bounded, readall() clamps with st_size < _PY_READ_MAX so worst case is a bad size guess.
Fixing it needs QSBR or a lifetime change, same design question as GH-151708, so I'd do it separately. __init__ is unsynchronised too.
Linux x86-64, gcc 13.3, --disable-gil --with-pydebug: reproducer crashed 4/4 before (0xdd pattern, one mimalloc: corrupted free list entry), 0/5 after. test_io and test_free_threading pass. Compiles clean on a GIL build.
No test yet, the obvious one is timing dependent and I'd rather bring it with the follow-up. Can add it now if you want.
Same change for 3.15 and 3.14. 3.13 stores a plain int and isn't affected.