/ src / utils / sessionActivity.ts
sessionActivity.ts
  1  /**
  2   * Session activity tracking with refcount-based heartbeat timer.
  3   *
  4   * The transport registers its keep-alive sender via registerSessionActivityCallback().
  5   * Callers (API streaming, tool execution) bracket their work with
  6   * startSessionActivity() / stopSessionActivity(). When the refcount is >0 a
  7   * periodic timer fires the registered callback every 30 seconds to keep the
  8   * container alive.
  9   *
 10   * Sending keep-alives is gated behind CLAUDE_CODE_REMOTE_SEND_KEEPALIVES.
 11   * Diagnostic logging always fires to help diagnose idle gaps.
 12   */
 13  
 14  import { registerCleanup } from './cleanupRegistry.js'
 15  import { logForDiagnosticsNoPII } from './diagLogs.js'
 16  import { isEnvTruthy } from './envUtils.js'
 17  
 18  const SESSION_ACTIVITY_INTERVAL_MS = 30_000
 19  
 20  export type SessionActivityReason = 'api_call' | 'tool_exec'
 21  
 22  let activityCallback: (() => void) | null = null
 23  let refcount = 0
 24  const activeReasons = new Map<SessionActivityReason, number>()
 25  let oldestActivityStartedAt: number | null = null
 26  let heartbeatTimer: ReturnType<typeof setInterval> | null = null
 27  let idleTimer: ReturnType<typeof setTimeout> | null = null
 28  let cleanupRegistered = false
 29  
 30  function startHeartbeatTimer(): void {
 31    clearIdleTimer()
 32    heartbeatTimer = setInterval(() => {
 33      logForDiagnosticsNoPII('debug', 'session_keepalive_heartbeat', {
 34        refcount,
 35      })
 36      if (isEnvTruthy(process.env.CLAUDE_CODE_REMOTE_SEND_KEEPALIVES)) {
 37        activityCallback?.()
 38      }
 39    }, SESSION_ACTIVITY_INTERVAL_MS)
 40  }
 41  
 42  function startIdleTimer(): void {
 43    clearIdleTimer()
 44    if (activityCallback === null) {
 45      return
 46    }
 47    idleTimer = setTimeout(() => {
 48      logForDiagnosticsNoPII('info', 'session_idle_30s')
 49      idleTimer = null
 50    }, SESSION_ACTIVITY_INTERVAL_MS)
 51  }
 52  
 53  function clearIdleTimer(): void {
 54    if (idleTimer !== null) {
 55      clearTimeout(idleTimer)
 56      idleTimer = null
 57    }
 58  }
 59  
 60  export function registerSessionActivityCallback(cb: () => void): void {
 61    activityCallback = cb
 62    // Restart timer if work is already in progress (e.g. reconnect during streaming)
 63    if (refcount > 0 && heartbeatTimer === null) {
 64      startHeartbeatTimer()
 65    }
 66  }
 67  
 68  export function unregisterSessionActivityCallback(): void {
 69    activityCallback = null
 70    // Stop timer if the callback is removed
 71    if (heartbeatTimer !== null) {
 72      clearInterval(heartbeatTimer)
 73      heartbeatTimer = null
 74    }
 75    clearIdleTimer()
 76  }
 77  
 78  export function sendSessionActivitySignal(): void {
 79    if (isEnvTruthy(process.env.CLAUDE_CODE_REMOTE_SEND_KEEPALIVES)) {
 80      activityCallback?.()
 81    }
 82  }
 83  
 84  export function isSessionActivityTrackingActive(): boolean {
 85    return activityCallback !== null
 86  }
 87  
 88  /**
 89   * Increment the activity refcount. When it transitions from 0→1 and a callback
 90   * is registered, start a periodic heartbeat timer.
 91   */
 92  export function startSessionActivity(reason: SessionActivityReason): void {
 93    refcount++
 94    activeReasons.set(reason, (activeReasons.get(reason) ?? 0) + 1)
 95    if (refcount === 1) {
 96      oldestActivityStartedAt = Date.now()
 97      if (activityCallback !== null && heartbeatTimer === null) {
 98        startHeartbeatTimer()
 99      }
100    }
101    if (!cleanupRegistered) {
102      cleanupRegistered = true
103      registerCleanup(async () => {
104        logForDiagnosticsNoPII('info', 'session_activity_at_shutdown', {
105          refcount,
106          active: Object.fromEntries(activeReasons),
107          // Only meaningful while work is in-flight; stale otherwise.
108          oldest_activity_ms:
109            refcount > 0 && oldestActivityStartedAt !== null
110              ? Date.now() - oldestActivityStartedAt
111              : null,
112        })
113      })
114    }
115  }
116  
117  /**
118   * Decrement the activity refcount. When it reaches 0, stop the heartbeat timer
119   * and start an idle timer that logs after 30s of inactivity.
120   */
121  export function stopSessionActivity(reason: SessionActivityReason): void {
122    if (refcount > 0) {
123      refcount--
124    }
125    const n = (activeReasons.get(reason) ?? 0) - 1
126    if (n > 0) activeReasons.set(reason, n)
127    else activeReasons.delete(reason)
128    if (refcount === 0 && heartbeatTimer !== null) {
129      clearInterval(heartbeatTimer)
130      heartbeatTimer = null
131      startIdleTimer()
132    }
133  }