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

lunarmodules / copas / 30297691869

27 Jul 2026 07:18PM UTC coverage: 85.029% (-0.3%) from 85.334%
30297691869

push

github

Tieske
fix(io): stale timeout waiters can block a reused socket's real waiter

A socket's read/write/connect timeout resumed the timed-out coroutine
and dropped the socket from the select set, but never cleared that
coroutine's slot in the socket's FIFO wait queue -- that queue was only
ever drained by the normal readiness path, which the timer bypasses.

If the caller kept the socket after a timeout (eg. returned it to a
connection pool) instead of closing it, the stale entry lingered. A
later coroutine waiting on the same socket queued behind it. When the
socket did become ready, the dispatcher popped and resumed the stale
(often already-finished) coroutine instead of the real waiter, dropped
the socket from the select set, and left the real waiter parked
forever with nothing left to wake it. Fixes #208.

Root cause was the FIFO itself: nothing in copas ever legitimately
waits more than one coroutine deep on a socket per direction -- a
socket is only ever driven by one owner at a time. Replaced the FIFO
with a single-slot claim/release pair on the socket set, and moved the
claim to happen before a coroutine yields (inside copas.receive/send/
receivefrom/receivepartial/connect/dohandshake) rather than after, in
the dispatcher. A conflicting second waiter now gets an immediate
"Operation already in progress" back as a normal return value -- same
shape as "timeout" or "closed" -- instead of being silently abandoned
with no way to observe the failure. This also means the dispatcher
never needs to reach into an unrelated coroutine's pending timer: each
coroutine cancels its own via the existing sto_timeout() path, exactly
like every other early-return branch already does.

Adds regression tests covering both directions (duplicate_read_waiter_
errors, duplicate_write_waiter_errors) in tests/tcptimeout.lua.

55 of 74 new or added lines in 1 file covered. (74.32%)

97 existing lines in 5 files now uncovered.

1471 of 1730 relevant lines covered (85.03%)

53656.51 hits per line

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

98.31
/src/copas/timer.lua
1
local copas = require("copas")
18✔
2

3
local xpcall = xpcall
18✔
4
local coroutine_running = coroutine.running
18✔
5

6
if _VERSION=="Lua 5.1" and not jit then     -- obsolete: only for Lua 5.1 compatibility
18✔
UNCOV
7
  xpcall = require("coxpcall").xpcall
3✔
UNCOV
8
  coroutine_running = require("coxpcall").running
3✔
9
end
10

11

12
local timer = {}
18✔
13
timer.__index = timer
18✔
14

15

16
local new_name do
18✔
17
  local count = 0
18✔
18

19
  function new_name()
15✔
20
    count = count + 1
48✔
21
    return "copas_timer_" .. count
48✔
22
  end
23
end
24

25

26
do
27
  local function expire_func(self, initial_delay)
28
    if self.errorhandler then
60✔
29
      copas.seterrorhandler(self.errorhandler)
6✔
30
    end
31
    copas.pause(initial_delay)
60✔
32
    while true do
33
      if not self.cancelled then
132✔
34
        if not self.recurring then
120✔
35
          -- non-recurring timer
36
          self.cancelled = true
24✔
37
          self.co = nil
24✔
38

39
          self:callback(self.params)
24✔
40
          return
24✔
41

42
        else
43
          -- recurring timer
44
          self:callback(self.params)
96✔
45
        end
46
      end
47

48
      if self.cancelled then
108✔
49
        -- clean up and exit the thread
50
        self.co = nil
36✔
51
        self.cancelled = true
36✔
52
        return
36✔
53
      end
54

55
      copas.pause(self.delay)
84✔
56
    end
57
  end
58

59

60
  --- Arms the timer object.
61
  -- @param initial_delay (optional) the first delay to use, if not provided uses the timer delay
62
  -- @return timer object, nil+error, or throws an error on bad input
63
  function timer:arm(initial_delay)
18✔
64
    assert(initial_delay == nil or initial_delay >= 0, "delay must be greater than or equal to 0")
66✔
65
    if self.co then
66✔
66
      return nil, "already armed"
6✔
67
    end
68

69
    self.cancelled = false
60✔
70
    self.co = copas.addnamedthread(self.name, expire_func, self, initial_delay or self.delay)
70✔
71
    return self
60✔
72
  end
73
end
74

75

76

77
--- Cancels a running timer.
78
-- If the timer callback is currently in progress (eg. yielded on socket I/O),
79
-- it is allowed to run to completion, it just won't be rescheduled. Only a
80
-- timer that is idle, waiting for its next recurrence, is woken up and
81
-- stopped immediately.
82
-- @return timer object, or nil+error
83
function timer:cancel()
18✔
84
  if not self.co then
48✔
85
    return nil, "not armed"
12✔
86
  end
87

88
  if self.cancelled then
36✔
89
    return nil, "already cancelled"
×
90
  end
91

92
  self.cancelled = true
36✔
93
  copas.wakeup(self.co) -- in case it's idle between recurrences, exit immediately
36✔
94
  self.co = nil
36✔
95
  return self
36✔
96
end
97

98

99
do
100
  -- xpcall error handler that forwards to the copas errorhandler
101
  local ehandler = function(err_obj)
102
    return copas.geterrorhandler()(err_obj, coroutine_running(), nil)
14✔
103
  end
104

105

106
  --- Creates a new timer object.
107
  -- Note: the callback signature is: `function(timer_obj, params)`.
108
  -- @param opts (table) `opts.delay` timer delay in seconds, `opts.callback` function to execute, `opts.recurring` boolean
109
  -- `opts.params` (optional) this value will be passed to the timer callback, `opts.initial_delay` (optional) the first delay to use, defaults to `delay`.
110
  -- @return timer object, or throws an error on bad input
111
  function timer.new(opts)
18✔
112
    assert((opts.delay or -1) >= 0, "delay must be greater than or equal to 0")
54✔
113
    assert(type(opts.callback) == "function", "expected callback to be a function")
54✔
114

115
    local callback = function(timer_obj, params)
116
      xpcall(opts.callback, ehandler, timer_obj, params)
120✔
117
    end
118

119
    return setmetatable({
126✔
120
      name = opts.name or new_name(),
62✔
121
      delay = opts.delay,
54✔
122
      callback = callback,
54✔
123
      recurring = not not opts.recurring,
54✔
124
      params = opts.params,
54✔
125
      cancelled = false,
45✔
126
      errorhandler = opts.errorhandler,
54✔
127
    }, timer):arm(opts.initial_delay)
108✔
128
  end
129
end
130

131

132

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

© 2026 Coveralls, Inc