1#!/usr/bin/env python
2#
3# Copyright 2008 the V8 project authors. All rights reserved.
4# Redistribution and use in source and binary forms, with or without
5# modification, are permitted provided that the following conditions are
6# met:
7#
8#     * Redistributions of source code must retain the above copyright
9#       notice, this list of conditions and the following disclaimer.
10#     * Redistributions in binary form must reproduce the above
11#       copyright notice, this list of conditions and the following
12#       disclaimer in the documentation and/or other materials provided
13#       with the distribution.
14#     * Neither the name of Google Inc. nor the names of its
15#       contributors may be used to endorse or promote products derived
16#       from this software without specific prior written permission.
17#
18# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
21# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
22# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
24# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
25# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
26# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29
30
31"""A cross-platform execution counter viewer.
32
33The stats viewer reads counters from a binary file and displays them
34in a window, re-reading and re-displaying with regular intervals.
35"""
36
37# for py2/py3 compatibility
38from __future__ import print_function
39
40import mmap
41import optparse
42import os
43import re
44import struct
45import sys
46import time
47import Tkinter
48
49
50# The interval, in milliseconds, between ui updates
51UPDATE_INTERVAL_MS = 100
52
53
54# Mapping from counter prefix to the formatting to be used for the counter
55COUNTER_LABELS = {"t": "%i ms.", "c": "%i"}
56
57
58# The magic numbers used to check if a file is not a counters file
59COUNTERS_FILE_MAGIC_NUMBER = 0xDEADFACE
60CHROME_COUNTERS_FILE_MAGIC_NUMBER = 0x13131313
61
62
63class StatsViewer(object):
64  """The main class that keeps the data used by the stats viewer."""
65
66  def __init__(self, data_name, name_filter):
67    """Creates a new instance.
68
69    Args:
70      data_name: the name of the file containing the counters.
71      name_filter: The regexp filter to apply to counter names.
72    """
73    self.data_name = data_name
74    self.name_filter = name_filter
75
76    # The handle created by mmap.mmap to the counters file.  We need
77    # this to clean it up on exit.
78    self.shared_mmap = None
79
80    # A mapping from counter names to the ui element that displays
81    # them
82    self.ui_counters = {}
83
84    # The counter collection used to access the counters file
85    self.data = None
86
87    # The Tkinter root window object
88    self.root = None
89
90  def Run(self):
91    """The main entry-point to running the stats viewer."""
92    try:
93      self.data = self.MountSharedData()
94      # OpenWindow blocks until the main window is closed
95      self.OpenWindow()
96    finally:
97      self.CleanUp()
98
99  def MountSharedData(self):
100    """Mount the binary counters file as a memory-mapped file.  If
101    something goes wrong print an informative message and exit the
102    program."""
103    if not os.path.exists(self.data_name):
104      maps_name = "/proc/%s/maps" % self.data_name
105      if not os.path.exists(maps_name):
106        print("\"%s\" is neither a counter file nor a PID." % self.data_name)
107        sys.exit(1)
108      maps_file = open(maps_name, "r")
109      try:
110        self.data_name = None
111        for m in re.finditer(r"/dev/shm/\S*", maps_file.read()):
112          if os.path.exists(m.group(0)):
113            self.data_name = m.group(0)
114            break
115        if self.data_name is None:
116          print("Can't find counter file in maps for PID %s." % self.data_name)
117          sys.exit(1)
118      finally:
119        maps_file.close()
120    data_file = open(self.data_name, "r")
121    size = os.fstat(data_file.fileno()).st_size
122    fileno = data_file.fileno()
123    self.shared_mmap = mmap.mmap(fileno, size, access=mmap.ACCESS_READ)
124    data_access = SharedDataAccess(self.shared_mmap)
125    if data_access.IntAt(0) == COUNTERS_FILE_MAGIC_NUMBER:
126      return CounterCollection(data_access)
127    elif data_access.IntAt(0) == CHROME_COUNTERS_FILE_MAGIC_NUMBER:
128      return ChromeCounterCollection(data_access)
129    print("File %s is not stats data." % self.data_name)
130    sys.exit(1)
131
132  def CleanUp(self):
133    """Cleans up the memory mapped file if necessary."""
134    if self.shared_mmap:
135      self.shared_mmap.close()
136
137  def UpdateCounters(self):
138    """Read the contents of the memory-mapped file and update the ui if
139    necessary.  If the same counters are present in the file as before
140    we just update the existing labels.  If any counters have been added
141    or removed we scrap the existing ui and draw a new one.
142    """
143    changed = False
144    counters_in_use = self.data.CountersInUse()
145    if counters_in_use != len(self.ui_counters):
146      self.RefreshCounters()
147      changed = True
148    else:
149      for i in range(self.data.CountersInUse()):
150        counter = self.data.Counter(i)
151        name = counter.Name()
152        if name in self.ui_counters:
153          value = counter.Value()
154          ui_counter = self.ui_counters[name]
155          counter_changed = ui_counter.Set(value)
156          changed = (changed or counter_changed)
157        else:
158          self.RefreshCounters()
159          changed = True
160          break
161    if changed:
162      # The title of the window shows the last time the file was
163      # changed.
164      self.UpdateTime()
165    self.ScheduleUpdate()
166
167  def UpdateTime(self):
168    """Update the title of the window with the current time."""
169    self.root.title("Stats Viewer [updated %s]" % time.strftime("%H:%M:%S"))
170
171  def ScheduleUpdate(self):
172    """Schedules the next ui update."""
173    self.root.after(UPDATE_INTERVAL_MS, lambda: self.UpdateCounters())
174
175  def RefreshCounters(self):
176    """Tear down and rebuild the controls in the main window."""
177    counters = self.ComputeCounters()
178    self.RebuildMainWindow(counters)
179
180  def ComputeCounters(self):
181    """Group the counters by the suffix of their name.
182
183    Since the same code-level counter (for instance "X") can result in
184    several variables in the binary counters file that differ only by a
185    two-character prefix (for instance "c:X" and "t:X") counters are
186    grouped by suffix and then displayed with custom formatting
187    depending on their prefix.
188
189    Returns:
190      A mapping from suffixes to a list of counters with that suffix,
191      sorted by prefix.
192    """
193    names = {}
194    for i in range(self.data.CountersInUse()):
195      counter = self.data.Counter(i)
196      name = counter.Name()
197      names[name] = counter
198
199    # By sorting the keys we ensure that the prefixes always come in the
200    # same order ("c:" before "t:") which looks more consistent in the
201    # ui.
202    sorted_keys = names.keys()
203    sorted_keys.sort()
204
205    # Group together the names whose suffix after a ':' are the same.
206    groups = {}
207    for name in sorted_keys:
208      counter = names[name]
209      if ":" in name:
210        name = name[name.find(":")+1:]
211      if not name in groups:
212        groups[name] = []
213      groups[name].append(counter)
214
215    return groups
216
217  def RebuildMainWindow(self, groups):
218    """Tear down and rebuild the main window.
219
220    Args:
221      groups: the groups of counters to display
222    """
223    # Remove elements in the current ui
224    self.ui_counters.clear()
225    for child in self.root.children.values():
226      child.destroy()
227
228    # Build new ui
229    index = 0
230    sorted_groups = groups.keys()
231    sorted_groups.sort()
232    for counter_name in sorted_groups:
233      counter_objs = groups[counter_name]
234      if self.name_filter.match(counter_name):
235        name = Tkinter.Label(self.root, width=50, anchor=Tkinter.W,
236                             text=counter_name)
237        name.grid(row=index, column=0, padx=1, pady=1)
238      count = len(counter_objs)
239      for i in range(count):
240        counter = counter_objs[i]
241        name = counter.Name()
242        var = Tkinter.StringVar()
243        if self.name_filter.match(name):
244          value = Tkinter.Label(self.root, width=15, anchor=Tkinter.W,
245                                textvariable=var)
246          value.grid(row=index, column=(1 + i), padx=1, pady=1)
247
248        # If we know how to interpret the prefix of this counter then
249        # add an appropriate formatting to the variable
250        if (":" in name) and (name[0] in COUNTER_LABELS):
251          format = COUNTER_LABELS[name[0]]
252        else:
253          format = "%i"
254        ui_counter = UiCounter(var, format)
255        self.ui_counters[name] = ui_counter
256        ui_counter.Set(counter.Value())
257      index += 1
258    self.root.update()
259
260  def OpenWindow(self):
261    """Create and display the root window."""
262    self.root = Tkinter.Tk()
263
264    # Tkinter is no good at resizing so we disable it
265    self.root.resizable(width=False, height=False)
266    self.RefreshCounters()
267    self.ScheduleUpdate()
268    self.root.mainloop()
269
270
271class UiCounter(object):
272  """A counter in the ui."""
273
274  def __init__(self, var, format):
275    """Creates a new ui counter.
276
277    Args:
278      var: the Tkinter string variable for updating the ui
279      format: the format string used to format this counter
280    """
281    self.var = var
282    self.format = format
283    self.last_value = None
284
285  def Set(self, value):
286    """Updates the ui for this counter.
287
288    Args:
289      value: The value to display
290
291    Returns:
292      True if the value had changed, otherwise False.  The first call
293      always returns True.
294    """
295    if value == self.last_value:
296      return False
297    else:
298      self.last_value = value
299      self.var.set(self.format % value)
300      return True
301
302
303class SharedDataAccess(object):
304  """A utility class for reading data from the memory-mapped binary
305  counters file."""
306
307  def __init__(self, data):
308    """Create a new instance.
309
310    Args:
311      data: A handle to the memory-mapped file, as returned by mmap.mmap.
312    """
313    self.data = data
314
315  def ByteAt(self, index):
316    """Return the (unsigned) byte at the specified byte index."""
317    return ord(self.CharAt(index))
318
319  def IntAt(self, index):
320    """Return the little-endian 32-byte int at the specified byte index."""
321    word_str = self.data[index:index+4]
322    result, = struct.unpack("I", word_str)
323    return result
324
325  def CharAt(self, index):
326    """Return the ascii character at the specified byte index."""
327    return self.data[index]
328
329
330class Counter(object):
331  """A pointer to a single counter within a binary counters file."""
332
333  def __init__(self, data, offset):
334    """Create a new instance.
335
336    Args:
337      data: the shared data access object containing the counter
338      offset: the byte offset of the start of this counter
339    """
340    self.data = data
341    self.offset = offset
342
343  def Value(self):
344    """Return the integer value of this counter."""
345    return self.data.IntAt(self.offset)
346
347  def Name(self):
348    """Return the ascii name of this counter."""
349    result = ""
350    index = self.offset + 4
351    current = self.data.ByteAt(index)
352    while current:
353      result += chr(current)
354      index += 1
355      current = self.data.ByteAt(index)
356    return result
357
358
359class CounterCollection(object):
360  """An overlay over a counters file that provides access to the
361  individual counters contained in the file."""
362
363  def __init__(self, data):
364    """Create a new instance.
365
366    Args:
367      data: the shared data access object
368    """
369    self.data = data
370    self.max_counters = data.IntAt(4)
371    self.max_name_size = data.IntAt(8)
372
373  def CountersInUse(self):
374    """Return the number of counters in active use."""
375    return self.data.IntAt(12)
376
377  def Counter(self, index):
378    """Return the index'th counter."""
379    return Counter(self.data, 16 + index * self.CounterSize())
380
381  def CounterSize(self):
382    """Return the size of a single counter."""
383    return 4 + self.max_name_size
384
385
386class ChromeCounter(object):
387  """A pointer to a single counter within a binary counters file."""
388
389  def __init__(self, data, name_offset, value_offset):
390    """Create a new instance.
391
392    Args:
393      data: the shared data access object containing the counter
394      name_offset: the byte offset of the start of this counter's name
395      value_offset: the byte offset of the start of this counter's value
396    """
397    self.data = data
398    self.name_offset = name_offset
399    self.value_offset = value_offset
400
401  def Value(self):
402    """Return the integer value of this counter."""
403    return self.data.IntAt(self.value_offset)
404
405  def Name(self):
406    """Return the ascii name of this counter."""
407    result = ""
408    index = self.name_offset
409    current = self.data.ByteAt(index)
410    while current:
411      result += chr(current)
412      index += 1
413      current = self.data.ByteAt(index)
414    return result
415
416
417class ChromeCounterCollection(object):
418  """An overlay over a counters file that provides access to the
419  individual counters contained in the file."""
420
421  _HEADER_SIZE = 4 * 4
422  _COUNTER_NAME_SIZE = 64
423  _THREAD_NAME_SIZE = 32
424
425  def __init__(self, data):
426    """Create a new instance.
427
428    Args:
429      data: the shared data access object
430    """
431    self.data = data
432    self.max_counters = data.IntAt(8)
433    self.max_threads = data.IntAt(12)
434    self.counter_names_offset = \
435        self._HEADER_SIZE + self.max_threads * (self._THREAD_NAME_SIZE + 2 * 4)
436    self.counter_values_offset = \
437        self.counter_names_offset + self.max_counters * self._COUNTER_NAME_SIZE
438
439  def CountersInUse(self):
440    """Return the number of counters in active use."""
441    for i in range(self.max_counters):
442      name_offset = self.counter_names_offset + i * self._COUNTER_NAME_SIZE
443      if self.data.ByteAt(name_offset) == 0:
444        return i
445    return self.max_counters
446
447  def Counter(self, i):
448    """Return the i'th counter."""
449    name_offset = self.counter_names_offset + i * self._COUNTER_NAME_SIZE
450    value_offset = self.counter_values_offset + i * self.max_threads * 4
451    return ChromeCounter(self.data, name_offset, value_offset)
452
453
454def Main(data_file, name_filter):
455  """Run the stats counter.
456
457  Args:
458    data_file: The counters file to monitor.
459    name_filter: The regexp filter to apply to counter names.
460  """
461  StatsViewer(data_file, name_filter).Run()
462
463
464if __name__ == "__main__":
465  parser = optparse.OptionParser("usage: %prog [--filter=re] "
466                                 "<stats data>|<test_shell pid>")
467  parser.add_option("--filter",
468                    default=".*",
469                    help=("regexp filter for counter names "
470                          "[default: %default]"))
471  (options, args) = parser.parse_args()
472  if len(args) != 1:
473    parser.print_help()
474    sys.exit(1)
475  Main(args[0], re.compile(options.filter))
476