legion-logging 1.5.19 → 1.5.21

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: '037790df316dd01a4f090a1d02a4b07fab65feac2d0c70a501d949d57fd45276'
4
- data.tar.gz: af6fda1d1af5fe004fe8e4b76c225aa997f4075aed19b8d557f4090cec9e7770
3
+ metadata.gz: c94ba448ab05d8309fd27afcd2672364c2d843ed277036f640cf798715fe97af
4
+ data.tar.gz: 146fa61ef555986daadc1e94996e53e936b34c47a71b8647fdad3c240808e107
5
5
  SHA512:
6
- metadata.gz: 659f1952bc1c331c74528153276b9a593eb1d775c67fbd57797434e78f1f016612ca2a2e0322ab6ad041f7101efede0a94e803c388dedf96608727431b5c303c
7
- data.tar.gz: 9f4150366ac4f64da3b1c14a429058820de2cb639dd810974f6448a5ca78fa7e32d5c13f23100eb0d21802143091df232b0ffd89263313f00b7df1355341b547
6
+ metadata.gz: 4391e8e771c4d747469af379d33b1b3f397f60426e6b750dbdc1a6de16e3fa88085abefe9a36760d058aed2ffef559f473e94a37f5bf1a1eb006f058fc3ac1db
7
+ data.tar.gz: e2d141523b19fe6f0c300d97b62fc28d6aeced3e9db8a8c276564cfd0fef5acdb0398aa1eac1542803df34ca12de171326103224739b1c3f500fda7e8dfe5a53
data/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Legion::Logging Changelog
2
2
 
3
+ ## [1.5.21] - 2026-10-30
4
+
5
+ ### Changed
6
+ - Enforce a hard retained-byte cap for managed log files: split oversized writes before the per-file limit, trim existing oversized files to their newest bytes, and immediately prune excess archives.
7
+ - Capture eligible inherited stdout/stderr through bounded kernel pipes, including raw descriptor and child-process writes; full pipes backpressure producers instead of allocating an unbounded byte queue.
8
+ - Coordinate same-path file writers with persistent zero-byte lock sidecars and close regular data descriptors between operations so idle devices cannot pin deleted archives.
9
+ - Fail closed on rotation or drain errors rather than append beyond the cap. The bound covers managed file contents, not filesystem metadata, snapshots, external writers, hardlink aliases, or externally retained descriptors.
10
+
11
+ ### Fixed
12
+ - Include downstream raw-stdio drains in `stop_async_writer` deadlines, including synchronous and file-only module logging.
13
+ - Preserve capture after pre-drain timeouts; wait for worker cleanup before reporting successful disable, and prevent inherited children from controlling parent workers or silently changing their retention policy.
14
+ - Preserve existing instance loggers across process-capture disable/re-enable and close destinations if fail-closed descriptor reattachment itself fails.
15
+
16
+ ## [1.5.20] - 2026-10-29
17
+
18
+ ### Added
19
+ - Size-based log rotation, on by default (100 MiB x 5 archives), configurable via `logging.rotation`
20
+ (`enabled`, `max_size_mb`, `max_files`, `stdio`) or `setup(rotation: {...})`; `rotation: false` disables it
21
+ - `Legion::Logging::RotatingFile`: self-rotating append-mode device used for `log_file:`; reopens if another
22
+ process rotated the path
23
+ - `Legion::Logging::StdioRotator`: rotates the file behind stdout/stderr when a supervisor (launchd
24
+ `StandardOutPath`, systemd `append:`) redirected them to an append-mode regular file, using `IO#reopen` so
25
+ raw stderr output follows the new file. Checks are logger-write-driven, not a hard disk quota.
26
+
27
+ ### Fixed
28
+ - Preserve current async OutputLogger routing, lifecycle locks, shutdown deadlines, and forwarding settings while integrating rotation (PR #21).
29
+ - Honor instance logger rotation overrides and opt-out; rotate distinct stderr files independently, including when stdout is a pipe.
30
+ - Preserve nested Settings lookup guards, normalize invalid limits, and reopen remembered paths after external rotation.
31
+ - Keep non-regular devices untouched and report rotation failures without discarding the incoming message.
32
+
3
33
  ## [1.5.19] - 2026-10-29
4
34
 
5
35
  ### Changed
data/CLAUDE.md CHANGED
@@ -18,7 +18,8 @@ Legion::Logging (singleton module)
18
18
  ├── SIEMExporter # PHI-redacting SIEM export (Splunk HEC, ELK)
19
19
  ├── Shipper # Bounded batch-delivery worker (file/HTTP transports)
20
20
  ├── Redactor # PII/PHI + secret pattern redaction (opt-in)
21
- └── MultiIO # Synchronous, sequential writes to multiple destinations
21
+ ├── MultiIO # Synchronous, sequential writes to multiple destinations
22
+ └── Rotation # RotatingFile (log_file) + shared StdioRotator (redirected stdout/stderr)
22
23
 
23
24
  # Module-level writer lambdas (pluggable forwarding slots)
24
25
  Legion::Logging.log_writer # ->(event, routing_key:, headers:, properties:) {}
@@ -41,6 +42,7 @@ Legion::Logging.exception_writer # ->(event, routing_key:, headers:, properties:
41
42
  - **Forwarding levels** — `logging.transport.log_levels` accepts strings/symbols; absent or empty lists default to warn/error/fatal. Settings guards use defaults while Settings is unloaded rather than initializing it.
42
43
  - **Synchronous APIs** — `MultiIO`, direct transport delivery, SIEM export, and direct hook dispatch remain synchronous. Shipper filtering/redaction run on its caller; bounded batch delivery runs on its worker.
43
44
  - **EventBuilder** — Structured event hashes from log context (caller, exception, lex identity). `fingerprint` produces MD5 for dedup.
45
+ - **Rotation** — Hard managed-content cap: 100 MiB per file plus five archives (600 MiB/path). `RotatingFile` splits writes, trims oversized existing files, prunes excess archives, and coordinates same-path/same-policy writers through a persistent zero-byte `.lock`. Regular data FDs close between operations. Eligible append-mode inherited stdout/stderr become bounded pipes drained by dedicated capped sinks, including raw-only and child writes; saturation blocks, errors fail closed. `stop_async_writer` drains both layers under one deadline, including sync/file-only module logging; call it after final raw writes. Capture is process-wide; disable only with quiescent producers. Pre-drain timeout keeps capture active; final cleanup failure/timeout requires restart. Instance loggers survive disable/re-enable. Forked children cannot control parent workers or change their policy. File contents are bounded, not filesystem metadata/snapshots, external writers/aliases, or externally held/inherited descriptors; a volume-level guarantee needs an OS quota.
44
46
 
45
47
  ## Role in LegionIO
46
48
 
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Logging module for the [LegionIO](https://github.com/LegionIO/LegionIO) framework. Provides colorized console output via Rainbow, structured JSON logging, multi-output IO, and a consistent logging interface across all Legion gems and extensions.
4
4
 
5
- **Version**: 1.5.19
5
+ **Version**: 1.5.21
6
6
 
7
7
  ## Installation
8
8
 
@@ -31,6 +31,71 @@ Legion::Logging.error('something went wrong')
31
31
  Legion::Logging.fatal('critical failure')
32
32
  ```
33
33
 
34
+ ### Log Rotation
35
+
36
+ Managed file contents have a hard byte bound: **100 MiB per file with five archives** by
37
+ default (`legion.log`, `legion.log.1` through `legion.log.5`), or **600 MiB per managed path**.
38
+ The bound is `max_bytes * (max_files + 1)`, where `max_bytes` is `max_size_mb * 1024 * 1024`,
39
+ rounded down to a positive byte count. Separate stdout, stderr, and configured log paths
40
+ each have their own retention budget.
41
+
42
+ This covers `log_file:` and eligible stdout/stderr files inherited from launchd, systemd
43
+ `append:`, or shell `>>` redirects. Only append-mode regular inherited files qualify;
44
+ pipes, terminals, and non-append redirects (`>`) remain unchanged. Eligible stderr is captured
45
+ independently even when stdout is a pipe. The actual descriptors become bounded kernel pipes,
46
+ so raw writes and inherited child-process writes also pass through the capped sinks without
47
+ needing a logger call. Full pipes block producers; there is no unbounded Ruby byte queue.
48
+ Raw syscall callers must handle short writes, as with any pipe.
49
+
50
+ Writes are split before a file exceeds its limit. Oversized records can cross archive,
51
+ line, and UTF-8 character boundaries; concatenate archives oldest-first followed by the active
52
+ file to read the retained byte stream. Enabling or tightening rotation trims each oversized
53
+ existing file in place to its newest bytes and deletes archives beyond `max_files`.
54
+ Rotation/drain failures do not fall back to uncapped appends; drain/control APIs surface errors.
55
+
56
+ File writes use a stable, zero-byte `<path>.lock` sidecar to coordinate devices and processes.
57
+ All cooperating writers must use the same canonical path and limits; do not delete the lock
58
+ while writers are alive. Regular data descriptors close between operations so idle devices
59
+ cannot retain deleted archives. External replacement is followed on the next write.
60
+
61
+ This is a bound on **Legion-managed file contents**, not an OS/filesystem quota. Filesystem
62
+ allocation overhead, snapshots, external writers, hardlink aliases, and descriptors retained
63
+ outside these devices are outside the bound. A fork without exec during an in-flight file
64
+ operation can inherit such a descriptor; close inherited descriptors or exec before leaving
65
+ the child idle. A strict volume-level disk-space guarantee requires a filesystem quota.
66
+
67
+ ```json
68
+ {
69
+ "logging": {
70
+ "rotation": { "enabled": true, "max_size_mb": 100, "max_files": 5, "stdio": true }
71
+ }
72
+ }
73
+ ```
74
+
75
+ ```ruby
76
+ Legion::Logging.setup(log_file: './legion.log', rotation: { max_size_mb: 50, max_files: 3 })
77
+ Legion::Logging.setup(log_file: './legion.log', rotation: false) # disable
78
+ Legion::Logging.setup(rotation: { stdio: false }) # leave inherited stdout/stderr alone
79
+ logger = Legion::Logging::Logger.new(log_file: './extension.log', rotation: { max_size_mb: 25 })
80
+ ```
81
+
82
+ Omitting `rotation:` reads `logging.rotation` from already-loaded Settings, otherwise uses the
83
+ defaults above. Instance loggers honor the same overrides, including `rotation: false`.
84
+
85
+ Inherited-stream capture is process-wide. Instance opt-out does not detach capture owned by
86
+ another logger; module-level `setup(rotation: false)` or `setup(rotation: { stdio: false })`
87
+ explicitly disables it. Quiesce raw producers, including children, before disabling capture.
88
+ Pre-drain timeout leaves capture active; a failure or timeout during final handle cleanup
89
+ leaves it fail-closed rather than restoring an uncapped fallback and requires process restart.
90
+ Forked children may write through the inherited pipes but cannot drain/reset parent workers
91
+ or request a different capture policy. Fork-without-exec children also retain pipe read ends,
92
+ which can delay `EPIPE` after a parent drain failure until those children close them or exit.
93
+
94
+ Captured stdio is asynchronous even with `async: false`: call `stop_async_writer(timeout: 2)`
95
+ **after the final raw stdout/stderr writes** to drain both layers before exit. This does not
96
+ detach capture, and abrupt process termination is not a delivery guarantee. Setup, explicit
97
+ rotation, and retention changes remain synchronous control operations.
98
+
34
99
  ### Async Logging
35
100
 
36
101
  By default, `setup` enables async logging: log calls enqueue output for a background writer, including fatal messages. Async does not mean wait-free. The queue holds at most 10,000 entries by default, and producers wait for capacity when it fills.
@@ -48,7 +113,7 @@ Legion::Logging.setup(level: 'info', async: false)
48
113
 
49
114
  Bounded backpressure is intentional: saturation blocks callers rather than dropping logs or allowing unbounded memory growth. A stalled destination can therefore stall producers once the queue fills. Buffer capacity limits queued entries, not their total byte size, and does not guarantee delivery across process termination.
50
115
 
51
- Use `Legion::Logging.stop_async_writer(timeout: 2)` during shutdown. It closes admission and asks the worker to drain accepted entries, returning `true` only after that worker exits. A deadline expiry returns `false` without killing the worker or draining on the caller; release the blocked destination and retry shutdown. Calling shutdown from a writer callback requests closure but returns `false` rather than joining itself.
116
+ Use `Legion::Logging.stop_async_writer(timeout: 2)` during shutdown, after the final raw stdio writes. It closes async admission and returns `true` only after the writer exits and relevant captured-stdio sinks finish their drain, within one shared deadline. It also drains captured stdio when async logging is disabled. A drain deadline returns `false` without killing workers or draining on the caller; release the blocked destination and retry. Calling shutdown from a writer callback requests closure but returns `false` rather than joining itself.
52
117
 
53
118
  A successful stop means queued entries have been processed, not that a sink durably stored them or an external service acknowledged them. AsyncWriter reports sink/callback failures locally and does not automatically retry them. The Shipper's retained-batch retry behavior is separate.
54
119
 
@@ -3,6 +3,7 @@
3
3
  require 'fileutils'
4
4
  require 'monitor'
5
5
  require_relative 'output_logger'
6
+ require_relative 'rotation'
6
7
 
7
8
  module Legion
8
9
  module Logging
@@ -123,36 +124,37 @@ module Legion
123
124
  end
124
125
 
125
126
  def output(**options)
126
- set_log(logfile: options[:log_file], log_stdout: options[:log_stdout])
127
+ set_log(logfile: options[:log_file], log_stdout: options[:log_stdout], rotation: options[:rotation],
128
+ shutdown_timeout: options.fetch(:shutdown_timeout, 2))
127
129
  end
128
130
 
129
131
  def log
130
132
  @log ||= set_log
131
133
  end
132
134
 
133
- def set_log(logfile: nil, log_stdout: nil, **)
135
+ # A nil rotation option reads loaded Settings; false disables rotation.
136
+ def set_log(logfile: nil, log_stdout: nil, rotation: nil, shutdown_timeout: 2, **)
134
137
  with_lifecycle_lock do
135
- return false if @async_writer && stop_async_writer == false
138
+ deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + shutdown_timeout if shutdown_timeout
139
+ return false if @async_writer && stop_async_writer(timeout: shutdown_timeout) == false
136
140
 
137
141
  previous_log = @log
142
+ rotation_opts = Legion::Logging::Rotation.resolve(raw: rotation.nil? ? Legion::Logging::Rotation.from_settings : rotation)
143
+ remaining = [deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC), 0].max if deadline
144
+ stdout = stdout_target(rotation_opts: rotation_opts, uses_stdout: !logfile || log_stdout != false, timeout: remaining)
145
+ return false if stdout == false
138
146
 
139
147
  if logfile && log_stdout != false
140
- path = prepare_log_path(logfile)
141
148
  require_relative 'multi_io'
142
- file = File.new(path, 'a')
143
- file.sync = true
144
- io = MultiIO.new($stdout, file)
149
+ io = Legion::Logging::MultiIO.new(stdout, open_log_file(path: prepare_log_path(logfile), rotation_opts: rotation_opts))
145
150
  @log = Legion::Logging::OutputLogger.new(io)
146
151
  elsif logfile
147
- file = File.new(prepare_log_path(logfile), 'a')
148
- file.sync = true
149
- @log = Legion::Logging::OutputLogger.new(file)
152
+ @log = Legion::Logging::OutputLogger.new(open_log_file(path: prepare_log_path(logfile), rotation_opts: rotation_opts))
150
153
  else
151
- @log = Legion::Logging::OutputLogger.new($stdout)
154
+ @log = Legion::Logging::OutputLogger.new(stdout)
152
155
  end
153
156
 
154
157
  @log.async_writer = @async_writer
155
-
156
158
  close_replaced_log(previous_log)
157
159
  @log
158
160
  end
@@ -210,14 +212,52 @@ module Legion
210
212
 
211
213
  def stop_async_writer(timeout: 2)
212
214
  with_lifecycle_lock do
213
- return true unless @async_writer
215
+ deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + timeout if timeout
216
+ return false if @async_writer && @async_writer.stop(timeout: timeout) == false
214
217
 
215
- @async_writer.stop(timeout: timeout)
218
+ remaining = [deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC), 0].max if deadline
219
+ flush_stdio_output!(timeout: remaining)
216
220
  end
217
221
  end
218
222
 
219
223
  private
220
224
 
225
+ def open_log_file(path:, rotation_opts:)
226
+ if rotation_opts[:enabled]
227
+ return Legion::Logging::RotatingFile.new(path: path, max_bytes: rotation_opts[:max_bytes], max_files: rotation_opts[:max_files])
228
+ end
229
+
230
+ file = File.new(path, 'a')
231
+ file.sync = true
232
+ file
233
+ end
234
+
235
+ def stdout_target(rotation_opts:, uses_stdout:, timeout:)
236
+ if rotation_opts[:enabled] && rotation_opts[:stdio]
237
+ return $stdout unless uses_stdout || equal?(Legion::Logging)
238
+
239
+ return Legion::Logging::StdioRotator.shared(max_bytes: rotation_opts[:max_bytes], max_files: rotation_opts[:max_files]) || $stdout
240
+ end
241
+
242
+ return false if equal?(Legion::Logging) && !Legion::Logging::StdioRotator.reset!(timeout: timeout)
243
+
244
+ $stdout
245
+ end
246
+
247
+ def flush_stdio_output!(timeout:) # rubocop:disable Naming/PredicateMethod -- Performs a drain; false signals its deadline.
248
+ deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + timeout if timeout
249
+ device = @log&.instance_variable_get(:@logdev)&.dev
250
+ targets = defined?(Legion::Logging::MultiIO) && device.is_a?(Legion::Logging::MultiIO) ? device.targets : [device]
251
+ captures = targets.grep(Legion::Logging::StdioRotator)
252
+ current = Legion::Logging::StdioRotator.current
253
+ captures << current if equal?(Legion::Logging) && current && !captures.include?(current)
254
+ captures.each do |capture|
255
+ remaining = [deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC), 0].max if deadline
256
+ return false if capture.flush(timeout: remaining) == false
257
+ end
258
+ true
259
+ end
260
+
221
261
  def with_lifecycle_lock
222
262
  lock = LIFECYCLE_MUTEX.synchronize { @lifecycle_lock ||= Monitor.new }
223
263
  entered = lock.try_enter
@@ -13,7 +13,7 @@ module Legion
13
13
 
14
14
  def initialize(level: 'info', log_file: nil, log_stdout: nil, lex: nil, trace: false, extended: false, trace_size: 4, format: :text, async: true, **opts)
15
15
  @lex = lex
16
- set_log(logfile: log_file, log_stdout: log_stdout)
16
+ set_log(logfile: log_file, log_stdout: log_stdout, rotation: opts[:rotation])
17
17
  log_level(level)
18
18
  log_format(format: format, lex: lex, extended: extended, **opts)
19
19
  @color = opts[:color]
@@ -3,8 +3,10 @@
3
3
  module Legion
4
4
  module Logging
5
5
  class MultiIO
6
+ attr_reader :targets
7
+
6
8
  def initialize(*targets)
7
- @targets = targets.flatten
9
+ @targets = targets.flatten.freeze
8
10
  end
9
11
 
10
12
  def write(message)
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Logging
5
+ # Every cooperating writer for a path must use the same limits. The stable
6
+ # lock file coordinates separate devices/processes and must not be removed.
7
+ class RotatingFile
8
+ BUFFER_BYTES = 64 * 1024
9
+
10
+ attr_reader :file_path, :max_bytes, :max_files
11
+
12
+ def initialize(path:, max_bytes:, max_files:)
13
+ unless max_bytes.is_a?(Integer) && max_bytes.positive? && max_files.is_a?(Integer) && max_files.positive?
14
+ raise ArgumentError, 'max_bytes and max_files must be positive integers'
15
+ end
16
+
17
+ @file_path = File.expand_path(path)
18
+ @max_bytes = max_bytes
19
+ @max_files = max_files
20
+ @mutex = Mutex.new
21
+ @archive_pattern = /\A#{Regexp.escape(File.basename(@file_path))}\.([1-9]\d*)\z/
22
+ @needs_normalization = true
23
+ @closed = false
24
+ synchronize_path { normalize_locked }
25
+ rescue StandardError
26
+ begin
27
+ @io&.close
28
+ ensure
29
+ @lock_io&.close
30
+ end
31
+ raise
32
+ end
33
+
34
+ def write(*messages)
35
+ raise ArgumentError, 'wrong number of arguments (given 0, expected 1+)' if messages.empty?
36
+
37
+ synchronize_path do
38
+ normalize_locked if @needs_normalization
39
+ next @io.write(*messages) unless @regular
40
+
41
+ trim_file(path: @file_path) if @io.size > @max_bytes
42
+ messages.sum { |message| write_message(message: message.to_s) }
43
+ end
44
+ end
45
+
46
+ def flush
47
+ @mutex.synchronize do
48
+ raise IOError, 'closed stream' if @closed
49
+
50
+ @io&.flush
51
+ end
52
+ self
53
+ end
54
+
55
+ def sync = true # rubocop:disable Naming/PredicateMethod -- IO#sync interface
56
+
57
+ def sync=(_value); end
58
+
59
+ def close
60
+ @mutex.synchronize do
61
+ return if @closed
62
+
63
+ @closed = true
64
+ begin
65
+ @io&.close
66
+ ensure
67
+ @lock_io.close if @lock_io && !@lock_io.closed?
68
+ end
69
+ end
70
+ end
71
+
72
+ def closed?
73
+ @mutex.synchronize { @closed }
74
+ end
75
+
76
+ def size
77
+ synchronize_path { @io.size }
78
+ end
79
+
80
+ def rotate!
81
+ synchronize_path do
82
+ normalize_locked if @needs_normalization
83
+ rotate_locked!
84
+ end
85
+ end
86
+
87
+ def configure(max_bytes:, max_files:)
88
+ unless max_bytes.is_a?(Integer) && max_bytes.positive? && max_files.is_a?(Integer) && max_files.positive?
89
+ raise ArgumentError, 'max_bytes and max_files must be positive integers'
90
+ end
91
+
92
+ synchronize_path do
93
+ @max_bytes = max_bytes
94
+ @max_files = max_files
95
+ @needs_normalization = true
96
+ normalize_locked
97
+ end
98
+ self
99
+ end
100
+
101
+ private
102
+
103
+ def open_file
104
+ file = File.new(@file_path, 'ab')
105
+ file.sync = true
106
+ file
107
+ end
108
+
109
+ def synchronize_path
110
+ @mutex.synchronize do
111
+ raise IOError, 'closed stream' if @closed
112
+
113
+ lock_path if @lock_io || File.file?(@file_path) || !File.exist?(@file_path)
114
+ begin
115
+ reopen_if_replaced
116
+ if @regular && !@lock_io
117
+ @io.close
118
+ @io = nil
119
+ lock_path
120
+ reopen_if_replaced
121
+ end
122
+ yield
123
+ ensure
124
+ begin
125
+ # Idle devices must not pin archives after another writer unlinks
126
+ # them. Release every regular data-file FD before the path lock.
127
+ if @regular && @io
128
+ @io.close
129
+ @io = nil
130
+ end
131
+ ensure
132
+ @lock_io&.flock(File::LOCK_UN)
133
+ end
134
+ end
135
+ end
136
+ end
137
+
138
+ def lock_path
139
+ # flock locks are inherited across fork with the open file description.
140
+ # Reopen in the child so its lock competes with its parent's lock.
141
+ if @lock_io && @lock_pid != ::Process.pid
142
+ @lock_io.close
143
+ @lock_io = nil
144
+ end
145
+ @lock_io ||= File.open("#{@file_path}.lock", File::RDWR | File::CREAT, 0o600)
146
+ @lock_pid = ::Process.pid
147
+ raise IOError, 'could not acquire log rotation lock' unless @lock_io.flock(File::LOCK_EX)
148
+ end
149
+
150
+ def reopen_if_replaced
151
+ if @io
152
+ current = @io.stat
153
+ target = File.stat(@file_path) if File.exist?(@file_path)
154
+ return if target && current.dev == target.dev && current.ino == target.ino
155
+ end
156
+
157
+ replace_io
158
+ end
159
+
160
+ def replace_io
161
+ previous = @io
162
+ @io = open_file
163
+ current = @io.stat
164
+ @regular = current.file?
165
+ @needs_normalization ||= @file_dev != current.dev || @file_ino != current.ino
166
+ @file_dev = current.dev
167
+ @file_ino = current.ino
168
+ previous&.close
169
+ end
170
+
171
+ def normalize_locked
172
+ if @regular
173
+ trim_file(path: @file_path)
174
+ directory = File.dirname(@file_path)
175
+ Dir.each_child(directory) do |name|
176
+ match = @archive_pattern.match(name)
177
+ next unless match
178
+
179
+ archive = File.join(directory, name)
180
+ if match[1].to_i > @max_files
181
+ File.unlink(archive)
182
+ else
183
+ trim_file(path: archive)
184
+ end
185
+ end
186
+ end
187
+ @needs_normalization = false
188
+ end
189
+
190
+ def trim_file(path:)
191
+ stat = File.stat(path)
192
+ return unless stat.file? && stat.size > @max_bytes
193
+
194
+ # Move the retained tail toward the beginning without a temporary file
195
+ # or a full-file allocation. Forward copies cannot overwrite unread data.
196
+ File.open(path, 'r+b') do |file|
197
+ source_offset = file.size - @max_bytes
198
+ offset = 0
199
+ buffer = +''.b
200
+ while offset < @max_bytes
201
+ file.seek(source_offset + offset)
202
+ chunk = file.read([BUFFER_BYTES, @max_bytes - offset].min, buffer)
203
+ raise IOError, 'log file changed while trimming its retained tail' if chunk.nil? || chunk.empty?
204
+
205
+ file.seek(offset)
206
+ offset += file.write(chunk)
207
+ end
208
+ file.truncate(@max_bytes)
209
+ end
210
+ end
211
+
212
+ def write_message(message:)
213
+ offset = 0
214
+ available = @max_bytes - @io.size
215
+ while offset < message.bytesize
216
+ unless available.positive?
217
+ rotate_locked!
218
+ available = @max_bytes
219
+ end
220
+ length = [message.bytesize - offset, available, BUFFER_BYTES].min
221
+ chunk = offset.zero? && length == message.bytesize ? message : message.byteslice(offset, length)
222
+ written = @io.write(chunk)
223
+ offset += written
224
+ available -= written
225
+ end
226
+ offset
227
+ end
228
+
229
+ def rotate_locked! # rubocop:disable Naming/PredicateMethod -- Renames files; false means a nonregular destination.
230
+ return false unless @regular
231
+
232
+ Rotation.shift_files(path: @file_path, max_files: @max_files)
233
+ replace_io
234
+ @needs_normalization = false
235
+ true
236
+ end
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module Legion
6
+ module Logging
7
+ # Settings and descriptor detection shared by the byte-capped file and stdio sinks.
8
+ module Rotation
9
+ DEFAULTS = {
10
+ enabled: true,
11
+ max_size_mb: 100,
12
+ max_files: 5,
13
+ stdio: true
14
+ }.freeze
15
+
16
+ F_GETPATH = 50 # macOS fcntl(2) command; not exposed by the Fcntl module
17
+ MAXPATHLEN = 1024
18
+
19
+ class << self
20
+ # Normalizes rotation settings. Accepts nil, false (disabled), or a Hash
21
+ # with symbol or string keys. Missing keys fall back to DEFAULTS.
22
+ def resolve(raw:)
23
+ overrides = if raw == false
24
+ { enabled: false }
25
+ elsif raw.is_a?(Hash)
26
+ raw.transform_keys(&:to_sym)
27
+ else
28
+ {}
29
+ end
30
+ merged = DEFAULTS.merge(overrides.slice(*DEFAULTS.keys))
31
+ merged[:enabled] = merged[:enabled] != false
32
+ merged[:stdio] = merged[:stdio] != false
33
+ merged[:max_size_mb] = positive_number(merged[:max_size_mb], DEFAULTS[:max_size_mb])
34
+ merged[:max_files] = positive_number(merged[:max_files], DEFAULTS[:max_files]).to_i
35
+ merged[:max_files] = DEFAULTS[:max_files] unless merged[:max_files].positive?
36
+ bytes = merged[:max_size_mb] * 1024 * 1024
37
+ unless bytes.finite?
38
+ merged[:max_size_mb] = DEFAULTS[:max_size_mb]
39
+ bytes = merged[:max_size_mb] * 1024 * 1024
40
+ end
41
+ merged[:max_bytes] = [bytes.to_i, 1].max
42
+ merged
43
+ end
44
+
45
+ # Reads rotation settings from Legion::Settings once it has loaded.
46
+ # Guarded against re-entry because Settings may log while loading.
47
+ def from_settings
48
+ return nil unless defined?(Legion::Settings) && Legion::Settings.respond_to?(:[])
49
+ return nil if Thread.current[:legion_logging_rotation_lookup]
50
+
51
+ lookup_started = true
52
+ Thread.current[:legion_logging_rotation_lookup] = true
53
+ return nil if Legion::Settings.respond_to?(:loaded?) && !Legion::Settings.loaded?
54
+
55
+ logging = Legion::Settings[:logging]
56
+ logging[:rotation] if logging.is_a?(Hash)
57
+ rescue StandardError => e
58
+ warn("Legion::Logging::Rotation.from_settings failed: #{e.class}: #{e.message}")
59
+ nil
60
+ ensure
61
+ Thread.current[:legion_logging_rotation_lookup] = nil if lookup_started
62
+ end
63
+
64
+ # Shifts path.(n-1) -> path.n ... path -> path.1, dropping the oldest.
65
+ def shift_files(path:, max_files:)
66
+ oldest = "#{path}.#{max_files}"
67
+ FileUtils.rm(oldest, force: false) if File.exist?(oldest)
68
+ (max_files - 1).downto(1) do |i|
69
+ src = "#{path}.#{i}"
70
+ File.rename(src, "#{path}.#{i + 1}") if File.exist?(src)
71
+ end
72
+ File.rename(path, "#{path}.1") if File.exist?(path)
73
+ end
74
+
75
+ # Returns the filesystem path behind an IO if it is a regular file opened
76
+ # in append mode (launchd StandardOutPath, systemd append:, shell >>), or nil.
77
+ # Non-append redirects (shell >) are left alone.
78
+ def regular_file_path(io:)
79
+ return nil unless io.respond_to?(:stat) && io.stat.file?
80
+ return nil unless append_mode?(io: io)
81
+
82
+ path = fd_path(io)
83
+ return nil unless path && File.file?(path)
84
+ return nil unless same_file?(io: io, path: path)
85
+
86
+ path
87
+ rescue StandardError => e
88
+ warn("Legion::Logging::Rotation.regular_file_path failed: #{e.class}: #{e.message}")
89
+ nil
90
+ end
91
+
92
+ def same_file?(io:, path:)
93
+ return false unless File.exist?(path)
94
+
95
+ io_stat = io.stat
96
+ path_stat = File.stat(path)
97
+ io_stat.ino == path_stat.ino && io_stat.dev == path_stat.dev
98
+ rescue SystemCallError, IOError => e
99
+ warn("Legion::Logging::Rotation.same_file? path=#{path} failed: #{e.class}: #{e.message}")
100
+ false
101
+ end
102
+
103
+ def append_mode?(io:)
104
+ require 'fcntl'
105
+ io.fcntl(Fcntl::F_GETFL).anybits?(File::APPEND)
106
+ rescue NotImplementedError, SystemCallError, IOError => e
107
+ warn("Legion::Logging::Rotation.append_mode? failed: #{e.class}: #{e.message}")
108
+ false
109
+ end
110
+
111
+ private
112
+
113
+ def fd_path(io)
114
+ if io.respond_to?(:path)
115
+ path = File.expand_path(io.path)
116
+ return path if same_file?(io: io, path: path)
117
+ end
118
+
119
+ if RUBY_PLATFORM.include?('darwin')
120
+ require 'fcntl'
121
+ buf = "\0" * MAXPATHLEN
122
+ io.fcntl(F_GETPATH, buf)
123
+ buf.unpack1('Z*')
124
+ else
125
+ proc_link = "/proc/self/fd/#{io.fileno}"
126
+ File.readlink(proc_link) if File.symlink?(proc_link)
127
+ end
128
+ end
129
+
130
+ def positive_number(value, default)
131
+ number = Float(value)
132
+ number.positive? && number.finite? ? number : default
133
+ rescue ArgumentError, TypeError => e
134
+ warn("Legion::Logging::Rotation invalid numeric limit: #{e.class}: #{e.message}")
135
+ default
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
141
+
142
+ require_relative 'rotating_file'
143
+ require_relative 'stdio_rotator'
@@ -16,7 +16,13 @@ module Legion
16
16
  trace_size: 4,
17
17
  extended: true,
18
18
  backtrace_limit: nil,
19
- forward_log_levels: DEFAULT_FORWARD_LOG_LEVELS
19
+ forward_log_levels: DEFAULT_FORWARD_LOG_LEVELS,
20
+ rotation: {
21
+ enabled: true,
22
+ max_size_mb: 100,
23
+ max_files: 5,
24
+ stdio: true
25
+ }
20
26
  }
21
27
  end
22
28
 
@@ -0,0 +1,492 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Logging
5
+ # The descriptors themselves become pipe writers, including writes which do
6
+ # not pass through Ruby's Logger. Only the drain thread owns the file sink.
7
+ class StdioRotator
8
+ class << self
9
+ attr_reader :current
10
+
11
+ def shared(max_bytes:, max_files:)
12
+ inherited = @current
13
+ if inherited && !inherited.owned?
14
+ raise IOError, 'cannot change the policy of an inherited stdio capture' unless inherited.max_bytes == max_bytes && inherited.max_files == max_files
15
+
16
+ return inherited
17
+ end
18
+
19
+ @shared_mutex.synchronize do
20
+ return @current.configure(max_bytes: max_bytes, max_files: max_files) if @current
21
+
22
+ capture = new(max_bytes: max_bytes, max_files: max_files)
23
+ @current = capture if capture.capturing?
24
+ end
25
+ end
26
+
27
+ def reset!(timeout: 2)
28
+ return false unless timeout.nil? ? @shared_mutex.lock : @shared_mutex.try_lock
29
+
30
+ begin
31
+ return true unless @current
32
+ return false unless @current.shutdown(timeout: timeout)
33
+
34
+ @current = nil
35
+ true
36
+ ensure
37
+ @shared_mutex.unlock
38
+ end
39
+ end
40
+ end
41
+ @shared_mutex = Mutex.new
42
+
43
+ attr_reader :max_bytes, :max_files
44
+
45
+ def initialize(max_bytes:, max_files:, stdout: $stdout, stderr: $stderr)
46
+ @stdout = stdout
47
+ @streams = [stdout, stderr].uniq(&:object_id)
48
+ @lifecycle = Mutex.new
49
+ @captures = []
50
+ @closed = false
51
+ @owner_pid = ::Process.pid
52
+ @max_bytes = max_bytes
53
+ @max_files = max_files
54
+ eligible = @streams.filter_map do |stream|
55
+ path = Rotation.regular_file_path(io: stream)
56
+ stream.flush if path
57
+ [stream, path, stream.stat] if path
58
+ end
59
+ eligible.group_by { |_, _, stat| [stat.dev, stat.ino] }.each_value do |group|
60
+ @captures << StdioCapture.new(path: group.first[1], streams: group.map(&:first), max_bytes: max_bytes, max_files: max_files)
61
+ end
62
+ @captures.each(&:attach)
63
+ @captures.each(&:release_backups)
64
+ rescue StandardError
65
+ @captures.reverse_each(&:rollback)
66
+ raise
67
+ end
68
+
69
+ def capturing? = !@captures.empty? && !@closed
70
+
71
+ def owned? = @owner_pid == ::Process.pid
72
+
73
+ def configure(max_bytes:, max_files:)
74
+ raise IOError, 'cannot configure an inherited stdio capture' unless owned?
75
+
76
+ @lifecycle.synchronize do
77
+ raise IOError, 'closed stdio capture' if @closed
78
+
79
+ @captures.each { |capture| capture.command(type: :configure, arguments: { max_bytes: max_bytes, max_files: max_files }) }
80
+ @max_bytes = max_bytes
81
+ @max_files = max_files
82
+ end
83
+ self
84
+ end
85
+
86
+ def write(*messages)
87
+ if @closed
88
+ current = self.class.current
89
+ return current.write(*messages) if current && !current.equal?(self) && current.stdout.equal?(@stdout)
90
+
91
+ return @stdout.write(*messages)
92
+ end
93
+
94
+ @captures.each(&:check_failure!) if owned?
95
+ @stdout.write(*messages)
96
+ rescue IOError, SystemCallError
97
+ @captures.each(&:check_failure!) if owned? && !@closed
98
+ raise
99
+ end
100
+
101
+ def flush(timeout: 2)
102
+ return false unless owned?
103
+
104
+ if @closed
105
+ current = self.class.current
106
+ return current.flush(timeout: timeout) if current && !current.equal?(self) && current.stdout.equal?(@stdout)
107
+
108
+ @stdout.flush
109
+ return self
110
+ end
111
+
112
+ deadline = monotonic_time + timeout if timeout
113
+ @captures.each { |capture| return false unless capture.flush(deadline: deadline) }
114
+ @stdout.flush unless @captures.any? { |capture| capture.includes?(stream: @stdout) }
115
+ self
116
+ end
117
+
118
+ # Logger borrows process stdio. Only explicit shutdown/reset! detaches it.
119
+ def close; end
120
+
121
+ def closed? = @closed
122
+
123
+ def sync = true # rubocop:disable Naming/PredicateMethod -- IO#sync interface
124
+
125
+ def sync=(_value); end
126
+
127
+ def rotate!
128
+ raise IOError, 'cannot rotate an inherited stdio capture' unless owned?
129
+
130
+ @lifecycle.synchronize do
131
+ raise IOError, 'closed stdio capture' if @closed
132
+
133
+ @captures.each { |capture| capture.command(type: :rotate!, arguments: {}) }
134
+ !@captures.empty?
135
+ end
136
+ end
137
+
138
+ def shutdown(timeout: 2)
139
+ return false unless owned?
140
+ return false unless timeout.nil? ? @lifecycle.lock : @lifecycle.try_lock
141
+
142
+ deadline = monotonic_time + timeout if timeout
143
+ detached = false
144
+ begin
145
+ return true if @closed
146
+ return false unless @captures.all? { |capture| capture.pause(deadline: deadline) }
147
+
148
+ detached = true
149
+ @closed = drain_and_detach!(deadline: deadline)
150
+ ensure
151
+ begin
152
+ unless @closed
153
+ @captures.each(&:attach) if detached
154
+ @captures.each(&:resume)
155
+ end
156
+ ensure
157
+ @lifecycle.unlock
158
+ end
159
+ end
160
+ end
161
+
162
+ protected
163
+
164
+ attr_reader :stdout
165
+
166
+ private
167
+
168
+ def drain_and_detach!(deadline:)
169
+ @captures.each(&:restore)
170
+ @captures.each(&:resume)
171
+ # Include bytes written to the old descriptors during restoration.
172
+ return false unless @captures.all? { |capture| capture.pause(deadline: deadline) }
173
+
174
+ # A final drain may itself rotate: restore the current active inode.
175
+ @captures.each(&:restore)
176
+ @captures.all? { |capture| capture.finish!(deadline: deadline) }
177
+ end
178
+
179
+ def monotonic_time
180
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
181
+ end
182
+ end
183
+
184
+ # One reader and one bounded control pipe per distinct inherited file.
185
+ # Commands occupy a single slot; bytes are never queued in Ruby.
186
+ class StdioCapture
187
+ READ_BYTES = 16 * 1024
188
+
189
+ def initialize(path:, streams:, max_bytes:, max_files:)
190
+ @path = path
191
+ @streams = streams
192
+ @state = Mutex.new
193
+ @changed = ConditionVariable.new
194
+ @requested_flush = 0
195
+ @completed_flush = 0
196
+ @backups = []
197
+ @properties = streams.map { |stream| [stream.sync, stream.close_on_exec?, stream.external_encoding, stream.internal_encoding] }
198
+ streams.each { |stream| @backups << stream.dup }
199
+ @sink = RotatingFile.new(path: path, max_bytes: max_bytes, max_files: max_files)
200
+ @reader, @writer = IO.pipe
201
+ @wake_reader, @wake_writer = IO.pipe
202
+ @writer.binmode
203
+ @writer.sync = true
204
+ @worker = Thread.new do
205
+ Thread.current.report_on_exception = false
206
+ drain
207
+ end
208
+ rescue StandardError
209
+ begin
210
+ close_handles
211
+ ensure
212
+ release_backups
213
+ end
214
+ raise
215
+ end
216
+
217
+ def includes?(stream:)
218
+ @streams.any? { |candidate| candidate.equal?(stream) }
219
+ end
220
+
221
+ def attach
222
+ writer = @state.synchronize { @writer.dup unless @writer.closed? }
223
+ unless writer
224
+ # A failure during shutdown must not leave restored regular-file
225
+ # descriptors as an uncapped fallback. A readerless pipe fails closed.
226
+ reader, writer = IO.pipe
227
+ reader.close
228
+ end
229
+ @streams.each_with_index do |stream, index|
230
+ next if stream.closed?
231
+
232
+ stream.reopen(writer)
233
+ apply_properties(stream: stream, index: index, captured: true)
234
+ end
235
+ rescue StandardError
236
+ # Construction still has original descriptors available for rollback.
237
+ raise unless @backups.empty?
238
+
239
+ # If even dup/reopen fails, close the destinations rather than leave a
240
+ # restored regular-file descriptor able to bypass the retention cap.
241
+ failure = nil
242
+ @streams.each do |stream|
243
+ stream.close unless stream.closed?
244
+ rescue StandardError => e
245
+ failure ||= e
246
+ end
247
+ raise failure if failure
248
+
249
+ raise
250
+ ensure
251
+ writer.close if writer && !writer.closed?
252
+ end
253
+
254
+ def restore
255
+ file = @state.synchronize do
256
+ prepared = @restore_file
257
+ @restore_file = nil
258
+ prepared
259
+ end
260
+ raise IOError, 'stdio restoration was not prepared' unless file
261
+
262
+ @streams.each_with_index do |stream, index|
263
+ next if stream.closed?
264
+
265
+ stream.reopen(file)
266
+ apply_properties(stream: stream, index: index, captured: false)
267
+ end
268
+ ensure
269
+ file&.close
270
+ end
271
+
272
+ def release_backups
273
+ failure = nil
274
+ @backups.each do |backup|
275
+ backup.close unless backup.closed?
276
+ rescue StandardError => e
277
+ failure ||= e
278
+ end
279
+ @backups.clear
280
+ raise failure if failure
281
+ end
282
+
283
+ def rollback
284
+ @streams.each_with_index do |stream, index|
285
+ stream.reopen(@backups[index])
286
+ apply_properties(stream: stream, index: index, captured: false)
287
+ end
288
+ ensure
289
+ begin
290
+ finish!
291
+ ensure
292
+ release_backups
293
+ end
294
+ end
295
+
296
+ def check_failure!
297
+ @state.synchronize do
298
+ raise @failure if @failure
299
+ raise IOError, 'stdio capture is stopping' if @stopping
300
+ end
301
+ end
302
+
303
+ def flush(deadline:)
304
+ wait_for_flush(deadline: deadline, pause: false)
305
+ end
306
+
307
+ def pause(deadline:)
308
+ wait_for_flush(deadline: deadline, pause: true)
309
+ end
310
+
311
+ def resume
312
+ file = @state.synchronize do
313
+ @pause_target = nil
314
+ prepared = @restore_file
315
+ @restore_file = nil
316
+ prepared
317
+ end
318
+ file&.close
319
+ @state.synchronize do
320
+ @paused = false
321
+ wake unless @failure || @stopping
322
+ end
323
+ end
324
+
325
+ def finish!(deadline: nil) # rubocop:disable Naming/PredicateMethod -- Stops and joins the worker; false signals its deadline.
326
+ @state.synchronize do
327
+ unless @stopping
328
+ @stopping = true
329
+ wake unless @failure
330
+ end
331
+ end
332
+ remaining = [deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC), 0].max if deadline
333
+ !@worker.join(remaining).nil?
334
+ end
335
+
336
+ def command(type:, arguments:)
337
+ @state.synchronize do
338
+ raise @failure if @failure
339
+ raise IOError, 'stdio capture is stopping' if @stopping
340
+
341
+ wait_until { !@command }
342
+
343
+ command = { type: type, arguments: arguments, complete: false }
344
+ @command = command
345
+ wake
346
+ wait_until { command[:complete] }
347
+ command[:result]
348
+ end
349
+ end
350
+
351
+ private
352
+
353
+ def apply_properties(stream:, index:, captured:)
354
+ sync, close_on_exec, external, internal = @properties[index]
355
+ stream.sync = captured || sync
356
+ stream.close_on_exec = close_on_exec
357
+ stream.set_encoding(external, internal) if external
358
+ end
359
+
360
+ def wait_for_flush(deadline:, pause:)
361
+ @state.synchronize do
362
+ raise @failure if @failure
363
+ raise IOError, 'stdio capture is stopping' if @stopping
364
+
365
+ target = @requested_flush += 1
366
+ @pause_target = target if pause
367
+ wake
368
+ wait_until(deadline: deadline) { @completed_flush >= target && (!pause || @paused) }
369
+ end
370
+ end
371
+
372
+ def wait_until(deadline: nil)
373
+ loop do
374
+ raise @failure if @failure
375
+ return true if yield
376
+
377
+ if deadline
378
+ remaining = deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
379
+ return false unless remaining.positive?
380
+ end
381
+
382
+ @changed.wait(@state, remaining)
383
+ end
384
+ end
385
+
386
+ def wake
387
+ return if @wake_pending
388
+
389
+ @wake_writer.write_nonblock('.')
390
+ @wake_pending = true
391
+ end
392
+
393
+ def next_action
394
+ @state.synchronize do
395
+ return [:stop] if @stopping
396
+ return [:wait] if @paused
397
+ return [:command, @command] if @command
398
+
399
+ # Only this thread completes flushes, after both the read and its
400
+ # corresponding sink write. A consumed chunk cannot hide from flush.
401
+ bytes = @reader.read_nonblock(READ_BYTES, exception: false)
402
+ return [:write, bytes] if bytes.is_a?(String)
403
+ return [:flush, @requested_flush] if @requested_flush > @completed_flush
404
+
405
+ [:wait]
406
+ end
407
+ end
408
+
409
+ def drain
410
+ loop do
411
+ action, payload = next_action
412
+ case action
413
+ when :stop then break
414
+ when :wait then wait_for_input
415
+ when :write then @sink.write(payload)
416
+ when :command then execute_command(command: payload)
417
+ when :flush then complete_flush(target: payload)
418
+ end
419
+ end
420
+ rescue StandardError => e
421
+ # Closing the read end wakes blocked producers with EPIPE. Never log a
422
+ # drain failure to stderr: stderr may be the pipe which just failed.
423
+ begin
424
+ @reader.close unless @reader.closed?
425
+ ensure
426
+ @state.synchronize do
427
+ @failure = e
428
+ @changed.broadcast
429
+ end
430
+ end
431
+ raise
432
+ ensure
433
+ close_handles
434
+ end
435
+
436
+ def wait_for_input
437
+ IO.select(@paused ? [@wake_reader] : [@reader, @wake_reader])
438
+ @state.synchronize do
439
+ @wake_reader.read_nonblock(READ_BYTES, exception: false)
440
+ @wake_pending = false
441
+ end
442
+ end
443
+
444
+ def execute_command(command:)
445
+ result = @sink.public_send(command[:type], **command[:arguments])
446
+ @state.synchronize do
447
+ command[:result] = result
448
+ command[:complete] = true
449
+ @command = nil
450
+ @changed.broadcast
451
+ end
452
+ end
453
+
454
+ def complete_flush(target:)
455
+ @sink.flush
456
+ prepare = @state.synchronize { @pause_target && target >= @pause_target }
457
+ file = File.new(@path, 'ab') if prepare
458
+ retained = false
459
+ @state.synchronize do
460
+ @completed_flush = target
461
+ if file && @pause_target && target >= @pause_target
462
+ @restore_file = file
463
+ @paused = true
464
+ retained = true
465
+ end
466
+ @changed.broadcast
467
+ end
468
+ ensure
469
+ file.close if file && !retained
470
+ end
471
+
472
+ def close_handles
473
+ failure = nil
474
+ [@reader, @writer, @wake_reader, @wake_writer, @restore_file, @sink].compact.each do |handle|
475
+ if handle.equal?(@writer)
476
+ @state.synchronize { handle.close unless handle.closed? }
477
+ else
478
+ handle.close unless handle.closed?
479
+ end
480
+ rescue StandardError => e
481
+ failure ||= e
482
+ @state.synchronize do
483
+ @failure ||= e
484
+ @changed.broadcast
485
+ end
486
+ end
487
+ raise failure if failure
488
+ end
489
+ end
490
+ private_constant :StdioCapture
491
+ end
492
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module Logging
5
- VERSION = '1.5.19'
5
+ VERSION = '1.5.21'
6
6
  end
7
7
  end
@@ -78,8 +78,11 @@ module Legion
78
78
 
79
79
  def setup(level: 'debug', format: :text, async: true, shutdown_timeout: 2, **options)
80
80
  with_lifecycle_lock do
81
+ deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + shutdown_timeout if shutdown_timeout
81
82
  return false if @async_writer && stop_async_writer(timeout: shutdown_timeout) == false
82
- return false if output(**options) == false
83
+
84
+ remaining = [deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC), 0].max if deadline
85
+ return false if output(shutdown_timeout: remaining, **options) == false
83
86
 
84
87
  log_level(level)
85
88
  log_format(format: format, **options)
@@ -94,6 +97,7 @@ module Legion
94
97
  extended: options.fetch(:extended, true),
95
98
  log_file: options[:log_file],
96
99
  log_stdout: options[:log_stdout],
100
+ rotation: options[:rotation],
97
101
  include_pid: options.fetch(:include_pid, false),
98
102
  color: @color
99
103
  }.freeze
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legion-logging
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.19
4
+ version: 1.5.21
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -72,11 +72,14 @@ files:
72
72
  - lib/legion/logging/multi_io.rb
73
73
  - lib/legion/logging/output_logger.rb
74
74
  - lib/legion/logging/redactor.rb
75
+ - lib/legion/logging/rotating_file.rb
76
+ - lib/legion/logging/rotation.rb
75
77
  - lib/legion/logging/settings.rb
76
78
  - lib/legion/logging/shipper.rb
77
79
  - lib/legion/logging/shipper/file_transport.rb
78
80
  - lib/legion/logging/shipper/http_transport.rb
79
81
  - lib/legion/logging/siem_exporter.rb
82
+ - lib/legion/logging/stdio_rotator.rb
80
83
  - lib/legion/logging/tagged_logger.rb
81
84
  - lib/legion/logging/version.rb
82
85
  - lib/legion/service.rb