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

lunarmodules / copas / 30206523120

26 Jul 2026 02:39PM UTC coverage: 85.11% (+0.04%) from 85.072%
30206523120

push

github

Tieske
fix(semaphore): stop leaking resources to canceled queue waiters

Same root cause as the copas.lock deadlock (#199): semaphore:give()
deducted a queued waiter's requested resources from the pool and then
called copas.wakeup() without checking whether it actually resumed
the coroutine. If that waiter had been canceled externally (eg. via
copas.removethread()/future:cancel()) while queued, the resources
were permanently subtracted from the pool even though the canceled
coroutine never runs to consume or return them - silently shrinking
the semaphore's effective capacity forever, and starving any real
waiter still queued behind it.

give() now only deducts the resources once copas.wakeup() confirms
the waiter actually resumed; a stale/canceled entry is dropped without
touching the count, and the loop continues to the next waiter in line.

18 of 18 new or added lines in 2 files covered. (100.0%)

127 existing lines in 5 files now uncovered.

1429 of 1679 relevant lines covered (85.11%)

67776.83 hits per line

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

97.25
/src/copas/semaphore.lua
1
local copas = require("copas")
162✔
2

3
local coroutine_running = coroutine.running
162✔
4
if _VERSION=="Lua 5.1" and not jit then     -- obsolete: only for Lua 5.1 compatibility
162✔
UNCOV
5
  coroutine_running = require("coxpcall").running
27✔
6
end
7

8
local DEFAULT_TIMEOUT = 10
162✔
9

10
local semaphore = {}
162✔
11
semaphore.__index = semaphore
162✔
12

13

14
-- registry, semaphore indexed by the coroutines using them.
15
local registry = setmetatable({}, { __mode="kv" })
162✔
16

17

18
-- create a new semaphore
19
-- @param max maximum number of resources the semaphore can hold (this maximum does NOT include resources that have been given but not yet returned).
20
-- @param start (optional, default 0) the initial resources available
21
-- @param seconds (optional, default 10) default semaphore timeout in seconds, or `math.huge` to have no timeout.
22
function semaphore.new(max, start, seconds)
162✔
23
  local timeout = tonumber(seconds or DEFAULT_TIMEOUT) or -1
506✔
24
  if timeout < 0 then
506✔
25
    error("expected timeout (2nd argument) to be a number greater than or equal to 0, got: " .. tostring(seconds), 2)
×
26
  end
27
  if type(max) ~= "number" or max < 1 then
506✔
28
    error("expected max resources (1st argument) to be a number greater than 0, got: " .. tostring(max), 2)
×
29
  end
30

31
  local self = setmetatable({
1,012✔
32
      count = start or 0,
506✔
33
      max = max,
506✔
34
      timeout = timeout,
506✔
35
      q_tip = 1,    -- position of next entry waiting
423✔
36
      q_tail = 1,   -- position where next one will be inserted
423✔
37
      queue = {},
506✔
38
      to_flags = setmetatable({}, { __mode = "k" }), -- timeout flags indexed by coroutine
506✔
39
    }, semaphore)
506✔
40

41
  return self
506✔
42
end
43

44

45
do
46
  local destroyed_func = function()
47
    return nil, "destroyed"
24✔
48
  end
49

50
  local destroyed_semaphore_mt = {
162✔
51
    __index = function()
52
      return destroyed_func
24✔
53
    end
54
  }
55

56
  -- destroy a semaphore.
57
  -- Releases all waiting threads with `nil+"destroyed"`
58
  function semaphore:destroy()
162✔
59
    self:give(math.huge)
386✔
60
    self.destroyed = true
386✔
61
    setmetatable(self, destroyed_semaphore_mt)
386✔
62
    return true
386✔
63
  end
64
end
65

66

67
-- Gives resources.
68
-- @param given (optional, default 1) number of resources to return. If more
69
-- than the maximum are returned then it will be capped at the maximum and
70
-- error "too many" will be returned.
71
function semaphore:give(given)
162✔
72
  local err
73
  given = given or 1
638✔
74
  local count = self.count + given
638✔
75
  --print("now at",count, ", after +"..given)
76
  if count > self.max then
638✔
77
    count = self.max
392✔
78
    err = "too many"
392✔
79
  end
80

81
  while self.q_tip < self.q_tail do
860✔
82
    local i = self.q_tip
228✔
83
    local nxt = self.queue[i] -- there can be holes, so nxt might be nil
228✔
84
    if not nxt then
228✔
85
      self.q_tip = i + 1
12✔
86
    elseif not copas.issleeping(nxt.co) then
252✔
87
      -- stale entry; 'nxt.co' was canceled externally (eg. via
88
      -- copas.removethread/future:cancel) and will never resume to
89
      -- consume or give back resources. Drop it without touching 'count',
90
      -- and regardless of how much it had requested, so it doesn't block
91
      -- waiters behind it that request less than it did.
92
      self.queue[i] = nil
18✔
93
      self.to_flags[nxt.co] = nil
18✔
94
      self.q_tip = i + 1
18✔
95
      nxt.co = nil
18✔
96
    elseif count >= nxt.requested then
198✔
97
      -- release it
98
      self.queue[i] = nil
192✔
99
      self.to_flags[nxt.co] = nil
192✔
100
      count = count - nxt.requested
192✔
101
      self.q_tip = i + 1
192✔
102
      copas.wakeup(nxt.co)
192✔
103
      nxt.co = nil
192✔
104
    else
105
      break -- we ran out of resources
106
    end
107
  end
108

109
  if self.q_tip == self.q_tail then  -- reset queue
638✔
110
    self.queue = {}
632✔
111
    self.q_tip = 1
632✔
112
    self.q_tail = 1
632✔
113
  end
114

115
  self.count = count
638✔
116
  if err then
638✔
117
    return nil, err
392✔
118
  end
119
  return true
246✔
120
end
121

122

123

124
local function timeout_handler(co)
125
  local self = registry[co]
12✔
126
  --print("checking timeout ", co)
127
  if not self then
12✔
128
    return
×
129
  end
130

131
  for i = self.q_tip, self.q_tail do
12✔
132
    local item = self.queue[i]
12✔
133
    if item and co == item.co then
12✔
134
      self.queue[i] = nil
12✔
135
      self.to_flags[co] = true
12✔
136
      --print("marked timeout ", co)
137
      copas.wakeup(co)
12✔
138
      return
12✔
139
    end
140
  end
141
  -- nothing to do here...
142
end
143

144

145
-- Requests resources from the semaphore.
146
-- Waits if there are not enough resources available before returning.
147
-- @param requested (optional, default 1) the number of resources requested
148
-- @param timeout (optional, defaults to semaphore timeout) timeout in
149
-- seconds. If 0 it will either succeed or return immediately with error "timeout".
150
-- If `math.huge` it will wait forever.
151
-- @return true, or nil+"destroyed"
152
function semaphore:take(requested, timeout)
162✔
153
  requested = requested or 1
348✔
154
  if self.q_tail == 1 and self.count >= requested then
348✔
155
    -- nobody is waiting before us, and there is enough in store
156
    self.count = self.count - requested
108✔
157
    return true
108✔
158
  end
159

160
  if requested > self.max then
240✔
161
    return nil, "too many"
6✔
162
  end
163

164
  local to = timeout or self.timeout
234✔
165
  if to == 0 then
234✔
166
    return nil, "timeout"
12✔
167
  end
168

169
  -- get in line
170
  local co = coroutine_running()
222✔
171
  self.to_flags[co] = nil
222✔
172
  registry[co] = self
222✔
173
  copas.timeout(to, timeout_handler)
222✔
174

175
  self.queue[self.q_tail] = {
222✔
176
    co = co,
222✔
177
    requested = requested,
222✔
178
    --timeout = nil, -- flag indicating timeout
179
  }
222✔
180
  self.q_tail = self.q_tail + 1
222✔
181

182
  copas.pauseforever() -- block until woken
222✔
183
  registry[co] = nil
204✔
184

185
  if self.to_flags[co] then
204✔
186
    -- a timeout happened
187
    self.to_flags[co] = nil
12✔
188
    return nil, "timeout"
12✔
189
  end
190

191
  copas.timeout(0)
192✔
192

193
  if self.destroyed then
192✔
194
    return nil, "destroyed"
54✔
195
  end
196

197
  return true
138✔
198
end
199

200
-- returns current available resources
201
function semaphore:get_count()
162✔
202
  return self.count
78✔
203
end
204

205
-- returns total shortage for requested resources
206
function semaphore:get_wait()
162✔
207
  local wait = 0
72✔
208
  for i = self.q_tip, self.q_tail - 1 do
156✔
209
    wait = wait + ((self.queue[i] or {}).requested or 0)
84✔
210
  end
211
  return wait - self.count
72✔
212
end
213

214

215
return semaphore
162✔
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