foruiman 0.1.2

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.
@@ -0,0 +1,403 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "process"
4
+ require_relative "log_store"
5
+ require_relative "output"
6
+
7
+ # Derived from Foreman's registration, process lookup, pipes and self-pipe signal
8
+ # handling. All process state and output now belong to the caller's event loop.
9
+ class Foruiman::Engine
10
+ HANDLED_SIGNALS = %i[INT TERM HUP].freeze
11
+ TERM_TIMEOUT = 5.0
12
+ READ_CHUNK = 4096
13
+ READ_BUDGET = 64 * 1024
14
+ State = Struct.new(:name, :process, :port, :pid, :pgid, :status, :exit_status,
15
+ :generation, :restart_pending, :deadline, :reaped, :group_gone,
16
+ keyword_init: true)
17
+ Event = Data.define(:type, :name, :pid, :status, :record, :message)
18
+
19
+ attr_reader :logs, :env, :processes, :root, :procfile_path
20
+
21
+ def initialize(procfile: nil, root: Dir.pwd, env: ENV.to_h, port: 5000, log_lines: 10_000,
22
+ term_timeout: TERM_TIMEOUT)
23
+ raise Foruiman::Error, "port must be an integer in 1..65535" unless port.is_a?(Integer) && (1..65_535).cover?(port)
24
+ raise Foruiman::Error, "log-lines must be a positive integer" unless log_lines.is_a?(Integer) && log_lines.positive?
25
+
26
+ @root = File.expand_path(root)
27
+ raise Foruiman::Error, "working directory does not exist: #{@root}" unless File.directory?(@root)
28
+
29
+ @env = env.dup.freeze
30
+ @base_port = port
31
+ @log_lines = log_lines
32
+ @term_timeout = term_timeout
33
+ @processes = []
34
+ @names = {}
35
+ @running = {}
36
+ @readers = {}
37
+ @listeners = []
38
+ @shutdown = false
39
+ @explicit_shutdown = false
40
+ @failed = false
41
+ @closed = false
42
+ @started = false
43
+ @signal_requested = false
44
+ @self_reader, @self_writer = create_pipe
45
+ load_procfile(procfile) if procfile
46
+ rescue StandardError
47
+ @self_reader&.close
48
+ @self_writer&.close
49
+ raise
50
+ end
51
+
52
+ def register(name, command)
53
+ raise Foruiman::Error, "cannot register after startup" if @started
54
+ raise Foruiman::Error, "duplicate process: #{name}" if @names.key?(name)
55
+ raise Foruiman::Error, "allocated port exceeds 65535 for #{name}" if @base_port + (processes.size * 100) > 65_535
56
+
57
+ Foruiman::Procfile.new[name] = command
58
+ process = Foruiman::Process.new(command, cwd: root, env: env)
59
+ state = State.new(name: name.freeze, process: process, port: @base_port + (processes.size * 100),
60
+ status: :pending, generation: 0, restart_pending: false, reaped: true, group_gone: true)
61
+ @names[name] = state
62
+ processes << state
63
+ state
64
+ end
65
+
66
+ def load_procfile(filename)
67
+ parsed = Foruiman::Procfile.new(filename)
68
+ entries = parsed.entries.to_a
69
+ last_port = @base_port + ((processes.size + entries.size - 1) * 100)
70
+ raise Foruiman::Error, "allocated port #{last_port} exceeds 65535" if last_port > 65_535
71
+
72
+ entries.each { |name, command| register(name, command) }
73
+ @procfile_path = File.expand_path(filename).freeze
74
+ self
75
+ end
76
+
77
+ def process_names
78
+ @names.keys
79
+ end
80
+
81
+ def process(name)
82
+ @names[name]&.process
83
+ end
84
+
85
+ def state(name)
86
+ @names.fetch(name)
87
+ end
88
+
89
+ def select(name)
90
+ raise Foruiman::Error, "cannot select after startup" if @started
91
+ raise Foruiman::Error, "unknown process: #{name}" unless @names.key?(name)
92
+
93
+ @names.select! { |key, _entry| key == name }
94
+ processes.select! { |entry| entry.name == name }
95
+ self
96
+ end
97
+
98
+ def on_event(&listener)
99
+ @listeners << listener
100
+ self
101
+ end
102
+
103
+ def start(name = nil)
104
+ raise Foruiman::Error, "supervisor is closed" if @closed
105
+ return if @shutdown
106
+
107
+ targets = name ? [state(name)] : processes
108
+ unless @started
109
+ @logs = Foruiman::LogStore.new(process_names, capacity: @log_lines) do |record|
110
+ emit(:output, state(record.name), record: record)
111
+ end
112
+ @started = true
113
+ end
114
+ targets.each { |entry| spawn_process(entry) unless entry.pgid }
115
+ self
116
+ end
117
+
118
+ alias start_all start
119
+
120
+ def restart(name)
121
+ return if @shutdown || @closed
122
+
123
+ return start(name) unless @started
124
+
125
+ entry = state(name)
126
+ return if entry.restart_pending
127
+
128
+ entry.restart_pending = true
129
+ entry.status = :restarting
130
+ lifecycle(entry, :restarting, "restarting")
131
+ if entry.pgid
132
+ terminate(entry)
133
+ else
134
+ spawn_process(entry)
135
+ end
136
+ end
137
+
138
+ def stop(name)
139
+ entry = state(name)
140
+ entry.restart_pending = false
141
+ return unless entry.pgid
142
+
143
+ entry.status = :stopping
144
+ lifecycle(entry, :stopping, "stopping")
145
+ terminate(entry)
146
+ end
147
+
148
+ def shutdown(explicit: true)
149
+ @explicit_shutdown ||= explicit
150
+ return if @shutdown
151
+
152
+ @shutdown = true
153
+ processes.each do |entry|
154
+ entry.restart_pending = false
155
+ stop(entry.name)
156
+ end
157
+ end
158
+
159
+ def shutting_down?
160
+ @shutdown
161
+ end
162
+
163
+ def finished?
164
+ @started && processes.all? { |entry| !entry.pgid && !entry.restart_pending } && @readers.empty?
165
+ end
166
+
167
+ def exit_code
168
+ @explicit_shutdown || !@failed ? 0 : 1
169
+ end
170
+
171
+ def run(keep_open: false)
172
+ register_signal_handlers
173
+ start_all
174
+ loop do
175
+ tick(timeout: keep_open ? 1.0 / 30 : 0.05)
176
+ yield self if block_given?
177
+ break if finished? && (!keep_open || shutting_down?)
178
+ end
179
+ exit_code
180
+ ensure
181
+ close
182
+ end
183
+
184
+ # Embedders may drive this method directly; start/restart/stop are called on
185
+ # that same thread. A bounded round-robin read prevents output starving input.
186
+ def tick(timeout: 0.03)
187
+ return if @closed
188
+
189
+ shutdown if @signal_requested
190
+ reap_children
191
+ advance_groups
192
+ ready = IO.select([@self_reader, *@readers.keys], nil, nil, timeout)&.first || []
193
+ drain_signal_pipe if ready.delete(@self_reader)
194
+ shutdown if @signal_requested
195
+ read_output(ready)
196
+ reap_children
197
+ advance_groups
198
+ rescue Errno::EINTR
199
+ # The next tick handles the deferred signal.
200
+ end
201
+
202
+ def close
203
+ return if @closed
204
+
205
+ # Observers can fail (e.g. a closed stdout). Cleanup must still own the loop.
206
+ @listeners.clear
207
+ shutdown(explicit: false)
208
+ tick(timeout: 0.01) until !@started || finished?
209
+ ensure
210
+ restore_signal_handlers
211
+ @self_reader.close unless @self_reader.closed?
212
+ @self_writer.close unless @self_writer.closed?
213
+ @closed = true
214
+ end
215
+
216
+ private
217
+
218
+ def create_pipe
219
+ IO.pipe("BINARY").each { |io| io.close_on_exec = true }
220
+ end
221
+
222
+ def spawn_process(entry)
223
+ return if @shutdown
224
+
225
+ stdout_reader, stdout_writer = create_pipe
226
+ stderr_reader, stderr_writer = create_pipe
227
+ begin
228
+ pid = entry.process.run(output: stdout_writer, error: stderr_writer,
229
+ env: { "PORT" => entry.port.to_s, "PS" => "#{entry.name}.1" })
230
+ rescue SystemCallError => e
231
+ stdout_reader.close
232
+ stderr_reader.close
233
+ entry.status = :failed
234
+ entry.restart_pending = false
235
+ @failed = true
236
+ lifecycle(entry, :failed, "failed to start: #{e.message}")
237
+ return
238
+ end
239
+ entry.pid = entry.pgid = pid
240
+ entry.status = :running
241
+ entry.exit_status = nil
242
+ entry.generation += 1
243
+ entry.restart_pending = false
244
+ entry.deadline = nil
245
+ entry.reaped = entry.group_gone = false
246
+ @running[pid] = entry
247
+ [[stdout_reader, :stdout], [stderr_reader, :stderr]].each do |reader, stream|
248
+ @readers[reader] = [entry, Foruiman::Output.new(logs, name: entry.name, stream: stream, pid: pid)]
249
+ end
250
+ lifecycle(entry, :started, "started with pid #{pid} (generation #{entry.generation})")
251
+ ensure
252
+ stdout_writer&.close
253
+ stderr_writer&.close
254
+ [stdout_reader, stderr_reader].compact.each do |reader|
255
+ reader.close unless reader.closed? || @readers.key?(reader)
256
+ end
257
+ end
258
+
259
+ def terminate(entry)
260
+ return if entry.deadline || entry.group_gone
261
+
262
+ signal_group(entry, :TERM)
263
+ entry.deadline = monotonic + @term_timeout
264
+ end
265
+
266
+ def signal_group(entry, signal)
267
+ ::Process.kill(signal, -entry.pgid) if entry.pgid
268
+ rescue Errno::ESRCH
269
+ entry.group_gone = true
270
+ end
271
+
272
+ def reap_children
273
+ @running.keys.each do |pid| # rubocop:disable Style/HashEachMethods -- observers can add processes
274
+ result = ::Process.waitpid2(pid, ::Process::WNOHANG)
275
+ next unless result
276
+
277
+ entry = @running.delete(pid)
278
+ entry.reaped = true
279
+ entry.exit_status = result.last
280
+ success = entry.exit_status.success?
281
+ @failed ||= !success && !%i[stopping restarting].include?(entry.status)
282
+ entry.status = success ? :exited : :failed unless %i[stopping restarting].include?(entry.status)
283
+ lifecycle(entry, :exited, termination_message_for(entry.exit_status))
284
+ terminate(entry)
285
+ end
286
+ end
287
+
288
+ def advance_groups
289
+ processes.each do |entry|
290
+ next unless entry.pgid
291
+ next unless entry.reaped || entry.deadline
292
+
293
+ entry.group_gone ||= !group_alive?(entry.pgid)
294
+ if !entry.group_gone && entry.deadline && monotonic >= entry.deadline
295
+ signal_group(entry, :KILL)
296
+ lifecycle(entry, :killed, "sent SIGKILL after TERM timeout")
297
+ entry.deadline = nil
298
+ end
299
+ next unless entry.reaped && entry.group_gone
300
+ next if @readers.any? { |_reader, (owner, _output)| owner.equal?(entry) }
301
+
302
+ entry.pgid = nil
303
+ entry.deadline = nil
304
+ entry.status = :stopped if entry.status == :stopping
305
+ spawn_process(entry) if entry.restart_pending && !@shutdown
306
+ end
307
+ end
308
+
309
+ def group_alive?(pgid)
310
+ ::Process.kill(0, -pgid)
311
+ return true unless RUBY_PLATFORM.include?("linux")
312
+
313
+ # Linux containers may leave orphan zombies unreaped under PID 1. They
314
+ # cannot run or receive signals, and must not hold shutdown open forever.
315
+ Dir.glob("/proc/[0-9]*/stat").any? do |filename|
316
+ fields = File.read(filename).rpartition(") ").last.split
317
+ fields[2].to_i == pgid && !%w[Z X].include?(fields[0])
318
+ rescue Errno::ENOENT, Errno::ESRCH, Errno::EACCES
319
+ false
320
+ end
321
+ rescue Errno::ESRCH
322
+ false
323
+ end
324
+
325
+ def read_output(ready)
326
+ budget = READ_BUDGET
327
+ ready.each do |reader|
328
+ break if budget <= 0
329
+
330
+ entry, output = @readers.fetch(reader)
331
+ bytes = reader.read_nonblock(READ_CHUNK, exception: false)
332
+ if bytes.nil? || (bytes == :wait_readable && entry.group_gone)
333
+ @readers.delete(reader)
334
+ reader.close
335
+ output.feed("", eof: true)
336
+ elsif bytes != :wait_readable
337
+ budget -= bytes.bytesize
338
+ output.feed(bytes)
339
+ # Rotate serviced readers to the back for the next select.
340
+ @readers[reader] = @readers.delete(reader)
341
+ end
342
+ end
343
+ # A daemon that escaped the group may still hold a pipe open. Drain available
344
+ # bytes, but do not wait on it once every owned group member has finished.
345
+ @readers.keys.each do |reader| # rubocop:disable Style/HashEachMethods -- observers can add readers
346
+ entry, output = @readers.fetch(reader)
347
+ next unless entry.group_gone && !ready.include?(reader)
348
+
349
+ @readers.delete(reader)
350
+ reader.close
351
+ output.feed("", eof: true)
352
+ end
353
+ end
354
+
355
+ def lifecycle(entry, type, message)
356
+ logs&.write(name: entry.name, stream: :lifecycle, pid: entry.pid,
357
+ text: "--- #{message} ---", complete: true)
358
+ emit(type, entry, message: message)
359
+ end
360
+
361
+ def emit(type, entry, record: nil, message: nil)
362
+ event = Event.new(type: type, name: entry.name, pid: entry.pid, status: entry.status,
363
+ record: record, message: message&.freeze)
364
+ @listeners.each { |listener| listener.call(event) }
365
+ end
366
+
367
+ def termination_message_for(status)
368
+ if status.exited?
369
+ "exited with code #{status.exitstatus}"
370
+ else
371
+ "terminated by SIG#{Signal.list.key(status.termsig)}"
372
+ end
373
+ end
374
+
375
+ def register_signal_handlers
376
+ @old_handlers = {}
377
+ HANDLED_SIGNALS.each do |signal|
378
+ @old_handlers[signal] = Signal.trap(signal) do
379
+ @signal_requested = true
380
+ notice_signal
381
+ end
382
+ end
383
+ end
384
+
385
+ def restore_signal_handlers
386
+ @old_handlers&.each { |signal, handler| Signal.trap(signal, handler) }
387
+ @old_handlers = nil
388
+ end
389
+
390
+ def notice_signal
391
+ @self_writer.write_nonblock(".", exception: false)
392
+ rescue Errno::EINTR
393
+ retry
394
+ end
395
+
396
+ def drain_signal_pipe
397
+ @self_reader.read_nonblock(4096, exception: false)
398
+ end
399
+
400
+ def monotonic
401
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
402
+ end
403
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Foreman's assignment and quoting rules, with a non-mutating merge API.
4
+ class Foruiman::Env
5
+ def initialize(filename)
6
+ @entries = File.read(filename).gsub("\r\n", "\n").split("\n").each_with_object({}) do |line, result|
7
+ next unless (match = line.match(/\A([A-Za-z_0-9]+)=(.*)\z/))
8
+
9
+ key, value = match.captures
10
+ result[key] = case value
11
+ when /\A'(.*)'\z/ then Regexp.last_match(1)
12
+ when /\A"(.*)"\z/ then Regexp.last_match(1).gsub('\\n', "\n").gsub(/\\(.)/, '\1')
13
+ else value
14
+ end
15
+ end
16
+ end
17
+
18
+ def entries(&block)
19
+ return @entries.each unless block
20
+
21
+ @entries.each(&block)
22
+ end
23
+
24
+ def to_h
25
+ @entries.dup
26
+ end
27
+
28
+ def self.load(root: Dir.pwd, file: nil, dotenv: true, inherited: ENV.to_h)
29
+ env = inherited.dup
30
+ default = File.join(root, ".env")
31
+ env.merge!(new(default).to_h) if dotenv && File.file?(default)
32
+ env.merge!(new(file).to_h) if file
33
+ env.transform_values { |value| value.dup.freeze }.freeze
34
+ end
35
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ring"
4
+
5
+ class Foruiman::LogStore
6
+ Record = Data.define(:sequence, :name, :stream, :pid, :time, :text, :complete)
7
+ attr_reader :all
8
+
9
+ def initialize(names, capacity: 10_000, &listener)
10
+ @all = Foruiman::Ring.new(capacity)
11
+ @buffers = names.to_h { |name| [name, Foruiman::Ring.new(capacity)] }
12
+ @sequence = 0
13
+ @listener = listener
14
+ end
15
+
16
+ def [](name)
17
+ name == "all" ? all : @buffers.fetch(name)
18
+ end
19
+
20
+ def write(name:, stream:, pid:, text:, complete:, previous: nil)
21
+ @sequence += 1 unless previous
22
+ record = Record.new(sequence: previous ? previous.sequence : @sequence, name: name,
23
+ stream: stream, pid: pid, time: previous ? previous.time : Time.now.freeze,
24
+ text: text.dup.freeze, complete: complete)
25
+ method = previous ? :replace : :append
26
+ @buffers.fetch(name).public_send(method, record)
27
+ all.public_send(method, record)
28
+ @listener&.call(record)
29
+ record
30
+ end
31
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ansi"
4
+
5
+ class Foruiman::Output
6
+ MAX_BYTES = 16 * 1024
7
+
8
+ def initialize(logs, name:, stream:, pid:)
9
+ @logs = logs
10
+ @metadata = { name: name, stream: stream, pid: pid }
11
+ @decoder = Foruiman::ANSI::Decoder.new
12
+ @styles = Foruiman::ANSI::Styles.new
13
+ @text = +""
14
+ @previous = nil
15
+ @dirty = false
16
+ @split_boundary = false
17
+ end
18
+
19
+ def feed(bytes, eof: false)
20
+ decoded = @decoder.feed(bytes, eof: eof)
21
+ decoded.scan(/\e\[[0-9;:]*m|\n|[^\e\n]+/).each do |token|
22
+ if token == "\n"
23
+ if @split_boundary && !@dirty && !@previous
24
+ @split_boundary = false
25
+ else
26
+ publish(true)
27
+ end
28
+ elsif token.start_with?("\e[")
29
+ split_record if @text.bytesize + token.bytesize > MAX_BYTES
30
+ @text << token
31
+ @styles.apply(token)
32
+ @dirty = true
33
+ else
34
+ append_text(token)
35
+ end
36
+ end
37
+ if eof
38
+ publish(true) if @dirty || @previous
39
+ elsif @dirty
40
+ publish(false)
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def append_text(text)
47
+ until text.empty?
48
+ available = MAX_BYTES - @text.bytesize
49
+ piece = text.byteslice(0, available)
50
+ piece = piece.byteslice(0, piece.bytesize - 1) until piece.valid_encoding?
51
+ if piece.empty?
52
+ split_record
53
+ next
54
+ end
55
+ @text << piece
56
+ @dirty = true
57
+ text = text.byteslice(piece.bytesize..)
58
+ split_record if @text.bytesize == MAX_BYTES
59
+ end
60
+ end
61
+
62
+ def split_record
63
+ publish(true)
64
+ @split_boundary = true
65
+ end
66
+
67
+ def publish(complete)
68
+ @split_boundary = false
69
+ @previous = @logs.write(**@metadata, text: @text, complete: complete, previous: @previous)
70
+ @dirty = false
71
+ return unless complete
72
+
73
+ @text = +@styles.prefix
74
+ @previous = nil
75
+ end
76
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Foruiman::Plain
4
+ def initialize(engine, output: $stdout)
5
+ @engine = engine
6
+ @output = output
7
+ end
8
+
9
+ def run
10
+ @engine.on_event do |event|
11
+ record = event.record
12
+ next unless record&.complete
13
+
14
+ text = record.text.gsub(Foruiman::ANSI::SGR, "")
15
+ @output.puts "#{record.time.strftime('%H:%M:%S')} #{record.name} [#{record.stream}] | #{text}"
16
+ @output.flush
17
+ end
18
+ @engine.run
19
+ rescue Errno::EPIPE
20
+ 0
21
+ end
22
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Adapted from Foreman::Process. Expansion belongs to the shell, not Ruby.
4
+ class Foruiman::Process
5
+ attr_reader :command, :env, :cwd
6
+
7
+ def initialize(command, options = {})
8
+ @command = command.dup.freeze
9
+ @env = (options[:env] || ENV.to_h).dup.freeze
10
+ @cwd = File.expand_path(options[:cwd] || Dir.pwd)
11
+ end
12
+
13
+ def run(options = {})
14
+ ::Process.spawn(env.merge(options.fetch(:env, {})), "/bin/sh", "-c", command,
15
+ chdir: cwd, in: File::NULL, out: options.fetch(:output, $stdout),
16
+ err: options.fetch(:error, $stderr), pgroup: true, unsetenv_others: true,
17
+ close_others: true)
18
+ end
19
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Derived from Foreman's ordered Procfile reader/writer; see docs/UPSTREAM.md.
4
+ class Foruiman::Procfile
5
+ class ParseError < Foruiman::Error
6
+ end
7
+
8
+ class EmptyFileError < ParseError
9
+ end
10
+
11
+ def initialize(filename = nil)
12
+ @entries = []
13
+ load(filename) if filename
14
+ end
15
+
16
+ def entries(&block)
17
+ return @entries.each unless block
18
+
19
+ @entries.each(&block)
20
+ end
21
+
22
+ def [](name)
23
+ @entries.find { |key, _command| key == name }&.last
24
+ end
25
+
26
+ def []=(name, command)
27
+ validate_entry!(name, command, "Procfile entry")
28
+ delete(name)
29
+ @entries << [name.freeze, command.freeze].freeze
30
+ end
31
+
32
+ def delete(name)
33
+ @entries.reject! { |key, _command| key == name }
34
+ end
35
+
36
+ def load(filename)
37
+ parsed = parse(filename)
38
+ raise EmptyFileError, "#{filename}: no processes defined" if parsed.empty?
39
+
40
+ @entries.replace(parsed)
41
+ self
42
+ end
43
+
44
+ def save(filename)
45
+ File.write(filename, "#{self}\n")
46
+ end
47
+
48
+ def to_s
49
+ @entries.map { |name, command| "#{name}: #{command}" }.join("\n")
50
+ end
51
+
52
+ private
53
+
54
+ def validate_entry!(name, command, location)
55
+ unless name.match?(/\A[A-Za-z0-9_-]+\z/) && !command.strip.empty? && !command.include?("\0")
56
+ raise ParseError, "#{location}: expected NAME: command"
57
+ end
58
+ raise ParseError, "#{location}: 'all' is reserved for the aggregate tab" if name == "all"
59
+ end
60
+
61
+ def parse(filename)
62
+ seen = {}
63
+ File.read(filename, encoding: "UTF-8").lines.filter_map.with_index(1) do |line, number|
64
+ line = line.delete_suffix("\n").delete_suffix("\r")
65
+ next if line.strip.empty? || line.lstrip.start_with?("#")
66
+
67
+ location = "#{filename}:#{number}"
68
+ match = line.match(/\A([A-Za-z0-9_-]+):[ \t]*(.*)\z/)
69
+ raise ParseError, "#{location}: expected NAME: command" unless match
70
+
71
+ name, command = match.captures
72
+ validate_entry!(name, command, location)
73
+ raise ParseError, "#{location}: duplicate '#{name}' (first defined on line #{seen[name]})" if seen[name]
74
+
75
+ seen[name] = number
76
+ [name.freeze, command.freeze].freeze
77
+ end
78
+ rescue ArgumentError => e
79
+ raise ParseError, "#{filename}: #{e.message}"
80
+ end
81
+ end