• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Tieske / corowatch / 3756734339

pending completion
3756734339

push

github

Thijs Schreijer
feat(watch) make hookcount configurable

11 of 11 new or added lines in 1 file covered. (100.0%)

86 of 123 relevant lines covered (69.92%)

3.24 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

69.92
/src/corowatch.lua
1
---------------------------------------------------------------------------------------
2
-- Module to watch coroutine executiontime. Coroutines running too long without
3
-- yielding can be killed to prevent them from locking the Lua state.
4
-- The module uses `LuaSocket` to get the time (`socket.gettime` function). If you
5
-- do not want that, override the `coroutine.gettime` method with your own
6
-- implementation.
7
--
8
-- @copyright Copyright (c) 2013-2022 Thijs Schreijer
9
-- @author Thijs Schreijer
10
-- @license MIT, see `LICENSE.md`.
11
-- @name corowatch
12
-- @class module
13

14
local M = {}    -- module table
1✔
15
M._VERSION = "1.0"
1✔
16
M._COPYRIGHT = "Copyright (c) 2013-2022 Thijs Schreijer"
1✔
17
M._DESCRIPTION = "Lua module to watch coroutine usage and kill a coroutine if it fails to yield in a timely manner."
1✔
18

19

20
local corocreate = coroutine.create
1✔
21
local cororesume = coroutine.resume
1✔
22
local cororunning = coroutine.running
1✔
23
local corostatus = coroutine.status
1✔
24
local corowrap = coroutine.wrap
1✔
25
local coroyield = coroutine.yield
1✔
26
local _sethook = debug.sethook
1✔
27
local traceback = debug.traceback
1✔
28
local watch, resume, create
29
local default_hookcount = 10000
1✔
30

31

32
local pack, unpack do -- pack/unpack to create/honour the .n field for nil-safety
1✔
33
  local _unpack = _G.table.unpack or _G.unpack
1✔
34
  function pack (...) return { n = select('#', ...), ...} end
6✔
35
  function unpack(t, i, j) return _unpack(t, i or 1, j or t.n or #t) end
4✔
36
end
37

38
-- create watch register; table indexed by coro with watch properties
39
local register = setmetatable({},{__mode = "k"})  -- set weak keys
1✔
40
-- register element = {
41
--   twarnlimit = when to warn, seconds
42
--   tkilllimit = when to kill, seconds
43
--   cb = callback, function
44
--   warned = boolean; was cwwarn called already?
45
--   killtime = point in time after which to kill the coroutine
46
--   warntime = point in time after which to warn
47
--   errmsg = errormessage if timedout, so if set, the coro should be dead
48
--   hook = function; the debughook in use
49
--   debug = debug.traceback() at the time of protecting
50
--   hookcount = the count setting for the debughook
51
-- }
52

53

54
local mainthread = {}  -- for 5.1 where there is no main thread access, running() returns nil
1✔
55
local function sethook(coro, ...)  -- compatibility for Lua 5.1
56
  if coro == mainthread then
18✔
57
    return _sethook(...)
×
58
  end
59
  return _sethook(coro, ...)
18✔
60
end
61

62
-- Gets the entry for the coro from the coroutine register.
63
-- @return the coroutine entry from the register
64
local getwatch = function(coro)
65
  return register[coro or cororunning() or mainthread]
43✔
66
end
67

68

69
-- Debughook function, to check for timeouts
70
local checkhook = function()
71
  local e = getwatch()
×
72
  if not e then return end  -- not being watched
×
73
  local t = M.gettime()
×
74
  if e.errmsg then
×
75
    -- the coro is tagged with an error. This means that somewhere the error
76
    -- was thrown, but the coro didn't die (probably a pcall in between). The coro should have died
77
    -- but didn't so it is in an 'undetermined' state. So kill again.
78
    error(e.errmsg,2)
×
79
  elseif not e.warned and e.warntime and e.warntime < t then
×
80
    -- warn now
81
    if e.cb("warn") then
×
82
      -- returned positive, so reset timeouts
83
      if e.tkilllimit then e.killtime = t + e.tkilllimit end
×
84
      if e.twarnlimit then e.warntime = t + e.twarnlimit end
×
85
    else
86
      e.warned = true
×
87
    end
88
  elseif e.killtime and e.killtime < t then
×
89
    if e.cb then e.cb("kill") end
×
90
    -- run hook now every instruction, to kill again and again if it persists (pcall/cboundary)
91
    sethook(cororunning(), e.hook, "", 1)
×
92
    -- kill now
93
    e.errmsg = "Coroutine exceeded its allowed running time of "..tostring(e.tkilllimit).." seconds, without yielding"
×
94
    if e.debug then
×
95
      e.errmsg = e.errmsg ..
×
96
       "\n============== traceback at coroutine creation time ====================\n" ..
×
97
       e.debug ..
×
98
       "\n======================== end of traceback =============================="
99
    end
100
    error(e.errmsg, 2)
×
101
  end
102
end
103

104
-- Creates an entry in the coroutine register. If it already exists, existing values
105
-- will be overwritten (in the existing entry) with the new values.
106
-- @param coro coroutine for which to create the entry
107
-- @return the entry created
108
local createwatch = function(coro, tkilllimit, twarnlimit, cb, hookcount)
109
  coro = coro or cororunning() or mainthread
7✔
110
  hookcount = hookcount or default_hookcount
7✔
111
  local entry = register[coro] or {}
7✔
112
  entry.tkilllimit = tkilllimit
7✔
113
  entry.twarnlimit = twarnlimit
7✔
114
  entry.cb = cb
7✔
115
  entry.hook = checkhook
7✔
116
  entry.debug = traceback()
7✔
117
  entry.hookcount = hookcount
7✔
118
  sethook(coro, entry.hook, "", hookcount)
7✔
119
  register[coro] = entry
7✔
120
  return entry
7✔
121
end
122

123
---------------------------------------------------------------------------------------
124
-- returns current time in seconds. If not overridden, it will require `luasocket` and use
125
-- `socket.gettime` to get the current time.
126
M.gettime = function()
127
  M.gettime = require("socket").gettime
1✔
128
  return M.gettime()
1✔
129
end
130

131
---------------------------------------------------------------------------------------
132
-- Protects a coroutine from running too long without yielding.
133
-- The callback has 1 parameter (string value being either "warn" or "kill"), but runs
134
-- on the coroutine that is subject of the warning. If the "warn" callback returns a
135
-- truthy value (neither `false`, nor `nil`) then the timeouts for kill and warn limits
136
-- will be reset (buying more time for the coroutine to finish its business).
137
--
138
-- The `hookcount` default of 10000 will ensure offending coroutines are caught with
139
-- limited performance impact. To better narrow down any offending code that takes too long,
140
-- this can be set to a lower value (eg. set it to 1, and it will break right after
141
-- the instruction that tripped the limit). But the smaller the value, the higher the
142
-- performance cost.
143
--
144
-- NOTE: the callback runs inside a debughook.
145
-- @tparam coroutine|nil coro coroutine to be protected, defaults to the currently running routine
146
-- @tparam number|nil tkilllimit time in seconds it is allowed to run without yielding
147
-- @tparam number|nil twarnlimit time in seconds it is allowed before `cb` is called
148
-- (must be smaller than `tkilllimit`)
149
-- @tparam function|nil cb callback executed when the kill or warn limit is reached.
150
-- @tparam[opt=10000] number hookcount the hookcount to use (every `x` number of VM instructions check the limits)
151
-- @return coro
152
M.watch = function(coro, tkilllimit, twarnlimit, cb, hookcount)
153
  if getwatch(coro) then error("Cannot create a watch, there already is one") end
12✔
154
  assert(tkilllimit or twarnlimit, "Either kill limit or warn limit must be provided")
11✔
155
  if twarnlimit then assert(cb, "A callback function must be provided when adding a warnlimit") end
10✔
156
  if tkilllimit and twarnlimit then assert(tkilllimit>twarnlimit, "The warnlimit must be smaller than the killlimit") end
8✔
157
  if hookcount then assert(hookcount >= 1, "The hookcount cannot be less than 1") end
7✔
158
  createwatch(coro, tkilllimit, twarnlimit, cb, hookcount)
7✔
159
  return coro
7✔
160
end
161
watch = M.watch
1✔
162

163
---------------------------------------------------------------------------------------
164
-- This is the same as the regular `coroutine.create`, except that when the running
165
-- coroutine is watched, then children spawned will also be watched with the same
166
-- settings.
167
-- @param f see `coroutine.create`
168
M.create = function(f)
169
  local s = getwatch(cororunning())
11✔
170
  if not s then return corocreate(f) end  -- I'm not being watched
11✔
171
  -- create and add watch
172
  return watch(corocreate(f), s.tkilllimit, s.twarnlimit, s.cb, s.hookcount)
×
173
end
174
create = M.create
1✔
175

176
---------------------------------------------------------------------------------------
177
-- This is the same as the regular `coroutine.wrap`, except that when the running
178
-- coroutine is watched, then children spawned will also be watched with the same
179
-- settings. To set sepecific settings for watching use `coroutine.wrapf`.
180
-- @param f see `coroutine.wrap`
181
-- @see wrapf
182
M.wrap = function(f)
183
  if not getwatch(cororunning()) then return corowrap(f) end  -- not watched
×
184
  local coro = create(f)
×
185
  return function(...) return resume(coro, ...) end
1✔
186
end
187

188
---------------------------------------------------------------------------------------
189
-- This is the same as the regular `coroutine.wrap`, except that the coroutine created
190
-- is watched according to the parameters provided, and not according to the watch
191
-- parameters of the currently running coroutine.
192
-- @tparam function f function to wrap
193
-- @tparam number|nil tkilllimit see `watch`
194
-- @tparam number|nil twarnlimit see `watch`
195
-- @tparam function|nil cb see `watch`
196
-- @tparam[opt=10000] number hookcount see `watch`
197
-- @see create
198
-- @see wrap
199
M.wrapf = function(f, tkilllimit, twarnlimit, cb, hookcount)
200
  local coro = watch(corocreate(f), tkilllimit, twarnlimit, cb, hookcount)
×
201
  return function(...) return resume(coro, ...) end
×
202
end
203

204
---------------------------------------------------------------------------------------
205
-- This is the same as the regular `coroutine.resume`.
206
-- @param coro see `coroutine.resume`
207
-- @param ... see `coroutine.resume`
208
M.resume = function(coro, ...)
209
  assert(type(coro) == "thread", "Expected thread, got "..type(coro))
5✔
210
  local e = getwatch(coro)
5✔
211
  if e then
5✔
212
    if e.errmsg then
5✔
213
      -- the coro being resumed is tagged with an error. This means that somewhere the error
214
      -- was thrown, but the coro didn't die (probably a pcall in between). The coro should have died
215
      -- but didn't so it is in an 'undetermined' state. Return error, don't resume
216
      return false, e.errmsg
×
217
    end
218
    local t = M.gettime()
5✔
219
    if e.tkilllimit then e.killtime = t + e.tkilllimit end
5✔
220
    if e.twarnlimit then e.warntime = t + e.twarnlimit end
5✔
221
    e.warned = nil
5✔
222
  end
223
  local r = pack(cororesume(coro, ...))
5✔
224
  if e and e.errmsg then
5✔
225
    return false, e.errmsg
2✔
226
  else
227
    return unpack(r)
3✔
228
  end
229
end
230
resume = M.resume
1✔
231

232
---------------------------------------------------------------------------------------
233
-- This is the same as the regular `coroutine.yield`.
234
-- @param ... see `coroutine.yield`
235
M.yield = function(...)
236
  local e = getwatch()
×
237
  if e then
×
238
    if e.errmsg then
×
239
      -- the coro is yielding, while it is tagged with an error. This means that somewhere the error
240
      -- was thrown, but the coro didn't die (probably a pcall in between). The coro should have died
241
      -- but didn't so it is in an 'undetermined' state. So kill again.
242
      error(e.errmsg,2)
×
243
    end
244
    e.killtime = nil
×
245
    e.warntime = nil
×
246
    e.warned = nil
×
247
  end
248
  return coroyield(...)
×
249
end
250

251
---------------------------------------------------------------------------------------
252
-- This is the same as the regular `coroutine.status`.
253
-- @param coro see `coroutine.status`
254
M.status = function(coro)
255
  if (getwatch(coro) or {}).errmsg then
3✔
256
    return "dead"
2✔
257
  else
258
    return corostatus(coro)
1✔
259
  end
260
end
261

262
---------------------------------------------------------------------------------------
263
-- This is the same as the regular `debug.sethook`, except that when trying to set a
264
-- hook on a coroutine that is being watched, if will throw an error.
265
-- @param coro see `debug.sethook`
266
-- @param ... see `debug.sethook`
267
M.sethook = function(coro, ...)
268
  if getwatch(coro) then
11✔
269
    error("Cannot set a debughook because corowatch is watching this coroutine", 2)
×
270
  end
271
  -- not watched, so do the regular thing
272
  return sethook(coro, ...)
11✔
273
end
274

275
---------------------------------------------------------------------------------------
276
-- Export the corowatch functions to an external table, or the global environment.
277
-- The functions exported are `create`, `yield`, `resume`, `status`, `wrap`, and `wrapf`. The standard
278
-- `coroutine.running` will be added if there is no `running` value in the table yet. So
279
-- basically it exports a complete `coroutine` table + `wrapf`.
280
-- If the provided table contains subtables `coroutine` and/or `debug` then it is assumed to
281
-- be a function/global environment and `sethook` will be exported as well (exports will then
282
-- go into the two subtables)
283
-- @tparam[opt] table t table to which to export the coroutine functions.
284
-- @return the table provided, or a new table if non was provided, with the exported functions
285
-- @usage
286
-- -- monkey patch global environment, both coroutine and debug tables
287
-- require("corowatch").export(_G)
288
M.export = function(t)
289
  t = t or {}
2✔
290
  local c, d
291
  assert(type(t) == "table", "Expected table, got "..type(t))
2✔
292
  if (type(t.debug) == "table") or type(t.coroutine == "table") then
2✔
293
    -- we got a global table, so monkeypatch debug and coroutine table
294
    d = t.debug
2✔
295
    c = t.coroutine
2✔
296
  else
297
    -- we got something else, just export coroutine here
298
    d = nil
×
299
    c = t
×
300
  end
301
  if type(d) == "table" then
2✔
302
    d.sethook = M.sethook
2✔
303
  end
304
  if type(c) == "table" then
2✔
305
    c.yield = M.yield
2✔
306
    c.create = M.create
2✔
307
    c.resume = M.resume
2✔
308
    c.running = c.running or cororunning
2✔
309
    c.status = M.status
2✔
310
    c.wrap = M.wrap
2✔
311
    c.wrapf = M.wrapf
2✔
312
  end
313
  return t
2✔
314
end
315

316
-- export some internals for testing if requested
317
if _TEST then  -- luacheck: ignore
1✔
318
  M._register = register
1✔
319
  M._getwatch = getwatch
1✔
320
end
321

322
return M
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc