hotcell-server 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 390fc0f10953763de1f745cc3002fa0e548b362cbb21c82faba7dfa807b49dd5
4
- data.tar.gz: fd6932e090acccea7964e38d76e65ea6163b1ebf6b5f401f6854fe8fa1e38034
3
+ metadata.gz: b69f25f352406df26617692f42c1ce78c7191df4d7b617360c62c2371e488bed
4
+ data.tar.gz: 34ca8c1eea5a9de3cf739ce548151ac5c9487e185739f684d179844c13257b1d
5
5
  SHA512:
6
- metadata.gz: a34f827e6dbb2616e4e20b122ff6a8fdd78e40c19bfd8297c767c450a06ea4efaebc245d3ca3fb117caf13b70d922236e3c51f6a0a93207b5fc0a815e60e2583
7
- data.tar.gz: '0769c12d7f26f35380dd5532edd8073c160f6a823128dcc4c88fddc697fd896be8cabec1eaa21dedf3c505a26ccda5a90cc38716a23461fa70410eb92d2ccc1e'
6
+ metadata.gz: 5667ac9be32cd60ab131df56fb4be0a0354f007a19d31990c6210076dcd6e72c019a903b8e4f4f89ed5bf4ee444269de2b30d4776aa6373750f02ab21fa2869b
7
+ data.tar.gz: 9567d005d357239081a4c804b081939714c1ead9afb2588fcbd95d6795c638770348959d00549d49d9b76ec7cdc7bb0e3b2f7dd226a1a001d0c532fb524a05d2
@@ -23,6 +23,7 @@ module HotCell
23
23
  queue_wait: 10, # seconds a queued connection may wait before it is answered `capacity`
24
24
  max_requests_per_worker: 1, # requests a worker serves before it is discarded
25
25
  control_deadline: 5, # seconds a control connection may take to send its request
26
+ sweep_interval: 10, # seconds between the supervisor's checks for a killed request's tree to unlink
26
27
  }.freeze
27
28
 
28
29
  LIMITS = {
@@ -51,6 +52,7 @@ module HotCell
51
52
  # in describe's JSON, and they may arrive as Active Support durations.
52
53
  @queue_wait = @queue_wait.to_f
53
54
  @control_deadline = @control_deadline.to_f
55
+ @sweep_interval = @sweep_interval.to_f
54
56
 
55
57
  # A nil is not "use the default" here, it is a missing number. A cell whose deadline is nil accepts
56
58
  # every request and then dies on the first arithmetic the supervisor does with it, so an explicit nil
@@ -103,6 +105,7 @@ module HotCell
103
105
  positive! :concurrency, integer: true
104
106
  positive! :queue_wait
105
107
  positive! :control_deadline
108
+ positive! :sweep_interval
106
109
 
107
110
  unless queue_size.is_a?(Integer) && !queue_size.negative?
108
111
  raise ConfigurationError, "queue_size: #{queue_size} must not be negative"
@@ -18,8 +18,12 @@ module HotCell
18
18
  #
19
19
  # `Dir.exist?` is not the guard, because it follows symlinks and answers false for a dangling one, and
20
20
  # an entry a tool left in a directory's place is exactly what this has to remove.
21
+ #
22
+ # A tree that is gone by the time the removal fails is the outcome this wants, however it went. Two
23
+ # sweepers can meet on one discarded tree — the worker that answered on the slot and the supervisor's
24
+ # own — and the loser's walk fails on an entry the winner unlinked first.
21
25
  def self.remove_tree(path)
22
- return true unless File.exist?(path) || File.symlink?(path)
26
+ return true if gone?(path)
23
27
 
24
28
  FileUtils.remove_entry path
25
29
  true
@@ -27,12 +31,26 @@ module HotCell
27
31
  repair_and_remove path
28
32
  end
29
33
 
34
+ # `lstat` rather than `File.exist?`, which answers false for a path it cannot stat as well as for one that
35
+ # is gone. Only ENOENT means gone; a tree behind a directory a tool made unsearchable is still there.
36
+ def self.gone?(path)
37
+ File.lstat path
38
+ false
39
+ rescue Errno::ENOENT
40
+ true
41
+ rescue SystemCallError
42
+ false
43
+ end
44
+ private_class_method :gone?
45
+
30
46
  def self.repair_and_remove(path)
47
+ return true if gone?(path)
48
+
31
49
  FileUtils.chmod_R 0o700, path, force: true
32
50
  FileUtils.remove_entry path
33
51
  true
34
52
  rescue SystemCallError
35
- false
53
+ gone?(path)
36
54
  end
37
55
  private_class_method :repair_and_remove
38
56
  end
data/lib/hot_cell/log.rb CHANGED
@@ -35,11 +35,18 @@ module HotCell
35
35
  "worker.unforkable" => "ERROR",
36
36
  "worker.undispatchable" => "ERROR",
37
37
  "worker.unreadable_report" => "ERROR",
38
+ "sweeper.forked" => "INFO",
39
+ "sweeper.deadline" => "WARN",
40
+ "sweeper.unforkable" => "ERROR",
41
+ "sweeper.crashed" => "ERROR",
42
+ "scratch.swept" => "INFO",
38
43
  "control.abandoned" => "WARN",
39
44
  "control.unanswerable" => "WARN",
40
45
  "slot.uncleaned" => "WARN",
41
46
  "scratch.unswept" => "WARN",
42
47
  "slot.undiscarded" => "WARN",
48
+ "slot.unswept" => "WARN",
49
+ "sweeper.died" => "WARN",
43
50
  }.freeze
44
51
 
45
52
  def self.null
@@ -2,6 +2,6 @@
2
2
 
3
3
  module HotCell
4
4
  module Server
5
- VERSION = "0.4.1"
5
+ VERSION = "0.5.0"
6
6
  end
7
7
  end
@@ -15,6 +15,7 @@ require "hot_cell/timing"
15
15
  require "hot_cell/counters"
16
16
  require "hot_cell/control"
17
17
  require "hot_cell/worker"
18
+ require "hot_cell/sweeper"
18
19
  require "hot_cell/supervisor"
19
20
 
20
21
  module HotCell
data/lib/hot_cell/slot.rb CHANGED
@@ -95,7 +95,8 @@ module HotCell
95
95
  #
96
96
  # A rename within one filesystem is O(1) and takes the tree out of the way. A worker sweeps it later,
97
97
  # after it has answered and before it reports itself idle — see Worker#serve, which is the one window
98
- # where the unlinking costs nobody's latency.
98
+ # where the unlinking costs nobody's latency. A worker killed at its deadline never reaches that window,
99
+ # so the supervisor also forks a Sweeper on a timer, which unlinks in a process of its own.
99
100
  #
100
101
  # The destination carries a random suffix rather than a counter, because the tool that filled the
101
102
  # directory runs as this user and can write to the slot's directory. A predictable name lets it
@@ -133,12 +134,25 @@ module HotCell
133
134
  # Unlinks whatever discard_home renamed out of the way. Partial progress is fine: a sweep killed
134
135
  # part-way leaves fewer entries for the next one, so this converges rather than repeating.
135
136
  def sweep
136
- Dir.glob(File.join(directory, "discarded-*")).map { |path| Filesystem.remove_tree(path) }.all?
137
+ discarded.map { |path| Filesystem.remove_tree(path) }.all?
137
138
  rescue SystemCallError
138
139
  # The glob itself can fail, because the slot directory is a name a tool can replace — a symlink loop
139
140
  # in its place answers ELOOP here rather than for any one entry. This runs from the worker's ensure,
140
141
  # where a raise would replace the caller's response with a crash.
141
142
  false
142
143
  end
144
+
145
+ # What discard_home has renamed aside and nobody has unlinked yet.
146
+ def discarded
147
+ Dir.glob(File.join(directory, "discarded-*"))
148
+ end
149
+
150
+ # Streams the directory and stops at the first match, because the supervisor asks this in its loop and
151
+ # a tool can put as many entries beside the discarded ones as it likes; a glob would list and sort them all.
152
+ def discarded?
153
+ Dir.each_child(directory).any? { |name| name.start_with?("discarded-") }
154
+ rescue Errno::ENOENT
155
+ false
156
+ end
143
157
  end
144
158
  end
@@ -22,11 +22,27 @@ module HotCell
22
22
  # the accept anyway, for the queue, for queued_ms, and to answer `capacity`. It also means the supervisor
23
23
  # knows when every worker started its current request, which is what the deadline needs.
24
24
  class Supervisor
25
+ # The one deadline the supervisor enforces, on a worker and on the sweeper alike: from `started_at`,
26
+ # for `deadline` seconds, killed once. `killed_for` is the one-kill latch — a killed process stays here
27
+ # until the reap, and without the latch it would be re-killed and re-logged on every pass until then.
28
+ # See `Child#overdue?` for the measurement behind that.
29
+ module Timed
30
+ def overdue?(now)
31
+ timed? && now - started_at >= deadline
32
+ end
33
+
34
+ def expires_at
35
+ started_at + deadline if timed?
36
+ end
37
+ end
38
+
25
39
  # Owns "is this worker busy" and the two transitions that change the answer, because the supervisor asking
26
40
  # `busy?` and the supervisor assigning the four fields `busy?` is computed from are the same fact. Spread
27
41
  # across the caller, a new field is one the next transition forgets to clear.
28
42
  Child = Struct.new(:slot, :pid, :control, :connection, :dispatched_at, :deadline, :served, :killed_for,
29
43
  :op, :retired_at, :buffer, :stderr, :captured, keyword_init: true) do
44
+ include Timed
45
+
30
46
  def self.build(slot:, pid:, control:, deadline:, stderr: nil)
31
47
  new slot: slot, pid: pid, control: control, deadline: deadline, served: 0, buffer: "".b,
32
48
  stderr: stderr, captured: "".b
@@ -87,12 +103,12 @@ module HotCell
87
103
  # SIGKILLs and 72 synchronous stdout writes for one breach. The window is longest exactly when the host
88
104
  # is already struggling — a worker in uninterruptible sleep, or one tearing down gigabytes of mappings —
89
105
  # and the loop it starves is the one enforcing every other request's deadline.
90
- def overdue?(now)
91
- busy? && killed_for.nil? && now - dispatched_at >= deadline
106
+ def timed?
107
+ busy? && killed_for.nil?
92
108
  end
93
109
 
94
- def expires_at
95
- dispatched_at + deadline if busy? && killed_for.nil?
110
+ def started_at
111
+ dispatched_at
96
112
  end
97
113
 
98
114
  # The retirement analogue of `overdue?`, and the only timer that can reach a worker whose idle
@@ -111,6 +127,18 @@ module HotCell
111
127
  end
112
128
  end
113
129
 
130
+ # The one sweeper the supervisor runs at a time. Not a `Child`: it is dispatched no request, holds no
131
+ # slot and answers nobody, so none of `busy?`, retirement or the idle report applies to it. What it
132
+ # shares with a worker is the deadline, and that is `Timed` — the same measurement, the same latch, the
133
+ # same `deadline` value from the configuration, and the same kill.
134
+ Sweep = Struct.new(:pid, :started_at, :deadline, :killed_for, keyword_init: true) do
135
+ include Timed
136
+
137
+ def timed?
138
+ killed_for.nil?
139
+ end
140
+ end
141
+
114
142
  # A path longer than this fails to bind with an error that does not say so. Darwin allows four fewer
115
143
  # bytes than Linux, and control.sock is the longer of the two names, so it overflows first.
116
144
  SUN_PATH_MAX = RUBY_PLATFORM.include?("darwin") ? 104 : 108
@@ -151,6 +179,8 @@ module HotCell
151
179
  @control_pending = []
152
180
  @counters = Counters.new
153
181
  @stopping = false
182
+ @sweep = nil
183
+ @next_sweep_at = Clock.now + configuration.sweep_interval
154
184
  end
155
185
 
156
186
  def boot
@@ -178,10 +208,12 @@ module HotCell
178
208
 
179
209
  enforce_deadlines
180
210
  enforce_retirements
211
+ enforce_sweep_deadline
181
212
  expire_queue
182
213
  expire_control
183
214
  retire_idle if @stopping
184
215
  pump
216
+ sweep_if_due
185
217
  end
186
218
  ensure
187
219
  shutdown
@@ -204,13 +236,16 @@ module HotCell
204
236
  end
205
237
 
206
238
  # The nearest thing that needs doing without anybody knocking: a deadline, a queued connection that
207
- # has waited long enough to be told so, or a control client that never said what it wanted.
239
+ # has waited long enough to be told so, a control client that never said what it wanted, or the
240
+ # sweeper's next tick.
208
241
  def wait_for
209
242
  now = Clock.now
210
243
  nearest = [ *@children.each_value.filter_map(&:expires_at),
211
244
  *@children.each_value.filter_map { |child| child.lingers_until(Configuration::KILL_GRACE) },
212
245
  *@queue.map { |(_, queued_at)| queued_at + configuration.queue_wait },
213
- *@control_pending.map { |pending| pending.accepted_at + configuration.control_deadline } ].min
246
+ *@control_pending.map { |pending| pending.accepted_at + configuration.control_deadline },
247
+ @sweep&.expires_at,
248
+ (@next_sweep_at unless @stopping) ].compact.min
214
249
  return nil if nearest.nil?
215
250
 
216
251
  [ nearest - now, 0 ].max
@@ -459,12 +494,12 @@ module HotCell
459
494
  deadline: configuration.limits.deadline, stderr: stderr_reader)
460
495
  end
461
496
 
462
- # Everything the supervisor holds and the worker must not: the listener, the signal pipe, the other
463
- # children's control sockets, and every connection the supervisor is still holding for somebody else.
464
497
  # The connection this worker is about to serve arrives over SCM_RIGHTS a moment from now, so closing
465
- # the inherited copy here costs nothing and stops it lingering for the worker's whole life.
498
+ # the inherited copy in `leave_supervisor` costs nothing and stops it lingering for the worker's whole
499
+ # life.
466
500
  def become_worker(supervisor_side, stderr_reader, stderr_writer)
467
- [ "CHLD", "INT", "TERM" ].each { |signal| trap signal, "DEFAULT" }
501
+ leave_supervisor
502
+ supervisor_side.close
468
503
 
469
504
  # Its own process group, so the deadline reaches the tools this request started rather than only the
470
505
  # Ruby process that started them. A tool is a grandchild — the worker spawns it — and killing the
@@ -487,20 +522,6 @@ module HotCell
487
522
  # reasons that section records. Landlock is the candidate that fits, tracked at basecamp/hotcell#13.
488
523
  Process.setpgid 0, 0
489
524
 
490
- supervisor_side.close
491
- @signals.close
492
- @signal_writer.close
493
- @work.close
494
- @control.close
495
-
496
- @children.each_value do |child|
497
- child.control.close
498
- child.connection&.close
499
- child.stderr&.close
500
- end
501
- @queue.each { |(connection, _)| connection.close }
502
- @control_pending.each { |pending| pending.connection.close }
503
-
504
525
  # fd 2 becomes the pipe, and it stays non-blocking. `IO.pipe` already returns both ends O_NONBLOCK
505
526
  # and `reopen` is a dup2, which shares the file description — so the flag would ride along on its
506
527
  # own. It is set here anyway, because a decision this load-bearing should be in the code rather than
@@ -518,6 +539,80 @@ module HotCell
518
539
  stderr_writer.close
519
540
  end
520
541
 
542
+ # Everything the supervisor holds and a child of its must not: the listeners, the signal pipe, the
543
+ # children's control sockets, and every connection the supervisor is still holding for somebody else.
544
+ def leave_supervisor
545
+ [ "CHLD", "INT", "TERM" ].each { |signal| trap signal, "DEFAULT" }
546
+
547
+ @signals.close
548
+ @signal_writer.close
549
+ @work.close
550
+ @control.close
551
+
552
+ @children.each_value do |child|
553
+ child.control.close
554
+ child.connection&.close
555
+ child.stderr&.close
556
+ end
557
+ @queue.each { |(connection, _)| connection.close }
558
+ @control_pending.each { |pending| pending.connection.close }
559
+ end
560
+
561
+ # Ticks whether or not it forks, so a sweep that outlives an interval is left to its deadline rather
562
+ # than joined by a second one. Forking only when a slot holds a tree keeps an idle cell from paying
563
+ # for a child every interval; the glob it costs is the one `Slot#discard_home` already runs inline.
564
+ def sweep_if_due
565
+ return if @stopping || Clock.now < @next_sweep_at
566
+
567
+ @next_sweep_at = Clock.now + configuration.sweep_interval
568
+ spawn_sweeper if @sweep.nil? && discarded_anywhere?
569
+ end
570
+
571
+ # A glob that raises is a slot directory a tool replaced, and the sweeper is the process that reports
572
+ # that, so it is forked to find out.
573
+ def discarded_anywhere?
574
+ (0...configuration.concurrency).any? { |number| Slot.build(workspace, number).discarded? }
575
+ rescue SystemCallError
576
+ true
577
+ end
578
+
579
+ # Held to the cell's deadline: a tree that takes longer to unlink than a request is allowed to run is
580
+ # one an input built to, and the next tick sweeps what this one left. A fork that fails is the host
581
+ # under pressure, as in `spawn`; the trees wait for the next tick.
582
+ def spawn_sweeper
583
+ pid = fork do
584
+ leave_supervisor
585
+ Sweeper.new(workspace: workspace, configuration: configuration, log: log).run
586
+ end
587
+
588
+ log.write "sweeper.forked", pid: pid
589
+ @sweep = Sweep.new(pid: pid, started_at: Clock.now, deadline: configuration.limits.deadline)
590
+ rescue SystemCallError => error
591
+ log.write "sweeper.unforkable", error: error.class.name, message: error.message
592
+ end
593
+
594
+ # Kill first, log second, as `enforce_deadlines` does. The sweeper spawns nothing and leads no group,
595
+ # so `kill_group` reaches it through its fallback to the bare pid.
596
+ def enforce_sweep_deadline
597
+ return unless @sweep&.overdue?(Clock.now)
598
+
599
+ @sweep.killed_for = Codes::DEADLINE
600
+ kill_group @sweep
601
+ log.write "sweeper.deadline", pid: @sweep.pid, deadline_s: @sweep.deadline
602
+ end
603
+
604
+ # A sweeper this supervisor killed already has its `sweeper.deadline` line. Any other abnormal end —
605
+ # the OOM killer, a sibling's signal, a crash `Sweeper#run` could not catch — would otherwise leave
606
+ # only `sweeper.forked` behind, and look exactly like a sweep that finished.
607
+ def reap_sweeper(status)
608
+ unless @sweep.killed_for || status.success?
609
+ log.write "sweeper.died", pid: @sweep.pid, signal: signal_name(status), exit_code: status.exitstatus
610
+ end
611
+
612
+ @sweep = nil
613
+ end
614
+
615
+
521
616
  # One bounded read per pass, never a loop until the pipe is empty: this runs inside the loop that
522
617
  # enforces every request's deadline, and the peer is a worker that can print as fast as it likes.
523
618
  #
@@ -810,6 +905,11 @@ module HotCell
810
905
  pid, status = Process.wait2(-1, Process::WNOHANG)
811
906
  break if pid.nil?
812
907
 
908
+ if @sweep&.pid == pid
909
+ reap_sweeper status
910
+ next
911
+ end
912
+
813
913
  child = @children.each_value.find { |candidate| candidate.pid == pid }
814
914
  next if child.nil?
815
915
 
@@ -1142,6 +1242,7 @@ module HotCell
1142
1242
  refuse_queue "the cell is stopping"
1143
1243
  @control_pending.each { |pending| pending.connection.close }
1144
1244
  @children.each_value { |child| child.control.close unless child.control.socket.closed? }
1245
+ kill_group @sweep if @sweep
1145
1246
  @work&.close
1146
1247
  @control&.close
1147
1248
  SOCKETS.each { |name| File.unlink socket_path(name) if File.socket?(socket_path(name)) }
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HotCell
4
+ # Unlinks what the supervisor renamed aside, in a process of its own.
5
+ #
6
+ # How long a recursive delete takes is chosen by the input that filled the tree, so it runs in neither
7
+ # the supervisor, whose loop enforces every deadline, nor a worker's request. A worker sweeps its own
8
+ # slot after it has answered, but a worker killed at its deadline never reaches that ensure — and a slot
9
+ # whose every request is killed stacked one tree per kill until the scratch was full. This is the sweep
10
+ # that needs no request to run: the supervisor forks it on a timer, holds it to a deadline, and never
11
+ # runs two at once.
12
+ class Sweeper
13
+ def initialize(workspace:, configuration:, log:)
14
+ @workspace = workspace
15
+ @configuration = configuration
16
+ @log = log
17
+ end
18
+
19
+ # exit! for the reason Worker#run does: nothing inherited from the supervisor may run its teardown here.
20
+ #
21
+ # The cell's memory limit goes on first, and only that one. `FileUtils.remove_entry` lists a directory
22
+ # before it unlinks anything in it, so a tree one directory wide enough allocates in proportion to its
23
+ # width; RLIMIT_DATA makes that this process's NoMemoryError and a `sweeper.crashed` line. It is a bound
24
+ # on this process and not on the cell: the cgroup counts every worker and the tmpfs too, and can run out
25
+ # first. A sweep that dies this way makes no progress on that directory, and that is accepted here.
26
+ #
27
+ # Not `file_size`: this process writes nothing but log lines, and a log that is a regular file is past
28
+ # any worker's limit already, so the first line would have killed the sweeper with SIGXFSZ.
29
+ def run
30
+ configuration.limits.merge(file_size: nil, open_files: nil).apply
31
+ started = Clock.now
32
+ swept = slots.sum { |slot| sweep slot }
33
+
34
+ log.write "scratch.swept", pid: Process.pid, swept: swept, duration_ms: Clock.ms_since(started)
35
+ exit! 0
36
+ rescue Exception => error
37
+ log.write "sweeper.crashed", pid: Process.pid, error: error.class.name, message: Failure.sanitize(error.message)
38
+ exit! 1
39
+ end
40
+
41
+ private
42
+ attr_reader :workspace, :configuration, :log
43
+
44
+ def slots
45
+ (0...configuration.concurrency).map { |number| Slot.build(workspace, number) }
46
+ end
47
+
48
+ # A tree that would not go is the worker's `slot.unswept`, from this pid: the same fact, whoever
49
+ # noticed it. The glob can raise — the slot directory is a name a tool can replace — and that too is
50
+ # a slot left unswept rather than the end of the sweep.
51
+ def sweep(slot)
52
+ trees = slot.discarded
53
+ removed = trees.count { |path| Filesystem.remove_tree(path) }
54
+ report_unswept slot if removed < trees.size
55
+ removed
56
+ rescue SystemCallError
57
+ report_unswept slot
58
+ 0
59
+ end
60
+
61
+ def report_unswept(slot)
62
+ log.write "slot.unswept", pid: Process.pid, slot: slot.number, home: slot.directory
63
+ end
64
+ end
65
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hotcell-server
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Dalessio
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - '='
17
17
  - !ruby/object:Gem::Version
18
- version: 0.4.1
18
+ version: 0.5.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - '='
24
24
  - !ruby/object:Gem::Version
25
- version: 0.4.1
25
+ version: 0.5.0
26
26
  description: |
27
27
  Runs a HotCell container. A supervisor listens on two Unix sockets, forks a worker for each request,
28
28
  and enforces a wall clock deadline and resource limits on it. Write the work as a subclass of
@@ -52,6 +52,7 @@ files:
52
52
  - lib/hot_cell/server/version.rb
53
53
  - lib/hot_cell/slot.rb
54
54
  - lib/hot_cell/supervisor.rb
55
+ - lib/hot_cell/sweeper.rb
55
56
  - lib/hot_cell/test_cell.rb
56
57
  - lib/hot_cell/test_operations.rb
57
58
  - lib/hot_cell/timing.rb
@@ -62,8 +63,8 @@ licenses:
62
63
  - MIT
63
64
  metadata:
64
65
  homepage_uri: https://github.com/basecamp/hotcell
65
- source_code_uri: https://github.com/basecamp/hotcell/tree/v0.4.1/hotcell-server
66
- changelog_uri: https://github.com/basecamp/hotcell/blob/v0.4.1/CHANGELOG.md
66
+ source_code_uri: https://github.com/basecamp/hotcell/tree/v0.5.0/hotcell-server
67
+ changelog_uri: https://github.com/basecamp/hotcell/blob/v0.5.0/CHANGELOG.md
67
68
  bug_tracker_uri: https://github.com/basecamp/hotcell/issues
68
69
  rubygems_mfa_required: 'true'
69
70
  rdoc_options: []