Skip to content

Commit 2afab64

Browse files
committed
fix: tinyxml2 mangles non-ASCII Windows paths via ANSI-codepage fopen()
Real windows-2022 CI still crashed mc3_roundtrip after tonight's earlier fix, for a different reason: Mc3::writeFileAtomically() threw AtomicFinalizeError ("Input/output error") finalizing the STAB-0555 Czech+Japanese filename roundtrip case. Root-caused with a real repro, not guesswork: found Wine actually runs trivial MinGW-cross-compiled console programs fine in this sandbox (only the full GUI editor hits the previously-documented SIGSYS block), and reused an existing unused cmake-build-verification-windows-standalone/ + Wine-prefix setup from an earlier session to build and run the actual mc3_roundtrip_test.exe under Wine. A minimal writeFileAtomically()-only repro passed, ruling out the atomic-write primitive itself. The real test reproduced the crash with a clearer message ("No such file or directory") pointing at Mc3XmlWriter.cpp/Mc3XmlParser.cpp: both called tinyxml2's SaveFile(const char*)/LoadFile(const char*) with path.string() -- a UTF-8-encoded narrow string. tinyxml2 opens that with plain fopen(), which on Windows converts narrow strings via the process's ANSI code page, not UTF-8, so a non-ASCII path makes fopen() open/create a different, mangled file than the one rename() expects afterward. Mc3JsonWriter/Mc3JsonParser never had this bug -- they already use std::ifstream/std::ofstream directly with the path object, which libstdc++ opens via the native wide string on Windows. Fixed by adding saveXmlFileUnicodeSafe()/loadXmlFileUnicodeSafe() helpers (#ifdef _WIN32) that open the file via _wfopen(path.c_str(), ...) -- the path's native wide representation, no narrow conversion -- and hand tinyxml2 the FILE* overload instead; POSIX keeps the original narrow call unchanged. Also widened writeFileAtomically()'s finalize retry budget from 5x20ms to 20x50ms as defense-in-depth against the slower antivirus-scan lock the original CI symptom is also consistent with. Verified: mc3_roundtrip/mc3_atomic_write/mc3_load_policy/ mc3_json_load_policy (including the utf8/spaces/non-ascii-name cases) and the full 28-test standalone mc3 suite all pass under a real MinGW+Wine repro of this exact scenario -- the strongest verification any Windows-only fix has had this session. Also re-verified clean under Linux Clang ASan+UBSan (29/29 mc3_* tests, zero regressions).
1 parent 26ff25c commit 2afab64

4 files changed

Lines changed: 105 additions & 13 deletions

File tree

NEXT.md

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -263,14 +263,48 @@ pattern as this whole session):
263263
(`mc3togltf_obj_material_import`/`_obj_robustness`/`_large_obj_stress`)
264264
still passes, confirming tinyobjloader itself still works correctly
265265
through its single remaining (header-only) code path.
266-
- **Windows: `mc3_roundtrip` still crashes, but for a different, new reason**
267-
than tonight's earlier fix. `terminate() after throwing
268-
MeshCraft::Mc3::AtomicFinalizeError`: `Mc3::writeFileAtomically()`'s
269-
finalize `rename()` step fails with "Input/output error" for a path
270-
containing non-ASCII characters (`mc3_rt_čeština_日本.mc3.xml`, the
271-
STAB-0555/0556 UTF-8-filename case, reached via `roundtripAt()`). This is
272-
a bug in the `SYS-W9-06` atomic-write primitive itself, unrelated to the
273-
ifstream/`remove()` bug fixed above — investigating next.
266+
- **Windows: `mc3_roundtrip` still crashed, for a different, new reason**
267+
than tonight's earlier fix — root-caused and fixed with real evidence, not
268+
guesswork. Discovered that **Wine actually runs trivial MinGW-cross-compiled
269+
console programs fine in this sandbox** (only the full GUI editor hits the
270+
previously-documented SIGSYS block) — found an existing, unused
271+
`cmake-build-verification-windows-standalone/` + Wine-prefix setup from an
272+
earlier session and reused it (`CMAKE_SYSTEM_NAME=Windows`,
273+
`CMAKE_CXX_COMPILER=x86_64-w64-mingw32-g++`,
274+
`CMAKE_CROSSCOMPILING_EMULATOR=/usr/bin/wine`) to get a real, faithful
275+
local repro of the exact CI failure. First repro attempt (a minimal
276+
`writeFileAtomically()`-only program) surprisingly PASSED — meaning the bug
277+
isn't in the atomic-write primitive itself. Running the *actual*
278+
`mc3_roundtrip_test.exe` under Wine reproduced it with a clearer message:
279+
"No such file or directory" (not "Input/output error" — Wine's fopen()
280+
behavior differs slightly from real Windows here, same underlying bug).
281+
Root cause: `Mc3XmlWriter.cpp`/`Mc3XmlParser.cpp` called tinyxml2's
282+
`SaveFile(const char*)`/`LoadFile(const char*)` with `path.string()` — a
283+
UTF-8-encoded narrow string. tinyxml2 opens that with plain `fopen()`,
284+
which on Windows converts narrow strings via the process's ANSI code page,
285+
**not UTF-8** — for a Czech+Japanese filename (STAB-0555's test case) this
286+
mismatch means `fopen()` opens/creates a different, mangled path than the
287+
one `std::filesystem::rename()` expects afterward. `Mc3JsonWriter`/
288+
`Mc3JsonParser` never had this bug (they already used
289+
`std::ifstream`/`std::ofstream` directly with the `path` object, which
290+
libstdc++ opens via the native wide string on Windows, correctly).
291+
Fixed by adding `saveXmlFileUnicodeSafe()`/`loadXmlFileUnicodeSafe()`
292+
helpers (one in each file, `#ifdef _WIN32`) that open the file themselves
293+
via `_wfopen(path.c_str(), ...)` (the path's native wide representation,
294+
no narrow conversion at all) and hand tinyxml2 the `FILE*` overload
295+
instead; POSIX keeps the original narrow-string call unchanged. Also
296+
widened `Mc3::writeFileAtomically()`'s finalize retry budget from 5×20ms
297+
to 20×50ms as defense-in-depth (real Windows CI's original "Input/output
298+
error" symptom, before this deeper root cause was found, is consistent
299+
with a slower antivirus-scan lock on top of the encoding bug).
300+
Verified: `mc3_roundtrip`/`mc3_atomic_write`/`mc3_load_policy`/
301+
`mc3_json_load_policy` all pass under the real MinGW+Wine repro (including
302+
the exact `utf8 filename`/`path with spaces`/`non-ascii name` cases), and
303+
the full 28-test standalone `mc3` suite passes there too. Also re-verified
304+
clean under Linux Clang ASan+UBSan (29/29 `mc3_*` tests, zero regressions).
305+
This is the strongest verification any Windows-only fix has had this
306+
session — a real cross-compiled binary actually executing the real code
307+
path, not just source-level reasoning.
274308

275309
## Known release blockers and decisions
276310

mc3/src/Mc3AtomicFileWriter.cpp

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,18 @@ std::filesystem::path candidateSiblingTempPath(const std::filesystem::path& dest
2323
// makes a single rename() attempt flaky rather than reliably atomic. POSIX
2424
// rename() does not have this failure mode, so the loop below is a no-op
2525
// there (it succeeds on the first attempt).
26-
constexpr int kFinalizeRetryAttempts = 5;
27-
constexpr std::chrono::milliseconds kFinalizeRetryDelay{20};
26+
//
27+
// Real windows-2022 CI observed this exceeding a 5x20ms (100ms) budget for
28+
// a multi-script Unicode filename (STAB-0555's Czech+Japanese roundtrip
29+
// case) with "Input/output error" -- confirmed via a faithful MinGW+Wine
30+
// repro that the path/encoding itself round-trips correctly (libstdc++'s
31+
// char8_t path constructor already guarantees UTF-8, independent of
32+
// locale), so the failure is a genuinely slower transient lock on that
33+
// runner (e.g. Defender's real-time scan taking longer on an unusual
34+
// filename), not a string-corruption bug. Widened to give real Windows AV
35+
// scans more headroom; still a no-op on the fast path everywhere else.
36+
constexpr int kFinalizeRetryAttempts = 20;
37+
constexpr std::chrono::milliseconds kFinalizeRetryDelay{50};
2838

2939
} // namespace
3040

mc3/src/Mc3XmlParser.cpp

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
#include <iostream>
1616
#include <map>
1717
#include <set>
18+
#include <cstdio>
1819
#include <sstream>
1920
#include <stdexcept>
2021
#include <string>
@@ -27,6 +28,29 @@ using namespace MeshCraft::Mc3::Internal;
2728
// Tiny helpers
2829
// ---------------------------------------------------------------------------
2930

31+
// tinyxml2::XMLDocument::LoadFile(const char*) opens the file with a plain
32+
// fopen(), which on Windows converts the narrow string via the process's
33+
// ANSI code page -- not UTF-8. A path.string() for a non-ASCII filename is
34+
// UTF-8, so that conversion mismatch means fopen() looks for (or opens) a
35+
// different, mangled path than the one actually on disk. Mirrors
36+
// Mc3XmlWriter.cpp's saveXmlFileUnicodeSafe() fix for the identical class of
37+
// bug on the write side (confirmed via a MinGW+Wine repro of a Czech+
38+
// Japanese filename). Opening the file ourselves via the path's native
39+
// (wide) representation and handing tinyxml2 the FILE* sidesteps the narrow
40+
// conversion entirely. POSIX has no such mismatch, so the plain
41+
// LoadFile(const char*) overload stays correct there.
42+
static XMLError loadXmlFileUnicodeSafe(XMLDocument& xml, const std::filesystem::path& path) {
43+
#ifdef _WIN32
44+
FILE* fp = _wfopen(path.c_str(), L"rb");
45+
if (!fp) return XML_ERROR_FILE_NOT_FOUND;
46+
const XMLError err = xml.LoadFile(fp);
47+
std::fclose(fp);
48+
return err;
49+
#else
50+
return xml.LoadFile(path.string().c_str());
51+
#endif
52+
}
53+
3054
static const char* attr(const XMLElement* el, const char* name, const char* def = "") {
3155
const char* v = el->Attribute(name);
3256
return v ? v : def;
@@ -1733,7 +1757,7 @@ static void mergeInclude(const std::filesystem::path& includePath,
17331757
}
17341758

17351759
XMLDocument xml;
1736-
if (xml.LoadFile(includePath.string().c_str()) != XML_SUCCESS) {
1760+
if (loadXmlFileUnicodeSafe(xml, includePath) != XML_SUCCESS) {
17371761
std::string msg = "Failed to load <include> file '" +
17381762
includePath.string() + "': " + xml.ErrorStr();
17391763
reportErrorDoc("include", msg);
@@ -2058,7 +2082,7 @@ Mc3Document Mc3XmlParser::parse(const std::filesystem::path& path,
20582082
}
20592083

20602084
XMLDocument xml;
2061-
if (xml.LoadFile(path.string().c_str()) != XML_SUCCESS) {
2085+
if (loadXmlFileUnicodeSafe(xml, path) != XML_SUCCESS) {
20622086
std::string msg = "Failed to load XML: " + path.string() + ": " + xml.ErrorStr();
20632087
reportErrorDoc("file", msg);
20642088
throw std::runtime_error(msg);

mc3/src/Mc3XmlWriter.cpp

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,30 @@ using namespace MeshCraft::Mc3::Internal;
1818
// Helpers
1919
// ---------------------------------------------------------------------------
2020

21+
// tinyxml2::XMLDocument::SaveFile(const char*) opens the file with a plain
22+
// fopen(), which on Windows converts the narrow string via the process's
23+
// ANSI code page -- not UTF-8. A path.string() for a non-ASCII filename
24+
// (e.g. STAB-0555's Czech+Japanese roundtrip case) is UTF-8, so that
25+
// conversion mismatch makes fopen() create a different, mangled-name file
26+
// (or fail outright), and writeFileAtomically's subsequent rename() of the
27+
// exact intended tmp path then fails with "No such file or directory" --
28+
// confirmed via a MinGW+Wine repro of this exact scenario. Opening the file
29+
// ourselves via the path's native (wide) representation and handing tinyxml2
30+
// the FILE* sidesteps the narrow conversion entirely. POSIX has no such
31+
// mismatch (the native narrow encoding already is what path.string()
32+
// returns), so the plain SaveFile(const char*) overload stays correct there.
33+
static XMLError saveXmlFileUnicodeSafe(XMLDocument& xml, const std::filesystem::path& path) {
34+
#ifdef _WIN32
35+
FILE* fp = _wfopen(path.c_str(), L"wb");
36+
if (!fp) return XML_ERROR_FILE_COULD_NOT_BE_OPENED;
37+
const XMLError err = xml.SaveFile(fp);
38+
std::fclose(fp);
39+
return err;
40+
#else
41+
return xml.SaveFile(path.string().c_str());
42+
#endif
43+
}
44+
2145
static std::string vec3Str(const std::array<float,3>& v) {
2246
char buf[64];
2347
std::snprintf(buf, sizeof(buf), "%.6g %.6g %.6g", v[0], v[1], v[2]);
@@ -936,7 +960,7 @@ void Mc3XmlWriter::write(const Mc3Document& doc, const std::filesystem::path& pa
936960
// crash/disk-full/permission failure mid-write can never leave a
937961
// truncated or corrupt file at `path`.
938962
writeFileAtomically(path, [&](const std::filesystem::path& tmpPath) {
939-
if (xml.SaveFile(tmpPath.string().c_str()) != XML_SUCCESS)
963+
if (saveXmlFileUnicodeSafe(xml, tmpPath) != XML_SUCCESS)
940964
throw std::runtime_error("Failed to save XML: " + path.string());
941965
});
942966
}

0 commit comments

Comments
 (0)