lib/test_prof/memory_prof/tracker/linked_list.rb



# frozen_string_literal: true

module TestProf
  module MemoryProf
    class Tracker
      # LinkedList is a linked list that track memory usage for individual examples/groups.
      # A list node (`LinkedListNode`) represents an example/group and its memory usage info:
      #
      # * memory_at_start - the amount of memory at the start of an example/group
      # * memory_at_finish - the amount of memory at the end of an example/group
      # * nested_memory - the amount of memory allocated by examples/groups defined inside a group
      # * previous - a link to the previous node
      #
      # Each node has a link to its previous node, and the head node points to the current example/group.
      # If we picture a linked list as a tree with root being the top level group and leaves being
      # current examples/groups, then the head node will always point to a leaf in that tree.
      #
      # For example, if we have the following spec:
      #
      #   describe Question do
      #     decribe "#publish" do
      #       context "when not published" do
      #         it "makes the question visible" do
      #           ...
      #         end
      #       end
      #     end
      #   end
      #
      # At the moment when rspec is executing the example, the list has the following structure
      # (^ denotes the head node):
      #
      #    ^"makes the question visible" ->  "when not published" -> "#publish" -> Question
      #
      # LinkedList supports two method for working with it:
      #
      #  * add_node – adds a node to the beginig of the list. At this point an example or group
      #    has started and we track how much memory has already been used.
      #  * remove_node – removes and returns the head node from the list. It means that the node
      #    example/group has finished and it is time to calculate its memory usage.
      #
      # When we remove a node we add its total_memory to the previous node.nested_memory, thus
      # gradually tracking the amount of memory used by nested examples inside a group.
      #
      # In the example above, after we remove the node "makes the question visible", we add its total
      # memory usage to nested_memory of the "when not published" node. If the "when not published"
      # group contains other examples or sub-groups, their total_memory will also be added to
      # "when not published" nested_memory. So when the group finishes we will have the total amount
      # of memory used by its nested examples/groups, and thus we will be able to calculate the memory
      # used by hooks and other code inside a group by subtracting nested_memory from total_memory.
      class LinkedList
        attr_reader :head

        def initialize(memory_at_start)
          add_node(:total, :total, memory_at_start)
        end

        def add_node(id, item, memory_at_start)
          @head = LinkedListNode.new(
            id: id,
            item: item,
            previous: head,
            memory_at_start: memory_at_start
          )
        end

        def remove_node(id, memory_at_finish)
          return if head.id != id
          head.finish(memory_at_finish)

          current = head
          @head = head.previous

          current
        end
      end

      class LinkedListNode
        attr_reader :id, :item, :previous, :memory_at_start, :memory_at_finish, :nested_memory

        def initialize(id:, item:, memory_at_start:, previous:)
          @id = id
          @item = item
          @previous = previous

          @memory_at_start = memory_at_start || 0
          @memory_at_finish = nil
          @nested_memory = 0
        end

        def total_memory
          return 0 if memory_at_finish.nil?
          # It seems that on Windows Minitest may release a lot of memory to
          # the OS when it finishes and executes #report, leading to memory_at_finish
          # being less than memory_at_start. In this case we return nested_memory
          # which does not account for the memory used in `after` hooks, but it
          # is better than nothing.
          return nested_memory if memory_at_start > memory_at_finish

          memory_at_finish - memory_at_start
        end

        def hooks_memory
          total_memory - nested_memory
        end

        def finish(memory_at_finish)
          @memory_at_finish = memory_at_finish

          previous&.add_nested(self)
        end

        protected

        def add_nested(node)
          @nested_memory += node.total_memory
        end
      end
    end
  end
end