fluentd 1.19.3 → 1.20.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.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +46 -0
  3. data/CHANGELOG.md +120 -0
  4. data/GithubWorkflow.md +1 -1
  5. data/MAINTAINERS.md +2 -2
  6. data/README.md +3 -2
  7. data/Rakefile +2 -1
  8. data/SECURITY.md +26 -1
  9. data/lib/fluent/command/cat.rb +3 -3
  10. data/lib/fluent/compat/exec_util.rb +20 -5
  11. data/lib/fluent/config/basic_parser.rb +0 -1
  12. data/lib/fluent/config/literal_parser.rb +22 -10
  13. data/lib/fluent/config/types.rb +10 -2
  14. data/lib/fluent/config/yaml_parser/loader.rb +12 -1
  15. data/lib/fluent/config/yaml_parser/parser.rb +14 -13
  16. data/lib/fluent/configurable.rb +1 -0
  17. data/lib/fluent/daemon.rb +2 -1
  18. data/lib/fluent/env.rb +1 -0
  19. data/lib/fluent/event.rb +37 -0
  20. data/lib/fluent/load.rb +0 -1
  21. data/lib/fluent/log.rb +2 -2
  22. data/lib/fluent/plugin/base.rb +7 -2
  23. data/lib/fluent/plugin/buf_file.rb +3 -3
  24. data/lib/fluent/plugin/buf_file_single.rb +6 -2
  25. data/lib/fluent/plugin/buffer/chunk.rb +14 -6
  26. data/lib/fluent/plugin/buffer/file_chunk.rb +28 -13
  27. data/lib/fluent/plugin/buffer/file_single_chunk.rb +28 -13
  28. data/lib/fluent/plugin/buffer.rb +125 -41
  29. data/lib/fluent/plugin/compressable.rb +2 -2
  30. data/lib/fluent/plugin/extractor.rb +17 -6
  31. data/lib/fluent/plugin/filter_record_transformer.rb +2 -1
  32. data/lib/fluent/plugin/formatter_out_file.rb +3 -4
  33. data/lib/fluent/plugin/in_exec.rb +6 -4
  34. data/lib/fluent/plugin/in_forward.rb +39 -29
  35. data/lib/fluent/plugin/in_http.rb +83 -2
  36. data/lib/fluent/plugin/in_monitor_agent.rb +2 -1
  37. data/lib/fluent/plugin/in_object_space.rb +2 -2
  38. data/lib/fluent/plugin/in_sample.rb +2 -1
  39. data/lib/fluent/plugin/in_syslog.rb +72 -6
  40. data/lib/fluent/plugin/in_tail/compatibility.rb +111 -0
  41. data/lib/fluent/plugin/in_tail/dir_watcher.rb +713 -0
  42. data/lib/fluent/plugin/in_tail/group_watch.rb +13 -13
  43. data/lib/fluent/plugin/in_tail/io_handler.rb +391 -0
  44. data/lib/fluent/plugin/in_tail/line_feeder.rb +230 -0
  45. data/lib/fluent/plugin/in_tail/stat_watcher.rb +52 -0
  46. data/lib/fluent/plugin/in_tail/tail_watcher.rb +276 -0
  47. data/lib/fluent/plugin/in_tail/worker_pool.rb +298 -0
  48. data/lib/fluent/plugin/in_tail.rb +324 -815
  49. data/lib/fluent/plugin/in_unix.rb +5 -3
  50. data/lib/fluent/plugin/out_exec_filter.rb +2 -2
  51. data/lib/fluent/plugin/out_file.rb +10 -4
  52. data/lib/fluent/plugin/out_forward/connection_manager.rb +18 -0
  53. data/lib/fluent/plugin/out_forward.rb +21 -2
  54. data/lib/fluent/plugin/out_http.rb +0 -10
  55. data/lib/fluent/plugin/output.rb +81 -31
  56. data/lib/fluent/plugin/parser_csv.rb +7 -2
  57. data/lib/fluent/plugin/parser_json.rb +25 -12
  58. data/lib/fluent/plugin/parser_ltsv.rb +2 -4
  59. data/lib/fluent/plugin/parser_msgpack.rb +7 -6
  60. data/lib/fluent/plugin/parser_syslog.rb +55 -8
  61. data/lib/fluent/plugin/parser_tsv.rb +6 -1
  62. data/lib/fluent/plugin/sd_file.rb +2 -1
  63. data/lib/fluent/plugin/storage_local.rb +2 -2
  64. data/lib/fluent/plugin_helper/http_server/server.rb +29 -13
  65. data/lib/fluent/plugin_helper/http_server.rb +2 -1
  66. data/lib/fluent/root_agent.rb +1 -1
  67. data/lib/fluent/supervisor.rb +32 -6
  68. data/lib/fluent/system_config.rb +5 -2
  69. data/lib/fluent/time.rb +8 -7
  70. data/lib/fluent/version.rb +1 -1
  71. data/templates/new_gem/.gitignore +8 -0
  72. metadata +41 -21
@@ -0,0 +1,52 @@
1
+ #
2
+ # Fluentd
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+ #
16
+
17
+ require 'fluent/plugin/input'
18
+ require 'cool.io'
19
+
20
+ module Fluent::Plugin
21
+ class TailInput < Fluent::Plugin::Input
22
+ class StatWatcher < Coolio::StatWatcher
23
+ def initialize(path, log, &callback)
24
+ @callback = callback
25
+ @log = log
26
+ super(path)
27
+ end
28
+
29
+ def on_change(prev, cur)
30
+ @callback.call
31
+ rescue
32
+ @log.error $!.to_s
33
+ @log.error_backtrace
34
+ end
35
+ end
36
+
37
+ class TimerTrigger < Coolio::TimerWatcher
38
+ def initialize(interval, log, repeat: true, &callback)
39
+ @log = log
40
+ @callback = callback
41
+ super(interval, repeat)
42
+ end
43
+
44
+ def on_timer
45
+ @callback.call
46
+ rescue => e
47
+ @log.error e.to_s
48
+ @log.error_backtrace
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,276 @@
1
+ #
2
+ # Fluentd
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+ #
16
+
17
+ require 'fluent/plugin/input'
18
+ require 'fluent/file_wrapper'
19
+ require 'fluent/plugin/in_tail/position_file'
20
+ require 'fluent/plugin/in_tail/compatibility'
21
+
22
+ module Fluent::Plugin
23
+ class TailInput < Fluent::Plugin::Input
24
+ class TailWatcher
25
+ def initialize(target_info, pe, log, read_from_head, follow_inodes, update_watcher, file_feed, io_handler_build, metrics)
26
+ @path = target_info.path
27
+ @ino = target_info.ino
28
+ @pe = pe || MemoryPositionEntry.new
29
+ @read_from_head = read_from_head
30
+ @follow_inodes = follow_inodes
31
+ @update_watcher = update_watcher
32
+ @log = log
33
+ @rotate_handler = RotateHandler.new(log, &method(:on_rotate))
34
+ @file_feed = file_feed
35
+ @io_handler = nil
36
+ @io_handler_build = io_handler_build
37
+ @metrics = metrics
38
+ @watchers = []
39
+ @detached = false
40
+ @close_pending = false
41
+ end
42
+
43
+ attr_reader :path, :ino
44
+
45
+ def detached?
46
+ @detached
47
+ end
48
+ attr_reader :pe
49
+ attr_reader :file_feed
50
+ attr_accessor :unwatched # This is used for removing position entry from PositionFile
51
+ attr_reader :watchers
52
+ attr_accessor :group_watcher
53
+
54
+ def tag
55
+ @parsed_tag ||= @path.tr('/', '.').squeeze('.').gsub(/^\./, '')
56
+ end
57
+
58
+ def register_watcher(watcher)
59
+ @watchers << watcher
60
+ end
61
+
62
+ def detach(shutdown_start_time = nil)
63
+ @detached = true
64
+ if @io_handler
65
+ ready_to_shutdown(shutdown_start_time)
66
+ @io_handler.on_notify
67
+ end
68
+ @file_feed&.close(self)
69
+ end
70
+
71
+ def ready_to_shutdown(shutdown_start_time)
72
+ @shutdown_start_time = shutdown_start_time
73
+ @io_handler.ready_to_shutdown(shutdown_start_time) if @io_handler
74
+ end
75
+
76
+ def close
77
+ if @io_handler&.pending? || @close_pending
78
+ @close_pending = true
79
+ return
80
+ end
81
+
82
+ force_close
83
+ end
84
+
85
+ def force_close
86
+ @close_pending = false
87
+ if @io_handler
88
+ @io_handler.close
89
+ @io_handler = nil
90
+ end
91
+ end
92
+
93
+ def defer_close
94
+ @close_pending = true
95
+ end
96
+
97
+ def close_if_ready(force: false)
98
+ return false unless @close_pending && @io_handler && !@io_handler.pending?
99
+ return false unless force || @io_handler.drained?
100
+
101
+ force_close
102
+ true
103
+ end
104
+
105
+ def eof?
106
+ @io_handler.nil? || @io_handler.eof?
107
+ end
108
+
109
+ def drained?
110
+ @io_handler.nil? || @io_handler.drained?
111
+ end
112
+
113
+ def pending?
114
+ @io_handler&.pending? || false
115
+ end
116
+
117
+ def on_notify
118
+ if @io_handler&.pending?
119
+ @io_handler.on_notify
120
+ return
121
+ end
122
+
123
+ begin
124
+ stat = Fluent::FileWrapper.stat(@path)
125
+ rescue Errno::ENOENT, Errno::EACCES
126
+ # moved or deleted
127
+ stat = nil
128
+ end
129
+
130
+ @rotate_handler.on_notify(stat) if @rotate_handler
131
+ read_more
132
+ end
133
+
134
+ def complete_async(success, resume: true)
135
+ return false unless @io_handler
136
+
137
+ should_notify = @io_handler.complete_async(success)
138
+ if @detached
139
+ read_more if resume && (should_notify || success)
140
+ close_if_ready
141
+ else
142
+ on_notify if resume && should_notify
143
+ end
144
+ should_notify
145
+ end
146
+
147
+ def read_more
148
+ @file_feed&.on_notify(self)
149
+ @io_handler.on_notify if @io_handler
150
+ end
151
+
152
+ def on_rotate(stat)
153
+ if @io_handler.nil?
154
+ if stat
155
+ # first time
156
+ fsize = stat.size
157
+ inode = stat.ino
158
+
159
+ last_inode = @pe.read_inode
160
+ if inode == last_inode
161
+ # rotated file has the same inode number with the last file.
162
+ # assuming following situation:
163
+ # a) file was once renamed and backed, or
164
+ # b) symlink or hardlink to the same file is recreated
165
+ # in either case of a and b, seek to the saved position
166
+ # c) file was once renamed, truncated and then backed
167
+ # in this case, consider it truncated
168
+ @pe.update(inode, 0) if fsize < @pe.read_pos
169
+ elsif last_inode != 0
170
+ # this is FilePositionEntry and fluentd once started.
171
+ # read data from the head of the rotated file.
172
+ # logs never duplicate because this file is a rotated new file.
173
+ @pe.update(inode, 0)
174
+ else
175
+ # this is MemoryPositionEntry or this is the first time fluentd started.
176
+ # seek to the end of the any files.
177
+ # logs may duplicate without this seek because it's not sure the file is
178
+ # existent file or rotated new file.
179
+ pos = @read_from_head ? 0 : fsize
180
+ @pe.update(inode, pos)
181
+ end
182
+ @io_handler = io_handler
183
+ else
184
+ @io_handler = NullIOHandler.new
185
+ end
186
+ else
187
+ watcher_needs_update = false
188
+
189
+ if stat
190
+ inode = stat.ino
191
+ if inode == @pe.read_inode # truncated
192
+ @pe.update_pos(0)
193
+ @io_handler.close
194
+ elsif !@io_handler.opened? # There is no previous file. Reuse TailWatcher
195
+ @pe.update(inode, 0)
196
+ else # file is rotated and new file found
197
+ watcher_needs_update = true
198
+ # Handle the old log file before renewing TailWatcher [fluentd#1055]
199
+ @io_handler.on_notify
200
+ end
201
+ else # file is rotated and new file not found
202
+ # Clear RotateHandler to avoid duplicated file watch in same path.
203
+ @rotate_handler = nil
204
+ watcher_needs_update = true
205
+ end
206
+
207
+ if watcher_needs_update
208
+ if @follow_inodes
209
+ # If stat is nil (file not present), NEED to stop and discard this watcher.
210
+ # When the file is disappeared but is resurrected soon, then `#refresh_watcher`
211
+ # can't recognize this TailWatcher needs to be stopped.
212
+ # This can happens when the file is rotated.
213
+ # If a notify comes before the new file for the path is created during rotation,
214
+ # then it appears as if the file was resurrected once it disappeared.
215
+ # Don't want to swap state because we need latest read offset in pos file even after rotate_wait
216
+ @update_watcher.call(self, @pe, stat&.ino)
217
+ else
218
+ # Permit to handle if stat is nil (file not present).
219
+ # If a file is mv-ed and a new file is created during
220
+ # calling `#refresh_watchers`s, and `#refresh_watchers` won't run `#start_watchers`
221
+ # and `#stop_watchers()` for the path because `target_paths_hash`
222
+ # always contains the path.
223
+ @update_watcher.call(self, swap_state(@pe), stat&.ino)
224
+ end
225
+ else
226
+ @log.info "detected rotation of #{@path}"
227
+ @io_handler = io_handler
228
+ end
229
+ @metrics.rotated.inc
230
+ end
231
+ end
232
+
233
+ def io_handler
234
+ handler = @io_handler_build.call(self, @path)
235
+ handler.ready_to_shutdown(@shutdown_start_time) if @shutdown_start_time
236
+ handler
237
+ end
238
+
239
+ def swap_state(pe)
240
+ # Use MemoryPositionEntry for rotated file temporary
241
+ mpe = MemoryPositionEntry.new
242
+ mpe.update(pe.read_inode, pe.read_pos)
243
+ @pe = mpe
244
+ pe # This pe will be updated in on_rotate after TailWatcher is initialized
245
+ end
246
+
247
+ class RotateHandler
248
+ def initialize(log, &on_rotate)
249
+ @log = log
250
+ @inode = nil
251
+ @fsize = -1 # first
252
+ @on_rotate = on_rotate
253
+ end
254
+
255
+ def on_notify(stat)
256
+ if stat.nil?
257
+ inode = nil
258
+ fsize = 0
259
+ else
260
+ inode = stat.ino
261
+ fsize = stat.size
262
+ end
263
+
264
+ if @inode != inode || fsize < @fsize
265
+ @on_rotate.call(stat)
266
+ end
267
+ @inode = inode
268
+ @fsize = fsize
269
+ rescue
270
+ @log.error $!.to_s
271
+ @log.error_backtrace
272
+ end
273
+ end
274
+ end
275
+ end
276
+ end
@@ -0,0 +1,298 @@
1
+ #
2
+ # Fluentd
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+ #
16
+
17
+ require 'cool.io'
18
+ require 'socket'
19
+ require 'fluent/env'
20
+ require 'fluent/plugin/input'
21
+
22
+ module Fluent::Plugin
23
+ class TailInput < Fluent::Plugin::Input
24
+ class WorkerPool
25
+ class WorkerStopped < StandardError; end
26
+ Task = Struct.new(:key, :bytes, :payload)
27
+ Result = Struct.new(:task, :value, :error)
28
+
29
+ def initialize(num_threads:, task_limit:, byte_limit:, thread_create:, notify:, &process)
30
+ unless num_threads > 0 && task_limit > 0 && byte_limit > 0
31
+ raise ArgumentError, 'worker and queue limits must be positive'
32
+ end
33
+ raise ArgumentError, 'processor is required' unless process
34
+
35
+ @num_threads = num_threads
36
+ @task_limit = task_limit
37
+ @byte_limit = byte_limit
38
+ @thread_create = thread_create
39
+ @notify = notify
40
+ @process = process
41
+ @mutex = Mutex.new
42
+ @jobs = Queue.new
43
+ @results = Queue.new
44
+ @active = {}
45
+ @bytes = 0
46
+ @regular_bytes = 0
47
+ @oversized_task = nil
48
+ @threads = []
49
+ @state = :new
50
+ @failure = nil
51
+ end
52
+
53
+ def start
54
+ @mutex.synchronize do
55
+ raise 'worker pool already started' unless @state == :new
56
+ @state = :starting
57
+ end
58
+ begin
59
+ @num_threads.times do |index|
60
+ @threads << @thread_create.call(:"in_tail_worker_#{index}") { run(index) }
61
+ end
62
+ @mutex.synchronize do
63
+ raise(@failure || WorkerStopped.new('pool stopped during start')) unless @state == :starting
64
+ @state = :running
65
+ end
66
+ self
67
+ rescue
68
+ stop
69
+ join
70
+ raise
71
+ end
72
+ end
73
+
74
+ # Capacity remains reserved until emit and position handling acknowledge
75
+ # the result, including while a parsed batch waits in the result queue.
76
+ def submit(key, payload, bytes:)
77
+ raise ArgumentError, 'batch bytes must be nonnegative' if bytes < 0
78
+
79
+ @mutex.synchronize do
80
+ return false unless @state == :running
81
+ return false if @active.key?(key) || @active.size >= @task_limit
82
+ # A single over-limit batch may use an extra reservation alongside the
83
+ # bounded regular budget so it does not force parsing on the event loop.
84
+ if bytes > @byte_limit
85
+ return false if @oversized_task
86
+ else
87
+ return false if @regular_bytes + bytes > @byte_limit
88
+ end
89
+
90
+ task = Task.new(key, bytes, payload).freeze
91
+ @active[key] = task
92
+ @bytes += bytes
93
+ if bytes > @byte_limit
94
+ @oversized_task = task
95
+ else
96
+ @regular_bytes += bytes
97
+ end
98
+ @jobs << task
99
+ true
100
+ end
101
+ end
102
+
103
+ def next_result
104
+ @results.pop(true)
105
+ rescue ThreadError
106
+ nil
107
+ end
108
+
109
+ def acknowledge(result)
110
+ @mutex.synchronize do
111
+ task = result.task
112
+ unless @active[task.key].equal?(task)
113
+ raise ArgumentError, 'result is no longer active'
114
+ end
115
+
116
+ @active.delete(task.key)
117
+ @bytes -= task.bytes
118
+ if task.bytes > @byte_limit
119
+ @oversized_task = nil
120
+ else
121
+ @regular_bytes -= task.bytes
122
+ end
123
+ end
124
+ end
125
+
126
+ def statistics
127
+ @mutex.synchronize { { tasks: @active.size, bytes: @bytes, state: @state }.freeze }
128
+ end
129
+
130
+ def failure
131
+ @mutex.synchronize { @failure }
132
+ end
133
+
134
+ def stop
135
+ @mutex.synchronize do
136
+ @state = :stopping unless @state == :failed
137
+ @jobs.close
138
+ end
139
+ end
140
+
141
+ # The source joins after draining results; the event loop never joins.
142
+ def join(timeout: nil)
143
+ deadline = timeout && Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
144
+ @threads.each do |thread|
145
+ remaining = deadline && [deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max
146
+ return false unless thread.join(remaining)
147
+ end
148
+ true
149
+ end
150
+
151
+ def kill
152
+ @threads.each(&:kill)
153
+ @threads.each(&:join)
154
+ end
155
+
156
+ private
157
+
158
+ def run(index)
159
+ normal_exit = false
160
+ while (task = @jobs.pop)
161
+ value = nil
162
+ error = nil
163
+ completed = false
164
+ begin
165
+ value = @process.call(index, task.payload)
166
+ completed = true
167
+ rescue Exception => e
168
+ # Deliver task failures to the owner instead of losing an active
169
+ # file or terminating a helper thread with abort_on_exception.
170
+ error = e
171
+ completed = true
172
+ ensure
173
+ error = WorkerStopped.new('worker stopped during a task') unless completed
174
+ publish(Result.new(task, value, error).freeze)
175
+ end
176
+ end
177
+ normal_exit = true
178
+ ensure
179
+ fail_pool(WorkerStopped.new('worker exited unexpectedly')) unless normal_exit
180
+ end
181
+
182
+ def publish(result)
183
+ @results << result
184
+ begin
185
+ @notify.call
186
+ rescue Exception => error
187
+ fail_pool(error)
188
+ end
189
+ end
190
+
191
+ def fail_pool(error)
192
+ @mutex.synchronize do
193
+ @failure ||= error
194
+ @state = :failed
195
+ @jobs.close
196
+ end
197
+ begin
198
+ while (task = @jobs.pop(true))
199
+ @results << Result.new(task, nil, error).freeze
200
+ end
201
+ rescue ThreadError
202
+ # All queued jobs have been accounted for.
203
+ end
204
+ begin
205
+ @notify.call
206
+ rescue Exception => notification_error
207
+ @mutex.synchronize { @failure ||= notification_error }
208
+ end
209
+ end
210
+ end
211
+
212
+ # Captures the built-in hook implementations once and checks whether a
213
+ # TailInput instance resolves each hook to the same method body. A mismatch
214
+ # means worker execution must fall back to the synchronous compatibility
215
+ # path; this deliberately does not try to infer safety from method owners.
216
+ class WorkerCompatibility
217
+ HOOKS = %i[
218
+ receive_lines flush_buffer convert_line_to_event parse_singleline
219
+ parse_multilines setup_watcher io_handler build_line_feeder
220
+ ].freeze
221
+
222
+ def initialize(base_class)
223
+ @hooks = HOOKS.to_h { |name| [name, base_class.instance_method(name)] }.freeze
224
+ end
225
+
226
+ def compatible?(plugin)
227
+ incompatible_hooks(plugin).empty?
228
+ end
229
+
230
+ def incompatible_hooks(plugin)
231
+ @hooks.filter_map do |name, built_in|
232
+ name unless plugin.singleton_class.instance_method(name) == built_in
233
+ rescue NameError
234
+ name
235
+ end
236
+ end
237
+ end
238
+
239
+ class WorkerCompletionWatcher < Coolio::IOWatcher
240
+ def initialize(log: nil, &on_completion)
241
+ @log = log
242
+ if Fluent.windows?
243
+ # Winsock select cannot monitor IO.pipe handles. Use connected
244
+ # loopback sockets, as libev does for its own Windows wakeups.
245
+ begin
246
+ listener = TCPServer.new('127.0.0.1', 0)
247
+ @writer = TCPSocket.new('127.0.0.1', listener.addr[1])
248
+ @reader = listener.accept
249
+ rescue
250
+ @reader&.close
251
+ @writer&.close
252
+ raise
253
+ ensure
254
+ listener&.close
255
+ end
256
+ else
257
+ @reader, @writer = IO.pipe
258
+ end
259
+ super(@reader)
260
+ @on_completion = on_completion
261
+ end
262
+
263
+ def signal
264
+ @writer.write_nonblock("\0")
265
+ true
266
+ rescue IO::WaitWritable
267
+ # A full pipe already has a notification pending on the event loop.
268
+ false
269
+ end
270
+
271
+ def on_signal
272
+ @on_completion.call
273
+ end
274
+
275
+ def on_readable
276
+ notified = false
277
+ begin
278
+ loop do
279
+ @reader.read_nonblock(4096)
280
+ notified = true
281
+ end
282
+ rescue IO::WaitReadable
283
+ return unless notified
284
+ end
285
+ on_signal
286
+ rescue => error
287
+ @log&.error 'unexpected error while processing in_tail worker completions', error: error
288
+ @log&.error_backtrace
289
+ end
290
+
291
+ def close
292
+ detach if attached?
293
+ @reader.close unless @reader.closed?
294
+ @writer.close unless @writer.closed?
295
+ end
296
+ end
297
+ end
298
+ end