Investigating System Freezes Caused by Long-Running Tuanjie Editor Sessions

Over the past couple of days, while developing a project with Tuanjie, I found that leaving the editor running for a while would occasionally cause my computer to freeze completely, usually by the time I returned to work the next day. There was no blue screen and no consistent error dialog; only a few applications occasionally reported OOM errors. Once it happened, the machine normally had to be rebooted by holding the power button.

After investigation, the root issue turned out to be a memory leak inside Tuanjie itself, which currently also exists on the Unity 6 mainline. Certain paths can trigger it more frequently and eventually cause a freeze. This article records the investigation process.

tuanjielogo

Environment

  • Operating system: Windows 11
  • Editor: Tuanjie 2022.3.62t6
  • Build branch: tuanjie/1.8/staging

Logs

Because the system was almost completely frozen when the issue occurred, I checked not only Tuanjie’s own logs but also the system crash logs to see whether a child process had unusually high usage.

I asked GPT to scan the drive and found 60 resource-exhaustion events within one week. The most important one was Event ID 2004; both the official documentation and the event description indicated that the crashes were indeed caused by insufficient memory:

Windows log

Interestingly, these events did not all have the same cause:

  1. In one case, Tuanjie’s private memory grew from about 35.18 GiB to 39.25 GiB, making it highly suspicious.
  2. In another case, system commit reached about 94.93/94.94 GiB.
    • svchost grew to 50.74 GiB.
    • Processes related to DoSvc used about 12.65 GiB.
    • Tuanjie used about 9.08 GiB.

Below are two representative Event ID 2004 entries. I added digit separators to the byte counts to make them easier to review.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[2026/7/16 11:30:59]
Provider: Microsoft-Windows-Resource-Exhaustion-Detector
Event ID: 2004
Windows successfully diagnosed a low virtual-memory condition. The following programs used most of the virtual memory:
Tuanjie.exe (38948) used 42,144,346,112 bytes;
svchost.exe (9560) used 8,966,844,416 bytes;
svchost.exe (44892) used 5,992,996,864 bytes.

[2026/7/20 09:04:22]
Provider: Microsoft-Windows-Resource-Exhaustion-Detector
Event ID: 2004
Windows successfully diagnosed a low virtual-memory condition. The following programs used most of the virtual memory:
svchost.exe (3068) used 54,483,042,304 bytes;
svchost.exe (5272) used 13,587,877,888 bytes;
Tuanjie.exe (6952) used 9,745,526,784 bytes.

The exported system logs contained 83 Event ID 2004 entries in total, with 60 occurring in the preceding seven days.

In the first entry, Tuanjie was clearly the largest consumer.

The second entry was quite different: svchost was the process consuming the largest share.

That made the situation more complicated. Tuanjie was suspicious, but it was not the only explanation for every OOM event. The eventual crash could be the result of multiple memory-leak bugs. The immediate goal was to eliminate the most damaging factor, or at least keep the editor from crashing after being left idle for too long.

Investigation Tool

I had AI put together a MemoryLeakTracker. Its purpose was to collect the following information when system memory showed abnormal growth over a period of time:

Collected data Tool Policy
System memory and kernel pools Performance counters Every 15 seconds, retained in a rolling buffer
Tuanjie memory, handles, and threads Performance counters Preserved when a threshold is reached
Handles, heaps, and virtual-memory stacks WPR/ETW Retains about 10 minutes of ETL data
Process state ProcDump Creates a dump when private memory reaches 12 GiB
Supporting evidence Event logs and system snapshots Saves one copy before and after the event

The collector is triggered when any of the following conditions is met:

  • Tuanjie private memory exceeds 6 GiB.
  • System commit exceeds 70%.
  • Handle count exceeds 10,000.
  • Growth exceeds 768 MiB within 10 minutes.
  • Private memory reaches 12 GiB, at which point a dump is created.

This tool does not prevent the system from crashing or rebooting. It only needs to leave some final evidence before that happens, so that the next time I log in I can see more specific logs. Without an ETL trace and a dump, all you get is a technically correct but unhelpful statement such as “usage is very high.” With call stacks, there is at least a chance to trace the issue back to the code above DuplicateHandle.

Reproducing the Issue

Once the tool was ready, I restarted the editor and left it running. Surprisingly, the tool displayed its recording prompt after only a few minutes. The reason the crash might normally take days to appear is probably that this machine has 64 GiB of physical memory to absorb the growth for a while.

1. Reproduction

The first important capture was located at:

E:\MemoryLeak-Capture\Incidents\20260720-142209-PID22848

At 14:21:19, two AssetImportWorker processes started.

At 14:22:09, one worker process reached about 11,590 handles, triggering the automatic capture.

The automatically generated incident-20260720-142210.json contained these key fields:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
{
"CapturedAt": "2026-07-20T14:22:16.1321305+08:00",
"Reason": "Handle count reached 11590",
"System": {
"CommitPercent": 29.05,
"AvailablePhysicalMiB": 36851.55,
"PoolPagedMiB": 1304.77,
"PoolNonpagedMiB": 1326.43
},
"Targets": [
{
"ProcessId": 27308,
"CreationTime": "2026-07-20T11:00:06.8128080+08:00",
"PrivateGiB": 3.853,
"HandleCount": 6060,
"ThreadCount": 292,
"ProjectPath": "E:\\Unity\\wx_gardenagrown_1\\Unity"
},
{
"ProcessId": 22848,
"ParentProcessId": 27308,
"PrivateGiB": 0.756,
"HandleCount": 11590,
"ThreadCount": 155,
"WorkerName": "AssetImportWorker0"
},
{
"ProcessId": 6884,
"ParentProcessId": 27308,
"PrivateGiB": 0.754,
"HandleCount": 11589,
"ThreadCount": 155,
"WorkerName": "AssetImportWorker1"
}
]
}

At that point, system commit was only 29.05%, so this was not an active OOM condition. The tracker had caught the abnormal behavior early.

The main process showed a very typical handle pattern: a rising and falling sawtooth curve, with each trough ending higher than the previous one:

Time Main-process handle count
14:22:10 6,060
14:24:18 35,684
14:29:57 120,037
14:30:29 8,846
14:38:51 132,168

From 14:59:59 to 15:00:04, only five seconds elapsed, yet the handle count grew from 14,884 to 16,825 while private memory was still around 4 GiB.

This essentially established the direction of the investigation: the leak was in handles rather than heap memory.

2. Analysis

Next, I used Sysinternals Handle to inspect PID 27308, the process that had just exhibited the problem.

One snapshot contained the following data:

  • Total handles: 97,998.
  • Thread handles: 92,490.
  • Live threads: 289.

The relevant Handle output was as follows:

1
2
3
4
5
6
7
8
9
10
11
Handle type summary:
Event : 908
Semaphore : 3264
Thread : 92490
Total handles: 97998

Get-Process PID 27308:
Live threads: 289

Thread-handle target aggregation:
>99% of thread handles -> TID 30960

More than ninety thousand Thread handles is frankly alarming.

After aggregating by target thread, more than 99% of the abnormal handles pointed to TID 30960. In other words, this entire pile of handles was associated with one thread; the system had not actually created that many running tasks.

I strongly suspected that the service behind thread 30960 was being duplicated or restarted repeatedly, causing the handle count to keep growing.

3. Confirmation

To analyze the ETL trace, I installed the Windows Performance Toolkit and loaded Microsoft and Tuanjie symbols in WPAExporter.

In only 15 seconds, the same duplication stack appeared 219,368 times, which was already far beyond normal.

1
2
3
4
5
6
7
8
9
10
Thread::RunThreadWrapper
PreviewTextureManager::LoadingLoop
PreviewTextureManager::GetMostImportantTextureToLoad
ProtectedScopedThreadAttach::ProtectedScopedThreadAttach
scripting_thread_attach
mono_thread_internal_attach
mono_thread_attach_internal
mono_threads_open_native_thread_handle
KernelBase!DuplicateHandle
ntdll!NtDuplicateObject

The aggregate summary exported from WPA was:

1
2
3
4
5
6
7
Trace range                         : 15 seconds
Dominant creation method : Duplicate
Dominant stack events : 219368
Observed net handle growth : 380-400 handles/second
Creating process : Tuanjie.exe (27308)
Handle type : Thread
Dominant target thread : TID 30960

At this point, the case was essentially solved:

  1. The background loading thread in PreviewTextureManager was executing preview work.
  2. It attached a native thread to Mono through ProtectedScopedThreadAttach.
  3. Mono duplicated the native thread handle.
    • Call site: mono_threads_open_native_thread_handle.
  4. The handles were not closed promptly, so the same thread object accumulated more and more of them.

The root cause therefore lay inside the Tuanjie engine, on the path between the preview thread and the Mono attachment process.

Why Was This Project So Easy to Trigger?

We had found who was leaking, but the investigation was not over. Why did the same engine trigger the issue so frequently in this project?

The editor logs showed that the following file was repeatedly detected, written, and imported around the time the abnormal behavior began:

1
Assets/Res/Editor/UI/UXTool/Tools/UserDatas/FilesRecentlySelectedData.json

Following the logs and the code revealed that frequent writes from the ThunderFire UXTool favorites feature were responsible:

  1. RecentSelectRecord subscribes to Selection.selectionChanged.
  2. Whenever the selection changes, the code first removes the old record and then adds a new one.
  3. Both removal and addition call JsonAssetManager.SaveAssets.
  4. SaveAssets writes JSON into the Assets directory.
    • It uses File.WriteAllText.
  5. The write triggers an Asset Database refresh.
  6. Resource items in the “recently selected” panel also call AssetPreview.GetAssetPreview.
  7. These preview requests ultimately enter the leaking PreviewTextureManager.

The Editor-prev.log from before the restart confirmed the same pattern. Here is a short extract:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Starting new worker id: 0 with log in .../Logs/AssetImportWorker0.log
Starting new worker id: 1 with log in .../Logs/AssetImportWorker1.log
Detected change in .../Assets/Res/Editor/UI/UXTool/Tools/UserDatas/FilesRecentlySelectedData.json
Start importing Assets/Res/Editor/UI/UXTool/Tools/UserDatas/FilesRecentlySelectedData.json
Refreshing native plugins compatible for Editor in 39.09 ms, found 15 plugins.
Asset Pipeline Refresh (...): Total: 0.182 seconds
Detected change in .../FilesRecentlySelectedData.json
Start importing Assets/Res/Editor/UI/UXTool/Tools/UserDatas/FilesRecentlySelectedData.json
Asset Pipeline Refresh (...): Total: 0.188 seconds
Detected change in .../FilesRecentlySelectedData.json
Start importing Assets/Res/Editor/UI/UXTool/Tools/UserDatas/FilesRecentlySelectedData.json
Asset Pipeline Refresh (...): Total: 0.208 seconds

Thread 00000000000068E0 may have been prematurely finalized
Thread 00000000000068E0 may have been prematurely finalized
Thread 00000000000068E0 may have been prematurely finalized
Thread 00000000000068E0 may have been prematurely finalized

The prematurely finalized messages line up with the sharp drops in handle count. The finalizer reclaims a batch of objects, but the same creation path is still running.

The key source locations include:

  • RecentSelectRecord.cs: subscribes to selection changes and removes or adds records.
  • RecentFilesSetting.cs: saves JSON immediately after every modification.
  • AssetItemMatFile.cs: requests material previews.
  • AssetItemOthersFile.cs: requests other resource previews.
  • SwitchSetting.json: stores the switch for the “recently selected panel history” feature.

Putting the relevant code together makes the relationship clear:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// RecentSelectRecord.cs
static RecentSelectRecord()
{
Selection.selectionChanged += UpdateRecentFiles;
}

if (recentList.Contains(guid))
{
recentSelected.Remove(guid);
}
if (guid != "" && !recentList.Contains(guid))
{
recentSelected.Add(guid);
}

// RecentFilesSetting.cs
public void Add(string guid)
{
List.Insert(0, guid);
JsonAssetManager.SaveAssets(this);
OnValueChanged();
}

public void Remove(string guid)
{
var index = List.FindIndex(i => i == guid);
if (index >= 0) List.RemoveAt(index);
JsonAssetManager.SaveAssets(this);
OnValueChanged();
}

// JsonAssetManager.cs
string dataAsJson = JsonUtility.ToJson(obj);
File.WriteAllText(path, dataAsJson);

// AssetItemMatFile.cs / AssetItemOthersFile.cs
Texture2D preview = AssetPreview.GetAssetPreview(_assetObj);

UXTool is only the trigger. It refreshes and requests previews frequently, but the underlying defect remains in the Tuanjie engine.

Official Issue Record

Unity’s Issue Tracker contains a similar record: UUM-141036.

Its native call chain is almost identical:

1
2
3
4
5
PreviewTextureManager::LoadingLoop
PreviewTextureManager::GetMostImportantTextureToLoad
ProtectedScopedThreadAttach
scripting_thread_attach
mono_thread_internal_attach

However, Tuanjie is an independent branch, so Unity’s work may no longer apply to it. Whether the target version has actually fixed the issue still needs to be verified in practice. Since upgrading the engine in the middle of a project carries substantial risk, this issue may remain with us for quite a long time.

Mitigation

Since I could not fix the issue itself, I disabled the tool that triggers it. I turned off UXTool’s “recently selected panel history” feature and restarted Tuanjie:

UXTool settings

Verification

Because the issue had been confirmed to involve handles, I focused on handle counts after the restart:

Time Handle count Private memory
15:45:42 5,513 3,629.7 MiB
15:50:57 5,811 3,702.5 MiB
15:55:57 6,100 3,711.8 MiB
16:03:10 6,075 3,719.1 MiB

During startup, the handle count peaked at 7,564 and quickly fell back to 5,500-6,100.

In a 39-second sample, the handle count grew from 5,831 to 5,871, or about 1.03 handles per second.

After about seven minutes, the count fell again from 6,100 to 6,075.

The old process used to grow by hundreds of handles per second and could spike into the hundreds of thousands. It appears that we removed the main trigger. Given that the underlying issue is in Tuanjie itself, a small residual increase is not surprising. Reducing the frequency of crashes counts as a success.

The Handle summary after restarting was back within the normal range:

1
2
3
4
5
6
7
8
9
Handle type summary:
Event : 819
Semaphore : 3225
Thread : 557
Total handles: 5871

Process snapshot:
Threads : 271
PrivateMiB : 3714.8

Summary

To summarize the chain of events:

  • Surface symptom: repeated memory exhaustion, desktop-component crashes, or a complete system freeze.
  • Confirmed defect: PreviewTextureManager repeatedly duplicates thread handles and does not release them promptly.
  • Project-side trigger: UXTool’s “recently selected” feature repeatedly writes JSON, refreshes assets, and requests previews.
  • Temporary mitigation: disable that feature, exit normally, and restart Tuanjie.
  • Verification: handle growth dropped from 380-400 per second to about 1 per second.
    • Thread handles dropped from 92,490 to 557.
    • The original leak signature disappeared.
  • Long-term resolution: wait for or verify a Tuanjie engine fix. The project-side workaround should not be mistaken for the engine defect having disappeared.