🔧

Pinia + SignalRで学んだレースコンディション対策 - メモリリークとTOCTOU問題を解決した話

に公開

はじめに

Vue 3 + Pinia + SignalR構成のプロジェクトで、PiniaストアをJavaScriptからTypeScriptに移行した際に、CodeRabbitのレビューで複数のレースコンディションとメモリリーク問題が見つかりました。この記事では、実際に遭遇した4つの問題とその解決方法を、初心者目線で整理します。

前提条件:

  • Vue 3 (Composition API)
  • Pinia (setup store pattern)
  • TypeScript
  • SignalR (@microsoft/signalr)

技術スタック:

  • Vue 3.5
  • Pinia 2.3
  • TypeScript 5.7
  • Vite 6.0

扱うコードの構造

今回移行したPiniaストア(usersストア)の主要な機能:

export const useUsersStore = defineStore('users', () => {
  const users: Ref<User[]> = ref([])
  const loading: Ref<boolean> = ref(false)
  const error: Ref<string | null> = ref(null)

  // CRUD operations
  async function loadUsers(): Promise<void>
  async function addUser(userData: Partial<User>): Promise<User>
  async function modifyUser(id: string, userData: Partial<User>): Promise<User>
  async function removeUser(id: string): Promise<void>

  // SignalR real-time updates
  async function initializeSignalR(): Promise<boolean>
  function cleanup(): void

  return { users, loading, error, loadUsers, addUser, modifyUser, removeUser, cleanup }
})

遭遇した4つの問題

1. SignalRハンドラのメモリリーク - 登録解除関数を呼んでいない

問題の状況:
usersストアでは、SignalRの更新・削除イベントを購読しています。しかし、ストアをクリーンアップする際に、イベントハンドラを登録解除していませんでした。

元のコード:

async function initializeSignalR(): Promise<boolean> {
  await connectSignalR()

  // イベントハンドラを登録(登録解除関数を受け取っているが保存していない)
  onUserUpdated((updatedUser) => {
    const index = users.value.findIndex(u => u.id === updatedUser.id)
    if (index !== -1) {
      users.value[index] = updatedUser
    } else {
      users.value.push(updatedUser)
    }
  })

  onUserDeleted((deletedUser) => {
    users.value = users.value.filter(u => u.id !== deletedUser.id)
  })
}

function cleanup(): void {
  // SignalR切断はしているが、ハンドラは残ったまま
  disconnectSignalR()
  users.value = []
}

何が問題?:
onUserUpdated()onUserDeleted() は登録解除関数(cleanup関数)を返しますが、それを保存していません。ストアを何度もマウント/アンマウントすると、イベントハンドラが増え続けてメモリリークします。

修正後:

// クリーンアップ関数を保存する変数
let unregisterUpdateHandler: (() => void) | null = null
let unregisterDeleteHandler: (() => void) | null = null

async function initializeSignalR(): Promise<boolean> {
  await connectSignalR()

  // 古いハンドラがあれば先に解除
  if (unregisterUpdateHandler) {
    unregisterUpdateHandler()
    unregisterUpdateHandler = null
  }
  if (unregisterDeleteHandler) {
    unregisterDeleteHandler()
    unregisterDeleteHandler = null
  }

  // 登録解除関数を保存
  unregisterUpdateHandler = onUserUpdated((updatedUser) => {
    const index = users.value.findIndex(u => u.id === updatedUser.id)
    if (index !== -1) {
      users.value[index] = updatedUser
    } else {
      users.value.push(updatedUser)
    }
  })

  unregisterDeleteHandler = onUserDeleted((deletedUser) => {
    users.value = users.value.filter(u => u.id !== deletedUser.id)
  })
}

function cleanup(): void {
  // クリーンアップ時にハンドラを登録解除
  if (unregisterUpdateHandler) {
    unregisterUpdateHandler()
    unregisterUpdateHandler = null
  }
  if (unregisterDeleteHandler) {
    unregisterDeleteHandler()
    unregisterDeleteHandler = null
  }

  disconnectSignalR()
  users.value = []
}

学んだこと:

  • イベント登録関数が返すクリーンアップ関数は必ず保存して呼び出す
  • 複数のハンドラがある場合は、それぞれの登録解除関数を管理する
  • 再初期化時には古いハンドラを先に解除する(二重登録防止)

2. AbortControllerのレースコンディション - 共有変数の更新タイミング

問題の状況:
loadUsers() が連続して呼ばれた時、abortController の参照が変わる前に cleanup が実行され、間違ったリクエストを中断してしまう可能性がありました。

元のコード:

let abortController: AbortController | null = null

async function loadUsers(): Promise<void> {
  if (abortController) {
    abortController.abort() // 前回のリクエストをキャンセル
  }

  abortController = new AbortController()
  loading.value = true

  try {
    users.value = await fetchUsers(abortController.signal)
  } finally {
    // 問題: 他の loadUsers() が abortController を上書きしている可能性
    loading.value = false
    abortController = null
  }
}

何が問題?:
以下のようなシナリオでレースコンディションが発生します:

  1. 1回目の loadUsers() が呼ばれる → abortController = A
  2. 2回目の loadUsers() が呼ばれる → Aをabort → abortController = B
  3. 1回目の finally が実行 → abortController を null にする(Bが消える!)
  4. 2回目のリクエストが中断できなくなる

修正後:

let abortController: AbortController | null = null

async function loadUsers(): Promise<void> {
  if (abortController) {
    abortController.abort()
  }

  // ローカル変数で現在のコントローラーを保持
  const currentController = new AbortController()
  abortController = currentController

  loading.value = true

  try {
    users.value = await fetchUsers(currentController.signal)
  } finally {
    // 自分のコントローラーがまだアクティブな場合のみクリーンアップ
    if (abortController === currentController) {
      loading.value = false
      abortController = null
    }
  }
}

学んだこと:

  • 非同期操作で共有変数を使う場合、ローカル参照を保持する
  • cleanup時に「このリクエストはまだアクティブか?」をチェックする
  • 同一性チェック(===)でレースコンディションを防ぐ

3. TOCTOU(Time-of-Check-Time-of-Use)レースコンディション

問題の状況:
SignalR初期化のフラグチェックと設定の間に時間差があり、複数の loadUsers() が同時に呼ばれると、どちらもチェックを通過して初期化が二重に実行される可能性がありました。

元のコード:

let signalRInitialized = false
let signalRInitializing = false

async function loadUsers(): Promise<void> {
  // ...ユーザーデータ取得...

  // 問題: チェックと設定の間に時間差がある
  if (!signalRInitialized && !signalRInitializing) {
    // ← ここで他の loadUsers() が割り込む可能性
    await initializeSignalR() // この中で signalRInitializing を設定
  }
}

async function initializeSignalR(): Promise<boolean> {
  signalRInitializing = true // ← 遅すぎる
  // ...
}

何が問題?:
TOCTOU(Time-of-Check-Time-of-Use)と呼ばれる典型的なレースコンディション:

  1. 1回目の loadUsers() がチェックを通過
  2. 1回目が initializeSignalR() を呼ぶ前に
  3. 2回目の loadUsers() がチェックを通過(まだフラグが立っていない)
  4. SignalRが二重に初期化される

修正後:

async function loadUsers(): Promise<void> {
  // ...ユーザーデータ取得...

  // チェックと設定を原子的に実行
  if (!signalRInitialized && !signalRInitializing) {
    signalRInitializing = true // 即座に設定
    try {
      await initializeSignalR()
    } finally {
      signalRInitializing = false // 必ずリセット
    }
  }
}

async function initializeSignalR(): Promise<boolean> {
  // フラグ管理は呼び出し側に任せる
  if (signalRInitialized) {
    return true
  }

  await connectSignalR()
  // ...ハンドラ登録...

  signalRInitialized = true
  return true
}

学んだこと:

  • チェック直後にフラグを設定する(TOCTOUを防ぐ)
  • try/finally でフラグのクリーンアップを保証
  • フラグ管理の責任を明確にする(呼び出し側 vs 関数内部)

4. cleanup()でフラグをリセットしていない

問題の状況:
cleanup() 関数で signalRInitializing フラグをリセットしていなかったため、cleanup後に再度初期化しようとしてもブロックされてしまいました。

元のコード:

function cleanup(): void {
  if (unregisterUpdateHandler) {
    unregisterUpdateHandler()
    unregisterUpdateHandler = null
  }
  if (unregisterDeleteHandler) {
    unregisterDeleteHandler()
    unregisterDeleteHandler = null
  }

  disconnectSignalR()
  signalRInitialized = false
  // signalRInitializing がリセットされていない!

  users.value = []
  loading.value = false
  error.value = null
}

何が問題?:
cleanup() を呼んだ後、再度 loadUsers() を実行すると:

if (!signalRInitialized && !signalRInitializing) {
  // signalRInitializing が true のまま → この条件が false
  // SignalRが再初期化されない
}

修正後:

function cleanup(): void {
  if (unregisterUpdateHandler) {
    unregisterUpdateHandler()
    unregisterUpdateHandler = null
  }
  if (unregisterDeleteHandler) {
    unregisterDeleteHandler()
    unregisterDeleteHandler = null
  }

  disconnectSignalR()
  signalRInitialized = false
  signalRInitializing = false // リセットを追加

  users.value = []
  loading.value = false
  error.value = null
}

学んだこと:

  • cleanup関数ですべての状態をリセットする
  • フラグ変数は「初期状態」を意識する
  • テストケースで「cleanup → 再利用」のシナリオを確認する

まとめ

Pinia + SignalR環境で遭遇した4つのレースコンディション問題:

  1. SignalRハンドラのメモリリーク → 登録解除関数を保存して呼び出す
  2. AbortControllerのレース → ローカル参照で同一性をチェック
  3. TOCTOUレース → チェック直後にフラグを設定
  4. cleanupでのフラグリセット漏れ → すべての状態を初期化

学んだ教訓:

  • 非同期処理では「時間差」を意識する
  • 共有変数よりローカル参照を使う
  • チェックと設定は可能な限り原子的に
  • cleanup関数ですべての状態をリセット

これらの問題はTypeScriptの型チェックだけでは見つかりませんでしたが、CodeRabbitのようなAIレビューツールを活用することで早期に発見できました。


参考リンク

Discussion