Logrotate evaluates configuration when invoked, rotates eligible logs, and applies the selected retention and compression actions. A timer or another scheduler determines when that evaluation happens. The tool does not continuously cap file size, and renaming a log does not automatically redirect an application’s open descriptor. A working setup combines evaluation frequency, file policy, and the writer’s actual reopening behavior.

The historical size-64, rotate-2 fixture runs only at invocation time. Rename does not redirect the open descriptor; the controlled logger must acknowledge reopening. After the third rotation, .1 holds the third payload uncompressed, .2.gz holds the second, and the first payload has expired. In a separate failing postrotate case, a nonzero result leaves both renamed old content and an empty replacement. Thresholds, generations and delaycompress are not a disk cap or proof of writer handoff.
File boxes represent generations and descriptor targets, not a time or byte scale. HUP works here because the fixture explicitly implements reopening; it is not a universal application contract. All CLI outcomes are historical and were not rerun. Current article review remains pending; technical and execution checks remain blocked.

When it breaks

A size threshold can be exceeded between invocations, especially during bursts or scheduler outages. rotate limits generations, not elapsed history or temporary disk use. Threshold times generation count is no hard capacity guarantee: active logs, between-check growth, compression, and copying also consume space.

create sets replacement mode and ownership without signaling the application. Postrotate can invoke supported reopening, but HUP is not universally reload. delaycompress postpones compression one cycle; it cannot fix writers retaining old inodes.

Script failures can occur after file operations have already succeeded. A nonzero logrotate result is not a rollback of every rename or creation. Repeating forced rotation without inspecting the actual files and writer state can rotate again or discard needed generations. Diagnose the partial outcome instead of assuming that failure means nothing changed.

Check it quickly

Repair draft checked 2026-10-19: the revised source was checked statically and with offline command/file-state doubles only. No logrotate execution or signals occurred. Technical and operational verification remain blocked. The original fixture’s historical run used Linux 6.12.76, Python 3.11.2, logrotate 3.21.0 and ext4; none of those observations validate this revision.

For a future run, save as logrotate-cli.py in a separately authorized disposable Linux lab with Python 3.11+, logrotate 3.21.0 and gzip, no host mounts or unrelated workloads. Only after verifying that boundary, run python3 logrotate-cli.py --isolated-disposable-lab as root. The flag and root identity are prerequisites, not proof of isolation. Discard the lab afterward.

The private configuration and state remain separate from system policy. The known child explicitly implements HUP reopening. Startup is inside cleanup protection; nonblocking pipe reads and writes use monotonic five-second deadlines, explicit EOF/error checks and a bounded acknowledgment line. Stderr goes to a private file instead of an unread pipe. Cleanup attempts kill, bounded reap and each stream close independently, preserving the original failure. Earlier cleanup errors or an uncertain logrotate timeout skip file deletion and require disposal; a deletion failure may leave only partial diagnostics. A timed-out CLI can leave hook/compressor descendants even after the logger is reaped. These are controller deadlines, not guarantees against uninterruptible kernel operations or arbitrary descendants.

from pathlib import Path
from tempfile import mkdtemp
import gzip
import os
import select
import shutil
import stat
import subprocess
import sys
import time


worker = r'''
import os,signal,sys
path=sys.argv[1];fd=os.open(path,os.O_WRONLY|os.O_CREAT|os.O_APPEND,0o600)
def reopen(signum,frame):
    global fd
    replacement=os.open(path,os.O_WRONLY|os.O_APPEND)
    os.close(fd);fd=replacement
    print('reopened',flush=True)
signal.signal(signal.SIGHUP,reopen)
try:
    print('ready',flush=True)
    for line in sys.stdin:
        if line=='QUIT\n':break
        data=line.encode()
        if os.write(fd,data)!=len(data):raise OSError('short log write')
        print('wrote',flush=True)
finally:os.close(fd)
'''


def expect(process, expected):
    # Raw nonblocking reads: readiness alone does not mean a complete line.
    descriptor = process.stdout.fileno()
    deadline = time.monotonic() + 5
    answer = bytearray()
    while time.monotonic() < deadline:
        remaining = max(0, deadline - time.monotonic())
        if not select.select([descriptor], [], [], remaining)[0]:
            continue
        try:
            byte = os.read(descriptor, 1)
        except BlockingIOError:
            continue
        if not byte:
            raise RuntimeError(f'logger EOF waiting for {expected}')
        answer.extend(byte)
        if byte == b'\n':
            if answer != (expected + '\n').encode():
                raise RuntimeError(f'unexpected logger acknowledgment: {answer!r}')
            return
        if len(answer) > 64:
            raise RuntimeError('logger acknowledgment is too long')
    raise TimeoutError(f'logger did not acknowledge {expected}')


def send_line(process, text):
    descriptor = process.stdin.fileno()
    pending = (text + '\n').encode()
    deadline = time.monotonic() + 5
    while pending and time.monotonic() < deadline:
        remaining = max(0, deadline - time.monotonic())
        if not select.select([], [descriptor], [], remaining)[1]:
            continue
        try:
            written = os.write(descriptor, pending)
        except BlockingIOError:
            continue
        if written == 0:
            raise RuntimeError('logger input made no progress')
        pending = pending[written:]
    if pending:
        raise TimeoutError('logger input deadline expired')


def attempt(errors, name, operation):
    try:
        operation()
    except Exception as error:
        errors.append(f'{name}: {error!r}')


def main():
    if sys.argv[1:] != ['--isolated-disposable-lab']:
        raise SystemExit('requires a separately verified disposable Linux lab')
    if os.geteuid() != 0:
        raise RuntimeError('root is required, but does not prove isolation')
    root = Path(mkdtemp(prefix='xbcatm-logrotate-', dir='/tmp'))
    active = root / 'app.log'
    configuration = root / 'logrotate.conf'
    state = root / 'state'
    process = error_file = None
    rotation_uncertain = False
    try:
        # A regular error file cannot fill an unread stderr pipe.
        error_file = (root / 'logger.stderr').open('wb')
        process = subprocess.Popen([sys.executable, '-c', worker, str(active)],
                                   stdin=subprocess.PIPE, stdout=subprocess.PIPE,
                                   stderr=error_file, bufsize=0)
        os.set_blocking(process.stdin.fileno(), False)
        os.set_blocking(process.stdout.fileno(), False)
        expect(process, 'ready')
        configuration.write_text(f'''"{active}" {{
    size 64
    rotate 2
    compress
    delaycompress
    missingok
    notifempty
    create 0600 root root
    postrotate
        /bin/kill -HUP {process.pid}
    endscript
}}
''')
        def append(text):
            send_line(process, text)
            expect(process, 'wrote')
        def run_rotation(arguments):
            nonlocal rotation_uncertain
            rotation_uncertain = True
            result = subprocess.run(arguments, capture_output=True, text=True, timeout=5)
            rotation_uncertain = False
            return result
        def rotate(*options):
            return run_rotation(['logrotate', *options, '--state', str(state), str(configuration)])
        append('short')
        initial = active.read_bytes()
        result = rotate('--debug')
        assert result.returncode == 0, result.stderr
        assert active.read_bytes() == initial and not state.exists() and not (root / 'app.log.1').exists()
        result = rotate()
        assert result.returncode == 0, result.stderr
        assert state.exists() and not (root / 'app.log.1').exists()
        print('debug changes neither logs nor state; a normal below-threshold run records state without rotating')
        first = initial + b'first-' + b'a' * 80 + b'\n'
        second = b'second-' + b'b' * 80 + b'\n'
        third = b'third-' + b'c' * 80 + b'\n'
        for number, payload in enumerate([first, second, third], 1):
            append((payload[len(initial):] if number == 1 else payload).decode().rstrip('\n'))
            result = rotate()
            assert result.returncode == 0, result.stderr
            expect(process, 'reopened')
            assert active.read_bytes() == b'' and stat.S_IMODE(active.stat().st_mode) == 0o600
            assert (root / 'app.log.1').read_bytes() == payload
            if number >= 2:
                with gzip.open(root / 'app.log.2.gz', 'rb') as handle:
                    assert handle.read() == (first if number == 2 else second)
            assert not (root / 'app.log.3.gz').exists()
        append('new-active')
        assert active.read_bytes() == b'new-active\n'
        print('three actual rotations reopen the writer, delay compression one generation, retain two archives, and preserve the expected bytes')
        failed = root / 'failed.log'
        failed.write_bytes(b'failure-fixture')
        bad = root / 'failed.conf'
        bad.write_text(f'''"{failed}" {{
    size 1
    rotate 1
    create 0600 root root
    postrotate
        /bin/false
    endscript
}}
''')
        result = run_rotation(['logrotate', '--state', str(root / 'failed.state'), str(bad)])
        assert result.returncode != 0
        assert (root / 'failed.log.1').read_bytes() == b'failure-fixture' and failed.read_bytes() == b''
        print('a failed postrotate command returns failure after rename/create side effects have already happened')
        send_line(process, 'QUIT')
        assert process.wait(timeout=5) == 0, (root / 'logger.stderr').read_text()
    finally:
        original = sys.exc_info()[1]
        errors = []
        if rotation_uncertain:
            errors.append('logrotate did not return; hook/compressor descendants are unverified')
        if process is not None:
            # Popen.kill is a no-op when this owned child has already completed.
            attempt(errors, 'kill owned logger', process.kill)
            attempt(errors, 'reap logger', lambda: process.wait(timeout=5))
            for name in ('stdin', 'stdout'):
                stream = getattr(process, name)
                if stream is not None:
                    attempt(errors, 'close ' + name, stream.close)
        if error_file is not None:
            attempt(errors, 'close logger error file', error_file.close)
        if not errors:
            attempt(errors, 'remove private files', lambda: shutil.rmtree(root))
        if errors:
            message = 'cleanup incomplete; discard lab: ' + '; '.join(errors)
            if original is not None:
                original.add_note(message)
            else:
                raise RuntimeError(message)


if __name__ == '__main__':
    main()

The configuration selects a 64-byte threshold, two generations, one-cycle delayed compression, and root-owned mode-0600 replacements. Its hook signals only the controlled child’s known PID. Write/reopen acknowledgments verify handoff rather than assuming signal-command success proves it.

For a short log, debug mode changes neither logs nor private state. Normal invocation creates state without rotating below threshold: successful evaluation can legitimately leave logs unchanged, and inspection differs from mutation.

Three invocations each follow a known above-threshold payload. Every rotation creates an empty mode-0600 active file; the child acknowledges reopening. Generation one stays uncompressed; its predecessor becomes gzip-compressed generation two.

The fixture decompresses and compares exact bytes. After rotation three, generation two holds payload two and generation one holds payload three; no third compressed generation remains. A final record reaches the current active file, checking retention, compression, and writer placement beyond filenames.

A second private configuration deliberately fails postrotate: logrotate returns failure after moving old content into generation one and creating an empty active file. Bounded logger cleanup precedes private-file removal, as described above. These assertions require a future real run; they are not current execution evidence.

Operate the policy as a complete workflow

Use a state file consistent with the intended rotation job, and account for its locking when scheduling concurrent invocations. Separate state files are not a shared history. Debug mode is useful for reviewing decisions, while force mode changes eligibility and should not be mistaken for proof that the ordinary schedule will rotate at the intended time.

Choose directives according to the desired trigger. In the cited version, size and time criteria are mutually exclusive, with the last specified taking precedence. minsize requires both size and the time interval; maxsize permits earlier size-triggered rotation. All still require an invocation. Verify the effective policy against representative small, large, empty, and missing files, and check the application identity can write the newly created file.

Monitor scheduled evaluations, reopening, filesystem capacity, and retained history. If copytruncate is necessary, accept and test its copy-to-truncate loss window. Add independent shipping or backups when retention requires them; verify old records remain readable after compression, rotation, and recovery.

Sources

  • logrotate 3.21.0 manual, size, rotate, create, compression, state, debugging, and script failures, checked 2026-09-12.

  • rename(2), why renaming a pathname does not redirect a live writer, checked 2026-09-12.

  • signal(7), application-defined HUP handling versus default termination, checked 2026-09-12.

  • Python 3.11 subprocess, timeout and child cleanup semantics, checked 2026-10-19.

  • Python 3.11 os, nonblocking descriptor I/O, checked 2026-10-19.