neovim

Neovim text editor
git clone https://git.dasho.dev/neovim.git
Log | Files | Refs | README

memory_usage_spec.lua (6490B)


      1 local t = require('test.testutil')
      2 local n = require('test.functional.testnvim')()
      3 
      4 local clear = n.clear
      5 local eval = n.eval
      6 local eq = t.eq
      7 local feed_command = n.feed_command
      8 local retry = t.retry
      9 local ok = t.ok
     10 local source = n.source
     11 local poke_eventloop = n.poke_eventloop
     12 local load_adjust = n.load_adjust
     13 local write_file = t.write_file
     14 local is_os = t.is_os
     15 local is_ci = t.is_ci
     16 
     17 clear()
     18 if t.is_asan() then
     19  pending('ASAN build is difficult to estimate memory usage', function() end)
     20  return
     21 elseif is_os('win') then
     22  if is_ci('github') then
     23    pending(
     24      'Windows runners in Github Actions do not have a stable environment to estimate memory usage',
     25      function() end
     26    )
     27    return
     28  elseif eval("executable('wmic')") == 0 then
     29    pending('missing "wmic" command', function() end)
     30    return
     31  end
     32 elseif eval("executable('ps')") == 0 then
     33  pending('missing "ps" command', function() end)
     34  return
     35 end
     36 
     37 local monitor_memory_usage = {
     38  memory_usage = function(self)
     39    local handle
     40    if is_os('win') then
     41      handle = io.popen('wmic process where processid=' .. self.pid .. ' get WorkingSetSize')
     42    else
     43      handle = io.popen('ps -o rss= -p ' .. self.pid)
     44    end
     45    return tonumber(handle:read('*a'):match('%d+'))
     46  end,
     47  op = function(self)
     48    retry(nil, 10000, function()
     49      local val = self.memory_usage(self)
     50      if self.max < val then
     51        self.max = val
     52      end
     53      table.insert(self.hist, val)
     54      ok(#self.hist > 20)
     55      local result = {}
     56      for key, value in ipairs(self.hist) do
     57        if value ~= self.hist[key + 1] then
     58          table.insert(result, value)
     59        end
     60      end
     61      table.remove(self.hist, 1)
     62      self.last = self.hist[#self.hist]
     63      eq(1, #result)
     64    end)
     65  end,
     66  dump = function(self)
     67    return 'max: ' .. self.max .. ', last: ' .. self.last
     68  end,
     69  monitor_memory_usage = function(self, pid)
     70    local obj = {
     71      pid = pid,
     72      max = 0,
     73      last = 0,
     74      hist = {},
     75    }
     76    setmetatable(obj, { __index = self })
     77    obj:op()
     78    return obj
     79  end,
     80 }
     81 setmetatable(monitor_memory_usage, {
     82  __call = function(self, pid)
     83    return monitor_memory_usage.monitor_memory_usage(self, pid)
     84  end,
     85 })
     86 
     87 describe('memory usage', function()
     88  local tmpfile = 'X_memory_usage'
     89 
     90  after_each(function()
     91    os.remove(tmpfile)
     92  end)
     93 
     94  local function check_result(tbl, status, result)
     95    if not status then
     96      print('')
     97      for key, val in pairs(tbl) do
     98        print(key, val:dump())
     99      end
    100      error(result)
    101    end
    102  end
    103 
    104  before_each(clear)
    105 
    106  --[[
    107  Case: if a local variable captures a:000, funccall object will be free
    108  just after it finishes.
    109  ]]
    110  --
    111  it('function capture vargs', function()
    112    local pid = eval('getpid()')
    113    local before = monitor_memory_usage(pid)
    114    write_file(
    115      tmpfile,
    116      [[
    117      func s:f(...)
    118        let x = a:000
    119      endfunc
    120      for _ in range(10000)
    121        call s:f(0)
    122      endfor
    123    ]]
    124    )
    125    -- TODO: check_result fails if command() is used here. Why? #16064
    126    feed_command('source ' .. tmpfile)
    127    poke_eventloop()
    128    local after = monitor_memory_usage(pid)
    129    -- Estimate the limit of max usage as 2x initial usage.
    130    -- The lower limit can fluctuate a bit, use 97%.
    131    check_result({ before = before, after = after }, pcall(ok, before.last * 97 / 100 < after.max))
    132    check_result({ before = before, after = after }, pcall(ok, before.last * 2 > after.max))
    133    -- In this case, garbage collecting is not needed.
    134    -- The value might fluctuate a bit, allow for 3% tolerance below and 5% above.
    135    -- Based on various test runs.
    136    local lower = after.last * 97 / 100
    137    local upper = after.last * 105 / 100
    138    check_result({ before = before, after = after }, pcall(ok, lower < after.max))
    139    check_result({ before = before, after = after }, pcall(ok, after.max < upper))
    140  end)
    141 
    142  --[[
    143  Case: if a local variable captures l: dict, funccall object will not be
    144  free until garbage collector runs, but after that memory usage doesn't
    145  increase so much even when rerun Xtest.vim since system memory caches.
    146  ]]
    147  --
    148  it('function capture lvars', function()
    149    local pid = eval('getpid()')
    150    local before = monitor_memory_usage(pid)
    151    write_file(
    152      tmpfile,
    153      [[
    154      if !exists('s:defined_func')
    155        func s:f()
    156          let x = l:
    157        endfunc
    158      endif
    159      let s:defined_func = 1
    160      for _ in range(10000)
    161        call s:f()
    162      endfor
    163    ]]
    164    )
    165    feed_command('source ' .. tmpfile)
    166    poke_eventloop()
    167    local after = monitor_memory_usage(pid)
    168    for _ = 1, 3 do
    169      -- TODO: check_result fails if command() is used here. Why? #16064
    170      feed_command('source ' .. tmpfile)
    171      poke_eventloop()
    172    end
    173    local last = monitor_memory_usage(pid)
    174    -- The usage may be a bit less than the last value, use 80%.
    175    -- Allow for 20% tolerance at the upper limit. That's very permissive, but
    176    -- otherwise the test fails sometimes.  On FreeBSD we need to be even much
    177    -- more permissive.
    178    local upper_multiplier = is_os('freebsd') and 19 or 12
    179    local lower = before.last * 8 / 10
    180    local upper = load_adjust((after.max + (after.last - before.last)) * upper_multiplier / 10)
    181    check_result({ before = before, after = after, last = last }, pcall(ok, lower < last.last))
    182    check_result({ before = before, after = after, last = last }, pcall(ok, last.last < upper))
    183  end)
    184 
    185  it('releases memory when closing windows when folds exist', function()
    186    if is_os('mac') then
    187      pending('macOS memory compression causes flakiness')
    188    end
    189    local pid = eval('getpid()')
    190    source([[
    191      new
    192      " Insert lines
    193      call nvim_buf_set_lines(0, 0, 0, v:false, repeat([''], 999))
    194      " Create folds
    195      normal! gg
    196      for _ in range(500)
    197        normal! zfjj
    198      endfor
    199    ]])
    200    poke_eventloop()
    201    local before = monitor_memory_usage(pid)
    202    source([[
    203      " Split and close window multiple times
    204      for _ in range(1000)
    205        split
    206        close
    207      endfor
    208    ]])
    209    poke_eventloop()
    210    local after = monitor_memory_usage(pid)
    211    source('bwipe!')
    212    poke_eventloop()
    213    -- Allow for an increase of 10% in memory usage, which accommodates minor fluctuation,
    214    -- but is small enough that if memory were not released (prior to PR #14884), the test
    215    -- would fail.
    216    local upper = before.last * 1.10
    217    check_result({ before = before, after = after }, pcall(ok, after.last <= upper))
    218  end)
    219 end)