Selkie.git | lib/Selkie/App/Internal/ | ErrorLog.rakumod


=begin pod

=head1 NAME

Selkie::App::Internal::ErrorLog - internal stderr redirection role for Selkie::App

=head1 DESCRIPTION

Implementation detail composed by C<Selkie::App>. Use C<Selkie::App.set-error-log>
or the C<error-log> constructor argument from application code.

=end pod

unit role Selkie::App::Internal::ErrorLog;

use NativeCall;
use Selkie::App::Internal::ErrorLogPlatform;

has Int $!saved-stderr-fd;
has IO::Handle $!saved-process-stderr;
has Pointer $!saved-windows-stderr-handle;
has Pointer $!windows-error-log-handle;
has Bool $!windows-stderr-synchronized = False;
has IO::Handle $!error-log-handle;

my constant INVALID-HANDLE-VALUE = -1;

my Lock $ERROR-LOG-LOCK .= new;

method !try-log(Str:D $step, &block) {
    block();
    CATCH {
        default {
            my $msg = .message;
            my $bt  = .?backtrace.?full.?Str // '';
            try $*ERR.say("[shutdown:$step] $msg");
            try $*ERR.say($bt) if $bt;
        }
    }
}

method !close-error-log-handle(--> Nil) {
    if $!error-log-handle {
        try $!error-log-handle.close;
        $!error-log-handle = Nil;
    }
}

method !replace-process-stderr(IO::Handle:D $handle --> Nil) {
    $!saved-process-stderr = PROCESS::<$ERR>;
    # Preserve Rakudo's process-level Scalar. Binding the symbol with := to a
    # method parameter or attribute container gives PROCESS::<$ERR> that
    # container's lifetime and can hang Windows during write or finalization.
    PROCESS::<$ERR> = $handle;
}

method !restore-process-stderr(--> Nil) {
    if $!saved-process-stderr.defined {
        PROCESS::<$ERR> = $!saved-process-stderr;
        $!saved-process-stderr = IO::Handle;
    }
}

method !install-error-log-unlocked(Str:D $path --> Nil) {
    my Str $clean-path = $path.trim;
    return if $clean-path.chars == 0;

    my $parent = $clean-path.IO.parent;
    try $parent.mkdir unless $parent.e;

    my $handle = try open $clean-path, :a;
    return unless $handle;
    $!error-log-handle = $handle;

    my $banner = "=== session {DateTime.now.truncated-to('second')} ===\n";
    try $handle.print($banner);
    try $handle.flush;

    if IS-WINDOWS {
        # MoarVM is built /MT on official Windows Rakudo releases, giving it
        # a private CRT descriptor table.  Passing native-descriptor to
        # ucrtbase._dup2 corrupts a different table and can close the kernel
        # handle cached behind MoarVM's fd 2.  Redirect the Raku object and
        # the Win32 standard-handle table independently instead.
        my $saved-windows-handle = try windows-get-std-handle(
            WINDOWS-STD-ERROR-HANDLE,
        );
        my $log-windows-handle = try windows-create-file(
            $clean-path.IO.absolute.Str,
            WINDOWS-FILE-APPEND-DATA +| WINDOWS-SYNCHRONIZE,
            WINDOWS-FILE-SHARE-ALL,
            Pointer,
            WINDOWS-OPEN-ALWAYS,
            WINDOWS-FILE-ATTRIBUTE-NORMAL,
            Pointer,
        );
        my $synchronized = $saved-windows-handle.defined
            && $saved-windows-handle.Int != INVALID-HANDLE-VALUE
            && $log-windows-handle.defined
            && $log-windows-handle.Int != INVALID-HANDLE-VALUE
            && $log-windows-handle.Int != 0
            && (try windows-set-std-handle(
                WINDOWS-STD-ERROR-HANDLE,
                $log-windows-handle,
            ));

        unless $synchronized {
            try windows-close-handle($log-windows-handle)
                if $log-windows-handle.defined
                    && $log-windows-handle.Int != INVALID-HANDLE-VALUE
                    && $log-windows-handle.Int != 0;
            self!close-error-log-handle;
            return;
        }

        $!saved-windows-stderr-handle = $saved-windows-handle;
        $!windows-error-log-handle = $log-windows-handle;
        $!windows-stderr-synchronized = True;
        self!replace-process-stderr($handle);
        return;
    }

    my $log-fd = $handle.native-descriptor;
    unless $log-fd.defined && $log-fd >= 0 {
        self!close-error-log-handle;
        return;
    }

    # NativeCall resolves lazily.  An optional error log must never prevent
    # the application from starting if a platform runtime is missing a
    # symbol, so thrown resolution/call failures and negative return codes
    # both take the same fail-open cleanup path.
    my $saved = try fd-dup(2);
    unless $saved.defined && $saved >= 0 {
        self!close-error-log-handle;
        return;
    }

    my $rc = try fd-dup2($log-fd, 2);
    unless $rc.defined && $rc >= 0 {
        try fd-close($saved);
        self!close-error-log-handle;
        return;
    }

    $!saved-stderr-fd = $saved;
}

method !install-error-log(Str:D $path --> Nil) {
    $ERROR-LOG-LOCK.protect: {
        self!install-error-log-unlocked($path);
    };
}

method !uninstall-error-log-unlocked(--> Nil) {
    if IS-WINDOWS && $!windows-stderr-synchronized {
        self!restore-process-stderr;
        my $restored = $!saved-windows-stderr-handle.defined
            && (try windows-set-std-handle(
                WINDOWS-STD-ERROR-HANDLE,
                $!saved-windows-stderr-handle,
            ));
        # If SetStdHandle unexpectedly fails, retain the log HANDLE rather
        # than leaving the process table pointing at a closed HANDLE.
        try windows-close-handle($!windows-error-log-handle)
            if $restored && $!windows-error-log-handle.defined;

        $!saved-windows-stderr-handle = Pointer;
        $!windows-error-log-handle = Pointer;
        $!windows-stderr-synchronized = False;
        self!close-error-log-handle;
        return;
    }

    if $!saved-stderr-fd.defined && $!saved-stderr-fd >= 0 {
        try fd-dup2($!saved-stderr-fd, 2);
        try fd-close($!saved-stderr-fd);
        $!saved-stderr-fd = Int;
    }
    self!close-error-log-handle;
}

method !uninstall-error-log(--> Nil) {
    $ERROR-LOG-LOCK.protect: {
        self!uninstall-error-log-unlocked;
    };
}