All files / src/perf busy.ts

100% Statements 39/39
93.75% Branches 15/16
100% Functions 4/4
100% Lines 39/39

Press n or j to go to the next uncovered block, b, p or k for the previous block.

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 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137                                                                              2x                 2x                                   2x                 2x                                   2x 78x 78x 78x 78x 78x   78x 155x 155x 155x       155x 1x 155x 78x   78x 78x       89x 26x 26x 30x 235x 235x 235x 30x 26x 89x 78x 1x 1x 1x 78x 64x 64x 78x 78x  
/**
 * How busy a thread is, measured from inside it.
 *
 * There is no browser API for the CPU a tab is using. `performance` reports
 * timings, `navigator.hardwareConcurrency` reports how many cores exist, and
 * nothing at all reports how much of them this page is burning — deliberately,
 * because a per-process CPU figure is a cross-origin side channel. So the number
 * has to be inferred, and the only honest way to infer it from inside a thread
 * is to ask that thread to do something trivial on a schedule and see how late
 * it is.
 *
 * A timer set for every `period` ms that fires 40 ms late spent those 40 ms
 * waiting behind something else on the same thread — one task, or several. Over
 * a window, the lateness sums to the time the thread was not available, and the
 * ratio of that to the window is the fraction of wall-clock the thread spent
 * working. That is what a task manager calls CPU for a single-threaded process,
 * and it is what this returns.
 *
 * What it is not: it cannot see work that finishes inside one period without
 * ever delaying the probe (a 2 ms task every 100 ms is invisible, and rightly —
 * it is 2% of nothing), and it cannot tell a thread that is *computing* from one
 * that is blocked on a synchronous read. For a viewer whose expensive moments
 * are all long ones — a whole-genome scan, a matrix decode, a full repaint —
 * both of those are the right trade.
 *
 * Runs unchanged on the main thread and in a worker: `setInterval` and
 * `performance.now` are the whole of its dependency list, and both exist in
 * every global this application has.
 */
 
/**
 * How often the probe asks to run.
 *
 * Short enough that a repaint of a few tens of milliseconds registers, long
 * enough that the probe is not itself the load: five wake-ups a second, each
 * doing an arithmetic mean over a bounded array, is beneath measurement. A 16 ms
 * period would resolve single frames and would also mean sixty wake-ups a second
 * in every thread, forever, on a laptop nobody is looking at.
 */
export const PROBE_PERIOD_MS = 200
 
/**
 * How much history one reading covers.
 *
 * Long enough to survive a single slow frame without the number jumping to 100%,
 * short enough that letting go of a pan is visibly the end of the load. Three
 * seconds is about fifteen samples.
 */
export const BUSY_WINDOW_MS = 3000
 
/**
 * A sample that is longer than this did not measure a busy thread.
 *
 * It measured a *suspended* one: a background tab has its timers clamped to once
 * a minute, and a machine coming back from sleep produces one gap of arbitrary
 * size. Both would otherwise read as a thread pinned for the whole interval,
 * which is the opposite of what happened.
 *
 * Thirty seconds, and the number matters. The obvious threshold — a second or
 * two, "longer than any task this application runs" — is wrong in the direction
 * that hurts: a whole-genome scan really does hold a worker for several seconds
 * at a stretch, and discarding those samples reported 0% during precisely the
 * operation the reader is watching. A throttled tab's gap is a minute by
 * construction and a sleep is longer again, so the two populations are an order
 * of magnitude apart and the line goes between them rather than under both.
 */
const SUSPENSION_MS = 30_000
 
/**
 * How often a worker tells the main thread what its probe says.
 *
 * A second, because that is the rate the status bar repaints at and a faster
 * report would be a message nobody reads. The reading itself is a three-second
 * average, so nothing is lost between reports.
 */
export const LOAD_REPORT_MS = 1000
 
export interface BusyProbe {
  /** The fraction of the last window this thread spent working, 0 to 1. */
  busy(): number
  /** Throw the history away — after a suspension the old samples describe nothing. */
  reset(): void
  /** Stop probing. */
  stop(): void
}
 
interface Sample {
  at: number
  span: number
  late: number
}
 
/** Start measuring the current thread. */
export function startBusyProbe(
  period: number = PROBE_PERIOD_MS,
  window: number = BUSY_WINDOW_MS,
): BusyProbe {
  const samples: Sample[] = []
  let last = performance.now()
 
  const timer = setInterval(() => {
    const at = performance.now()
    const span = at - last
    last = at
    // A timer implementation that catches up after a delay produces a *short*
    // span next, not a negative one; `Math.max` is against clock adjustments
    // rather than against that.
    if (span < SUSPENSION_MS) samples.push({ at, span, late: Math.max(0, span - period) })
    else samples.length = 0
    while (samples.length > 0 && at - samples[0].at > window) samples.shift()
  }, period)
 
  return {
    busy(): number {
      // One sample is a coin toss — a single 40 ms task lands as 20% or as 0%
      // depending on where the window fell. Below three, say zero rather than
      // publish the toss.
      if (samples.length < 3) return 0
      let span = 0
      let late = 0
      for (const sample of samples) {
        span += sample.span
        late += sample.late
      }
      if (span <= 0) return 0
      return Math.min(1, late / span)
    },
    reset(): void {
      samples.length = 0
      last = performance.now()
    },
    stop(): void {
      clearInterval(timer)
    },
  }
}