From 419303d0f6602247b5ab55a3b9873334c8724626 Mon Sep 17 00:00:00 2001 From: dhkatz Date: Mon, 2 Nov 2026 09:17:33 -0800 Subject: [PATCH] Report native crashes to T5DE The next start reports a native crash that ended the last run (IMVU's crash.log, this client's own only, each once), with consent; checked on the build (native_crash). Co-Authored-By: Claude Opus 5.5 --- .../0033-Report-native-crashes-to-T5DE.patch | 97 +++++++++++++++++++ tests/py27/check_build.py | 69 +++++++++++++ tests/test_client.py | 2 +- 3 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 patches/0033-Report-native-crashes-to-T5DE.patch diff --git a/patches/0033-Report-native-crashes-to-T5DE.patch b/patches/0033-Report-native-crashes-to-T5DE.patch new file mode 100644 index 0000000..bf01bf7 --- /dev/null +++ b/patches/0033-Report-native-crashes-to-T5DE.patch @@ -0,0 +1,97 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: dhkatz +Date: Fri, 30 Oct 2026 08:16:59 -0700 +Subject: [PATCH] Report native crashes to T5DE + +A native crash (in IMVU's C++ code, or a renderer) is reported to T5DE the next time the client starts, +with the person's consent, like its Python errors: the exception and the crashing thread's stack +(each call's module and offset), from the crash log IMVU's crash handler leaves in %APPDATA%\IMVU, which +the client never read (its data is in T5DE's folder). Each run records its process ID, so only the +client's own crashes are sent, each once. + +Co-Authored-By: Claude Opus 5.5 +T5de-Id: t5de-native-crashes +T5de-Name: Native Crash Reports +T5de-Group: core +T5de-Required: true +T5de-Depends: t5de-diagnostics +--- + library/main/client.py | 50 +++++++++++++++++++++++++++++++++++++++ + library/main/clientapp.py | 3 +++ + 2 files changed, 53 insertions(+) + +diff --git a/library/main/client.py b/library/main/client.py +index 9cad8bcf9f775a3ad5b3fa0fbad9b4534e215918..e59ee3f55843e2967d1f5919bd2c17971af292e5 100644 +--- a/library/main/client.py ++++ b/library/main/client.py +@@ -96,6 +96,56 @@ def t5deReport(excInfo, kind): + except Exception: + pass + ++def t5deNativeCrash(): ++ """Report the native crash that ended this client's last run, if there was one. IMVU's crash handler writes ++ %APPDATA%/IMVU/crash.log, which the client itself never reads (its data is in T5DE's folder). Only the exception ++ and the crashing thread's stack (module and offset) are sent. Each run records its process ID, so only this ++ client's own crashes are sent (not IMVU's, which writes there too), and each once.""" ++ try: ++ import json, os, re, tempfile, time ++ import imvu.fs.windows ++ import imvu.version ++ record = os.path.join(imvu.fs.windows.getUserDirectory(), 't5de_last_run.json') ++ try: ++ with open(record) as f: ++ last = json.load(f) ++ except Exception: ++ last = {} ++ with open(record, 'w') as f: ++ json.dump({'pid': os.getpid(), 'start': time.time()}, f) ++ log = os.path.join(os.environ.get('APPDATA', ''), 'IMVU', 'crash.log') ++ if not last.get('pid') or not os.path.isfile(log) or os.path.getmtime(log) < last.get('start', 0): ++ return ++ with open(log) as f: ++ text = f.read(4000000) ++ pid = re.search(r'Process ID:\s*(\d+)', text) ++ if not pid or int(pid.group(1)) != last['pid']: ++ return ++ code = re.search(r'ExceptionCode:\s*(0x[0-9a-fA-F]+)', text) ++ code = code.group(1).lower() if code else 'unknown' ++ frames = [] ++ crashing = text.split('CRASHING THREAD:', 1) ++ if len(crashing) == 2: ++ for line in re.split(r'\n\s*\n', crashing[1], 1)[0].splitlines(): ++ m = re.match(r'\s*\(([^#!,()]+?)(?:#[0-9a-fA-F]+)?!([0-9a-fA-F]+)', line) ++ if m: ++ frames.append({'file': m.group(1), 'function': '+0x' + m.group(2).lower(), 'line': 0}) ++ names = {'0xc0000005': 'access violation', '0xc00000fd': 'stack overflow', '0xc0000409': 'stack buffer overrun', ++ '0xe06d7363': 'C++ exception', '0x80000003': 'breakpoint', '0xc000001d': 'illegal instruction'} ++ # The log lists the outermost call first. The crash is named after the innermost module that isn't ++ # Windows' or the C runtime's (which only raise it). ++ system = re.compile(r'^(ntdll|kernelbase|kernel32|msvcr\d*|msvcp\d*|ucrtbase|vcruntime\d*)\.dll$', re.I) ++ own = [f['file'] for f in frames if not system.match(f['file'])] ++ where = own[-1] if own else (frames[-1]['file'] if frames else 'unknown module') ++ report = {'platform': 'native', 'type': 'NativeCrash', 'value': 'exception %s (%s) in %s' % (code, names.get(code, 'unknown'), where), 'frames': frames[:100], 'tags': {'kind': 'native', 'imvu': imvu.version.__version__}} ++ fd, path = tempfile.mkstemp(prefix='t5de-report-', suffix='.json') ++ os.write(fd, json.dumps(report)) ++ os.close(fd) ++ t5deHelper('report', path) ++ except Exception: ++ pass ++ ++ + def t5deUpdateFile(): + import os, tempfile + return os.path.join(tempfile.gettempdir(), 't5de-update.json') +diff --git a/library/main/clientapp.py b/library/main/clientapp.py +index 4a1778d5aaf11f5cc6502c30ec81ac46fbf5c654..9807b988b17cd867994fbcac8ff74f6f528aecf1 100644 +--- a/library/main/clientapp.py ++++ b/library/main/clientapp.py +@@ -714,6 +714,9 @@ def reportUnrecoverableError(serviceProvider, userAccount, dialogFactory): + def runMain(argv=sys.argv): + from main import client + client.t5deTrack('launch') ++ # T5DE: a native crash ending the last run, to T5DE (with consent). Here, not at login: ClientApp binds ++ # maybeReportUncleanShutdownAfterLogin as a default argument, which a patch can't reach. ++ client.t5deNativeCrash() + return _t5de_original_runMain(argv) + + diff --git a/tests/py27/check_build.py b/tests/py27/check_build.py index a6f88a5..bdbdac7 100644 --- a/tests/py27/check_build.py +++ b/tests/py27/check_build.py @@ -339,5 +339,74 @@ def proxy(): return not wrong, '; '.join(wrong) or 'requests, errors, post_multipart, IMQ and fallback as expected' +# The shape of IMVU's native crash log (%APPDATA%\IMVU\crash.log), cut down: outermost call first. +CRASH_LOG = '''------------------------------------------------- +IMVU CRASH LOG + +PROCESS INFO: + Process ID: 4242 + Main thread ID: 0x0000307c + +EXCEPTION: + ExceptionRecord: 0x00cfcea0 + ExceptionCode: 0xc0000005 + +CRASHING THREAD: 0x0000307c() + Priority: 0 + Stack: + (PYTHON27.DLL#7e2905e5022c4e4b8281138443d73afa3!00123bbd, pc=72a43bbd) + (SceneWindow.dll#4322532c99964c0aa698af609c8076181!000a9d7d, pc=718b9d7d) + (MSVCR100.dll#da7b3ed8d4af49efa87d61651bd1d6492!0002872d, pc=72bf872d) + +THREAD: 0x00001bc8() + Stack: + (ntdll.dll#dbc4b3053c0f3189895b2be009a0d2a41!0006c6e1, pc=7757c6e1) +''' + + +def native_crash(): + root = tempfile.mkdtemp() + try: + os.makedirs(os.path.join(root, 'IMVU')) + os.makedirs(os.path.join(root, 'T5DE')) + log = os.path.join(root, 'IMVU', 'crash.log') + with open(log, 'w') as f: + f.write(CRASH_LOG) + os.environ['APPDATA'] = root + imvu = types.ModuleType('imvu') + imvu.fs = types.ModuleType('imvu.fs') + imvu.fs.windows = types.ModuleType('imvu.fs.windows') + imvu.fs.windows.getUserDirectory = lambda: os.path.join(root, 'T5DE') + imvu.version = types.ModuleType('imvu.version') + imvu.version.__version__ = '554.8' + sys.modules.update({'imvu': imvu, 'imvu.fs': imvu.fs, 'imvu.fs.windows': imvu.fs.windows, + 'imvu.version': imvu.version}) + client = _no_proxy() + sent = [] + client.t5deHelper = lambda *args: sent.append(json.load(open(args[1]))) + record = os.path.join(root, 'T5DE', 't5de_last_run.json') + + def last_run(pid): + with open(record, 'w') as f: + json.dump({'pid': pid, 'start': os.path.getmtime(log) - 60}, f) + + last_run(4241) # another process's crash (IMVU's own client writes there too): not sent + client.t5deNativeCrash() + unsent = len(sent) + last_run(4242) # the last run's: sent once + client.t5deNativeCrash() + client.t5deNativeCrash() + want = {'platform': 'native', 'type': 'NativeCrash', + 'value': 'exception 0xc0000005 (access violation) in SceneWindow.dll', + 'frames': [{'file': 'PYTHON27.DLL', 'function': '+0x00123bbd', 'line': 0}, + {'file': 'SceneWindow.dll', 'function': '+0x000a9d7d', 'line': 0}, + {'file': 'MSVCR100.dll', 'function': '+0x0002872d', 'line': 0}], + 'tags': {'kind': 'native', 'imvu': '554.8'}} + ok = unsent == 0 and sent == [want] + return ok, 'one report of the last run\'s crash' if ok else 'sent %r' % sent + finally: + shutil.rmtree(root, ignore_errors=True) + + ok, detail = globals()[CHECK]() print(json.dumps({'ok': ok, 'detail': detail})) diff --git a/tests/test_client.py b/tests/test_client.py index 925be2c..f0d9cff 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -23,7 +23,7 @@ def manifest(project): @pytest.mark.parametrize("check", ["embeds_originals", "devicefingerprint", "prefs_key", "user_directory", - "network_original", "network_blocklist", "proxy"]) + "network_original", "network_blocklist", "proxy", "native_crash"]) def test_spliced_library(client, run_py27, check): install, built = client result = run_py27("check_build.py", install / "library.zip", built / "library.zip", check)