Linear ArchiveArchived issues viewer
← Back to list
INS-866

ガチャメモリリーク 実機解析ツール検討260618

StatusDone
TeamInstansys
Assigneeasuki.uehata@instansys.co.jp
PriorityUrgent
Created2026/06/18 05:04
Completed2026/06/22 01:28
Archived2026/06/30 01:05
Bug

Description

INS-864 は、平賀さんが修正加える前の事前準備検討のまとめ、調査のための課題 整理まで

ここからは平賀さんの修正踏まえての解析・メモリリークログ差し込み・ツール調整・対策

Comments (10)

asuki.uehata@instansys.co.jp2026/06/18 05:05

ローカル実機検証用に、apps/client/src/lib/leak-tracker.ts 作成

// INS-862 ガチャ画面メモリリーク調査用の観測フック集。
// import "@/lib/leak-tracker" するだけで window.__leak が生える。
// 解析が終わったら main.tsx の import 1行をコメントアウトすれば全フックが切れる。
//
// 設計方針:
// - 値は呼び出し時に毎回読む(スナップショットは F の snap/trail で別途)
// - 既存 __pixiSound / __pixiAssets / __pixiApp / __imageCache は読むだけ(二重公開しない)
// - setTimeout / addEventListener の上書きパッチは入れない(副作用回避、最終手段として温存)
// - createElement の WeakRef パッチだけは E のオプトインで提供(呼ぶまで副作用なし)
//
// 使い方(Safari console):
//   window.__leak.snap("before-gacha")
//   // …10連ガチャを引く…
//   window.__leak.snap("after-gacha")
//   window.__leak.trail()
//   window.__leak.diff("before-gacha", "after-gacha")
//
//   window.__leak.dom.animating()           // 動いてる DOM 要素数
//   window.__leak.pixi.soundsCount()        // 保持中 AudioBuffer 数
//   window.__leak.caches.imageCacheBytes()  // useImageCache の累計バイト
//
//   window.__leak.enableDetachedDomTracking()
//   // …往復・ガチャを引く…
//   window.__leak.dom.detached()            // { alive, detached, gc }
//
//   // mount/unmount サイクルで GC されているか確認:
//   useEffect(() => {
//     const probe = {};
//     window.__leak?.registerProbe(probe, "char-obtained-overlay-1");
//     return () => { /* 数秒後に [leak/gc] released: ... が出れば OK */ };
//   }, []);

type SnapValue = number | null;
type Snap = {
  ts: number;
  label: string;
  dom_total: SnapValue;
  img: SnapValue;
  canvas: SnapValue;
  anim: SnapValue;
  detached: SnapValue;
  sounds: SnapValue;
  soundsMB: SnapValue;
  assets: SnapValue;
  managedTex: SnapValue;
  imgCache: SnapValue;
  imgCacheMB: SnapValue;
};

const MAX_TRAIL = 200;
const trail: Snap[] = [];

let detachedRefs: Set<WeakRef<Element>> | null = null;
let origCreateElement:
  | (<K extends keyof HTMLElementTagNameMap>(
      tagName: K,
      options?: ElementCreationOptions
    ) => HTMLElementTagNameMap[K])
  | null = null;

const gcRegistry =
  typeof FinalizationRegistry !== "undefined"
    ? new FinalizationRegistry<string>((label) => {
        console.log(`[leak/gc] released: ${label}`);
      })
    : null;

type SoundBuffer = {
  duration: number;
  sampleRate: number;
  numberOfChannels: number;
};
type PixiSound = { _sounds?: Record<string, { media?: { buffer?: SoundBuffer } }> };
type PixiAssets = { cache?: { _cache?: Map<unknown, unknown> } };
type PixiApp = {
  renderer?: { texture?: { managedTextures?: unknown[] } };
};
type ImageCacheAPI = {
  size: () => number;
  sizeBytes: () => number;
};

const w = () => window as unknown as {
  __pixiSound?: PixiSound;
  __pixiAssets?: PixiAssets;
  __pixiApp?: PixiApp | null;
  __imageCache?: ImageCacheAPI;
};

const dom = {
  total: () => document.querySelectorAll("*").length,
  images: () => document.querySelectorAll("img").length,
  canvases: () => document.querySelectorAll("canvas").length,
  animating: () => document.querySelectorAll('[style*="animation"]').length,
  detached: (): { alive: number; detached: number; gc: number } | null => {
    if (!detachedRefs) return null;
    let detached = 0;
    let alive = 0;
    let gc = 0;
    for (const ref of [...detachedRefs]) {
      const el = ref.deref();
      if (!el) {
        gc++;
        detachedRefs.delete(ref);
        continue;
      }
      if (!document.contains(el)) detached++;
      else alive++;
    }
    return { alive, detached, gc };
  },
};

const pixi = {
  soundsCount: (): number | null => {
    const s = w().__pixiSound?._sounds;
    return s ? Object.keys(s).length : null;
  },
  soundsBytes: (): number | null => {
    const s = w().__pixiSound?._sounds;
    if (!s) return null;
    let bytes = 0;
    for (const snd of Object.values(s)) {
      const buf = snd?.media?.buffer;
      if (!buf) continue;
      bytes += buf.duration * buf.sampleRate * buf.numberOfChannels * 4;
    }
    return bytes;
  },
  assetsCacheCount: (): number | null =>
    w().__pixiAssets?.cache?._cache?.size ?? null,
  managedTextures: (): number | null =>
    w().__pixiApp?.renderer?.texture?.managedTextures?.length ?? null,
};

const caches = {
  imageCacheCount: (): number | null => w().__imageCache?.size() ?? null,
  imageCacheBytes: (): number | null => w().__imageCache?.sizeBytes() ?? null,
};

const toMB = (bytes: number | null): number | null =>
  bytes === null ? null : +(bytes / 1024 / 1024).toFixed(2);

const snap = (label: string): Snap => {
  const detachedInfo = dom.detached();
  const s: Snap = {
    ts: Date.now(),
    label,
    dom_total: dom.total(),
    img: dom.images(),
    canvas: dom.canvases(),
    anim: dom.animating(),
    detached: detachedInfo?.detached ?? null,
    sounds: pixi.soundsCount(),
    soundsMB: toMB(pixi.soundsBytes()),
    assets: pixi.assetsCacheCount(),
    managedTex: pixi.managedTextures(),
    imgCache: caches.imageCacheCount(),
    imgCacheMB: toMB(caches.imageCacheBytes()),
  };
  trail.push(s);
  if (trail.length > MAX_TRAIL) trail.shift();
  return s;
};

const showTrail = () => console.table(trail);

const diff = (a: string, b: string) => {
  const A = trail.find((s) => s.label === a);
  const B = trail.find((s) => s.label === b);
  if (!A || !B) {
    console.warn(`[leak] snap not found: ${!A ? a : b}`);
    return;
  }
  const out: Record<string, number> = {};
  for (const k of Object.keys(A) as (keyof Snap)[]) {
    const va = A[k];
    const vb = B[k];
    if (typeof va === "number" && typeof vb === "number") {
      out[k] = vb - va;
    }
  }
  console.table(out);
};

const registerProbe = (obj: object, label: string) => {
  gcRegistry?.register(obj, label);
};

const enableDetachedDomTracking = () => {
  if (detachedRefs) {
    console.warn("[leak] detached-dom tracking already enabled");
    return;
  }
  detachedRefs = new Set();
  origCreateElement = document.createElement.bind(document) as typeof origCreateElement;
  document.createElement = ((tagName: string, options?: ElementCreationOptions) => {
    const el = (origCreateElement as (t: string, o?: ElementCreationOptions) => Element)(
      tagName,
      options
    );
    detachedRefs?.add(new WeakRef(el));
    return el;
  }) as typeof document.createElement;
  console.log("[leak] detached-dom tracking enabled (createElement patched)");
};

const disableDetachedDomTracking = () => {
  if (!origCreateElement) {
    console.warn("[leak] detached-dom tracking not enabled");
    return;
  }
  document.createElement = origCreateElement as typeof document.createElement;
  origCreateElement = null;
  detachedRefs = null;
  console.log("[leak] detached-dom tracking disabled");
};

const api = {
  dom,
  pixi,
  caches,
  snap,
  trail: showTrail,
  diff,
  registerProbe,
  enableDetachedDomTracking,
  disableDetachedDomTracking,
  _trail: () => trail,
};

if (typeof window !== "undefined") {
  (window as unknown as { __leak?: typeof api }).__leak = api;
}

export type LeakTracker = typeof api;
asuki.uehata@instansys.co.jp2026/06/18 05:07

ローカル実機環境 Safari スニペット実行 動作確認OK。 ーーーー 検証

ガチャ画面に遷移 → window.__leak.snap("gacha-top")

10連を引く → window.__leak.snap("after-10")

window.__leak.diff("gacha-top", "after-10") で差分確認

asuki.uehata@instansys.co.jp2026/06/18 05:07
ガチャ画面に遷移 → window.__leak.snap("gacha-top")
10連を引く → window.__leak.snap("after-10")
window.__leak.diff("gacha-top", "after-10") で差分確認

ーーー
ガチャ画面に遷移 → window.__leak.snap("gacha-top")
Object = $2

anim: 45

assets: 6

canvas: 1

detached: null

dom_total: 347

img: 39

imgCache: 0

imgCacheMB: 0

label: "gacha-top"

managedTex: null

sounds: 13

soundsMB: 80.4

ts: 1781758537674

Objectプロトタイプ


10連を引く → window.__leak.snap("after-10")

Object = $3

anim: 241

assets: 6

canvas: 1

detached: null

dom_total: 651

img: 62

imgCache: 8

imgCacheMB: 0.4

label: "after-10"

managedTex: null

sounds: 13

soundsMB: 80.4

ts: 1781758651628

Objectプロトタイプ



window.__leak.diff("gacha-top", "after-10") で差分確認

ts
113954
dom_total
304
anim
196
img
23
imgCache
8
imgCacheMB
0.4
soundsMB
0
sounds
0
canvas
0
assets
0

window.__leak.snap("gacha-end-top") 10連ガチャが終わってガチャTOP画面に戻った時点

Object = $4

anim: 45

assets: 6

canvas: 1

detached: null

dom_total: 347

img: 39

imgCache: 8

imgCacheMB: 0.4

label: "gacha-end-top"

managedTex: null

sounds: 13

soundsMB: 80.4

ts: 1781758718879

Objectプロトタイプ


window.__leak.snap("gacha-end-home") 10連ガチャが終わってホーム画面に戻った時点

Object = $5

anim: 0

assets: 0

canvas: 1

detached: null

dom_total: 239

img: 30

imgCache: 8

imgCacheMB: 0.4

label: "gacha-end-home"

managedTex: null

sounds: 21

soundsMB: 81.75

ts: 1781758778581

Objectプロトタイプ
asuki.uehata@instansys.co.jp2026/06/18 05:08

10連 1回 調査結果

指標gacha-topafter-10gacha-end-topgacha-end-home解釈
dom_total347651347239ピーク+304→完全に解放
anim45241450同上、リークなし ✅
img39623930解放されてる ✅
imgCache08880.4MB誤差レベル、無視可
sounds13131321Home戻りで +8
soundsMB80.480.480.481.75🚨 常時80MB滞留
assets6660Home戻りで解放 ✅
canvas1111全画面で常駐(要調査)
asuki.uehata@instansys.co.jp2026/06/18 05:08

一番目立つ発見:AudioBuffer 80.4MB

13 sounds で平均 6.2MB/sound = 大物が混ざってる可能性大。スマホの実RAM圧として80MBは無視できない。

ガチャ画面の DOM・anim は完璧に解放されているのに対し、AudioBuffer は 一度ロードされたら基本ずっと滞留しています。引き継ぎドキュメントが「未観測の領域」に挙げていた @pixi/sound AudioBuffer 滞留 が、定量化できた

asuki.uehata@instansys.co.jp2026/06/18 05:25
> Object.entries(window.__pixiSound._sounds)
  .map(([k, v]) => {
    const b = v.media?.buffer;
    const mb = b ? +((b.duration * b.sampleRate * b.numberOfChannels * 4) / 1024 / 1024).toFixed(2) : 0;
    return { alias: k, mb, sec: b?.duration?.toFixed(1) ?? null, ch: b?.numberOfChannels ?? null };
  })
  .sort((a, b) => b.mb - a.mb);
< Array (21) = $6
0
{alias: "sounds/bgm/home.m4a", mb: 66.71, sec: "182.2", ch: 2}
1
{alias: "voices/luke/home_5.m4a", mb: 1.36, sec: "7.4", ch: 1}
2
{alias: "voices/luke/home_8.m4a", mb: 1.11, sec: "6.1", ch: 1}
3
{alias: "se/melt-weapon.mp3", mb: 1.1, sec: "3.0", ch: 2}
4
{alias: "voices/luke/home_1.m4a", mb: 1.09, sec: "6.0", ch: 1}
5
{alias: "voices/luke/home_4.m4a", mb: 1.09, sec: "6.0", ch: 1}
6
{alias: "voices/luke/home_2.m4a", mb: 1.05, sec: "5.8", ch: 1}
7
{alias: "voices/luke/home_7.m4a", mb: 1.02, sec: "5.6", ch: 1}
8
{alias: "voices/luke/home_3.m4a", mb: 0.92, sec: "5.0", ch: 1}
9
{alias: "se/charin.mp3", mb: 0.86, sec: "2.4", ch: 2}
10
{alias: "voices/luke/home_6.m4a", mb: 0.85, sec: "4.7", ch: 1}
11
{alias: "se/confetti.m4a", mb: 0.84, sec: "2.3", ch: 2}
12
{alias: "se/start.mp3", mb: 0.73, sec: "2.0", ch: 2}
13
{alias: "se/fanfare.mp3", mb: 0.71, sec: "1.9", ch: 2}
14
{alias: "se/blessing-levelup.mp3", mb: 0.69, sec: "1.9", ch: 2}
15
{alias: "se/chapter-unlock.m4a", mb: 0.67, sec: "1.8", ch: 2}
16
{alias: "se/upgrade-fail.mp3", mb: 0.37, sec: "1.0", ch: 2}
17
{alias: "voices/freya/call_title.m4a", mb: 0.31, sec: "1.7", ch: 1}
18
{alias: "voices/alfred/call_instansys.m4a", mb: 0.18, sec: "1.0", ch: 1}
19
{alias: "se/cancel.m4a", mb: 0.05, sec: "0.1", ch: 2}
20
{alias: "se/click.m4a", mb: 0.05, sec: "0.1", ch: 2}

Arrayプロトタイプ

ホーム画面で、66MB

これは、ガチャ画面でも同じ要因の問題を孕むことになる 本来 3MB の m4aファイルだが、

3MB → 66.7MB の 22倍膨張は WebAudio API の AudioBuffer 仕様そのもの

asuki.uehata@instansys.co.jp2026/06/18 05:25

ディスク上: home.m4a = 3MB(AAC圧縮)

↓ @pixi/sound が WebAudio API で decodeAudioData

RAM上: AudioBuffer = 66.7MB(非圧縮 float32 PCM)

= 182.2秒 × 44.1kHz × 2ch × 4byte

asuki.uehata@instansys.co.jp2026/06/18 05:25

これは @pixi/sound のデフォルト挙動(WebAudioMedia)。一旦 decode したら GC されるまで RAM に居座るstop() してもメモリは解放されません。

asuki.uehata@instansys.co.jp2026/06/18 05:27

ガチャでも同じ問題を抱えたままとなる

累積系障害との関連(累積はなかったので関連なさそうだが)

  • home.m4a が 66.7MB 滞留
  • 同じノリで他の BGM(megami-sama.m4a, battle01.m4a 等)が再生されると それぞれ数十MB ずつ追加
  • ファイル一覧見ると BGM 全10本。全部 decode したら 100MB級になる可能性
  • iOS WKWebView の WebAudio process は数百MB 上限

ではあるが、 BGMが非圧縮で重いとはいえ、累積はしていなかった。