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

Tieske / corowatch / 3806703464

pending completion
3806703464

push

github

Thijs Schreijer
chore(docs) re-render docs

72 of 133 relevant lines covered (54.14%)

0.68 hits per line

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

54.14
/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.1"
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
1✔
35
  function unpack(t, i, j) return _unpack(t, i or 1, j or t.n or #t) end
1✔
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
1✔
57
    return _sethook(...)
×
58
  end
59
  return _sethook(coro, ...)
1✔
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]
2✔
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
1✔
110
  hookcount = hookcount or default_hookcount
1✔
111
  local entry = register[coro] or {}
1✔
112
  entry.tkilllimit = tkilllimit
1✔
113
  entry.twarnlimit = twarnlimit
1✔
114
  entry.cb = cb
1✔
115
  entry.hook = checkhook
1✔
116
  entry.debug = traceback()
1✔
117
  entry.hookcount = hookcount
1✔
118
  sethook(coro, entry.hook, "", hookcount)
1✔
119
  register[coro] = entry
×
120
  return entry
×
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
×
128
  return M.gettime()
×
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
2✔
154
    error("Cannot create a watch, there already is one")
×
155
  end
156
  assert(tkilllimit or twarnlimit, "Either kill limit or warn limit must be provided")
1✔
157
  if twarnlimit ~= nil then
1✔
158
    assert(type(twarnlimit) == "number", "Expected warn-limit to be a number")
1✔
159
    assert(cb, "A callback function must be provided when adding a warnlimit")
1✔
160
  end
161
  if tkilllimit ~= nil then
1✔
162
    assert(type(tkilllimit) == "number", "Expected kill-limit to be a number")
1✔
163
    if twarnlimit then
1✔
164
      assert(tkilllimit>twarnlimit, "The warnlimit must be smaller than the killlimit")
1✔
165
    end
166
  end
167
  if cb ~= nil then
1✔
168
    assert(type(cb) == "function", "Expected callback to be a function")
1✔
169
  end
170
  if hookcount ~= nil then
1✔
171
    assert(type(hookcount) == "number", "Expected hookcount to be a number")
1✔
172
    assert(hookcount >= 1, "The hookcount cannot be less than 1")
1✔
173
  end
174
  createwatch(coro, tkilllimit, twarnlimit, cb, hookcount)
1✔
175
  return coro
×
176
end
177
watch = M.watch
1✔
178

179
---------------------------------------------------------------------------------------
180
-- This is the same as the regular `coroutine.create`, except that when the running
181
-- coroutine is watched, then children spawned will also be watched with the same
182
-- settings.
183
-- @param f see `coroutine.create`
184
M.create = function(f)
185
  local s = getwatch(cororunning())
1✔
186
  if not s then return corocreate(f) end  -- I'm not being watched
1✔
187
  -- create and add watch
188
  return watch(corocreate(f), s.tkilllimit, s.twarnlimit, s.cb, s.hookcount)
×
189
end
190
create = M.create
1✔
191

192
---------------------------------------------------------------------------------------
193
-- This is the same as the regular `coroutine.wrap`, except that when the running
194
-- coroutine is watched, then children spawned will also be watched with the same
195
-- settings. To set sepecific settings for watching use `coroutine.wrapf`.
196
-- @param f see `coroutine.wrap`
197
-- @see wrapf
198
M.wrap = function(f)
199
  if not getwatch(cororunning()) then return corowrap(f) end  -- not watched
×
200
  local coro = create(f)
×
201
  return function(...) return resume(coro, ...) end
×
202
end
203

204
---------------------------------------------------------------------------------------
205
-- This is the same as the regular `coroutine.wrap`, except that the coroutine created
206
-- is watched according to the parameters provided, and not according to the watch
207
-- parameters of the currently running coroutine.
208
-- @tparam function f function to wrap
209
-- @tparam number|nil tkilllimit see `watch`
210
-- @tparam number|nil twarnlimit see `watch`
211
-- @tparam function|nil cb see `watch`
212
-- @tparam[opt=10000] number hookcount see `watch`
213
-- @see create
214
-- @see wrap
215
M.wrapf = function(f, tkilllimit, twarnlimit, cb, hookcount)
216
  local coro = watch(corocreate(f), tkilllimit, twarnlimit, cb, hookcount)
×
217
  return function(...) return resume(coro, ...) end
×
218
end
219

220
---------------------------------------------------------------------------------------
221
-- This is the same as the regular `coroutine.resume`.
222
-- @param coro see `coroutine.resume`
223
-- @param ... see `coroutine.resume`
224
M.resume = function(coro, ...)
225
  assert(type(coro) == "thread", "Expected thread, got "..type(coro))
×
226
  local e = getwatch(coro)
×
227
  if e then
×
228
    if e.errmsg then
×
229
      -- the coro being resumed is tagged with an error. This means that somewhere the error
230
      -- was thrown, but the coro didn't die (probably a pcall in between). The coro should have died
231
      -- but didn't so it is in an 'undetermined' state. Return error, don't resume
232
      return false, e.errmsg
×
233
    end
234
    local t = M.gettime()
×
235
    if e.tkilllimit then e.killtime = t + e.tkilllimit end
×
236
    if e.twarnlimit then e.warntime = t + e.twarnlimit end
×
237
    e.warned = nil
×
238
  end
239
  local r = pack(cororesume(coro, ...))
×
240
  if e and e.errmsg then
×
241
    return false, e.errmsg
×
242
  else
243
    return unpack(r)
×
244
  end
245
end
246
resume = M.resume
1✔
247

248
---------------------------------------------------------------------------------------
249
-- This is the same as the regular `coroutine.yield`.
250
-- @param ... see `coroutine.yield`
251
M.yield = function(...)
252
  local e = getwatch()
×
253
  if e then
×
254
    if e.errmsg then
×
255
      -- the coro is yielding, while it is tagged with an error. This means that somewhere the error
256
      -- was thrown, but the coro didn't die (probably a pcall in between). The coro should have died
257
      -- but didn't so it is in an 'undetermined' state. So kill again.
258
      error(e.errmsg,2)
×
259
    end
260
    e.killtime = nil
×
261
    e.warntime = nil
×
262
    e.warned = nil
×
263
  end
264
  return coroyield(...)
×
265
end
266

267
---------------------------------------------------------------------------------------
268
-- This is the same as the regular `coroutine.status`.
269
-- @param coro see `coroutine.status`
270
M.status = function(coro)
271
  if (getwatch(coro) or {}).errmsg then
×
272
    return "dead"
×
273
  else
274
    return corostatus(coro)
×
275
  end
276
end
277

278
---------------------------------------------------------------------------------------
279
-- This is the same as the regular `debug.sethook`, except that when trying to set a
280
-- hook on a coroutine that is being watched, if will throw an error.
281
-- @param coro see `debug.sethook`
282
-- @param ... see `debug.sethook`
283
M.sethook = function(coro, ...)
284
  if getwatch(coro) then
×
285
    error("Cannot set a debughook because corowatch is watching this coroutine", 2)
×
286
  end
287
  -- not watched, so do the regular thing
288
  return sethook(coro, ...)
×
289
end
290

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

332
-- export some internals for testing if requested
333
if _TEST then  -- luacheck: ignore
1✔
334
  M._register = register
1✔
335
  M._getwatch = getwatch
1✔
336
end
337

338
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