Selkie.git | t/ | 70-terminal-cleanup.rakutest


use Test;
use lib 'lib';

use NativeCall;

use Selkie::App;
use Selkie::App::Internal::Terminal;
use Selkie::App::Internal::TerminalPlatform;
use Selkie::App::Internal::TerminalPlatform :test;

# build-terminal-cleanup-sequence is the escape-sequence backstop
# emitted at the end of Selkie::App.shutdown to defend against
# terminal-mode leaks notcurses_stop didn't fully handle (notably
# the Kitty keyboard protocol push on iTerm2). Tested as a class
# method so it doesn't need a real notcurses init or a tty.

my $seq = Selkie::App.build-terminal-cleanup-sequence;

ok $seq.chars > 0, 'returns a non-empty string';
ok $seq.starts-with("\e"), 'starts with ESC';

# Kitty keyboard protocol pop is the load-bearing fix for the
# iTerm2 leak. Three pops because notcurses pushes once but
# overshooting is safe (pop on empty stack is a no-op) and other
# code paths could nest a push.
my $pop-count = ($seq ~~ m:g/"\e[<u"/).elems;
is $pop-count, 3, 'Kitty keyboard protocol pop emitted three times';

# Mouse tracking — every encoding variant the standard defines,
# so any leak across notcurses-supported terminals is covered.
like $seq, /"\e[?1000l"/, 'X10 mouse off';
like $seq, /"\e[?1001l"/, 'VT220 highlight mouse off';
like $seq, /"\e[?1002l"/, 'button-event mouse off';
like $seq, /"\e[?1003l"/, 'any-event mouse off';
like $seq, /"\e[?1005l"/, 'UTF-8 mouse encoding off';
like $seq, /"\e[?1006l"/, 'SGR mouse encoding off';
like $seq, /"\e[?1015l"/, 'urxvt mouse encoding off';
like $seq, /"\e[?1016l"/, 'SGR-pixel mouse encoding off';

# Other input-protocol modes that can leak.
like $seq, /"\e[?1004l"/, 'focus event reporting off';
like $seq, /"\e[?2004l"/, 'bracketed paste off';
like $seq, /"\e[>4;0m"/,  'modify-other-keys off';

# Visible state — cursor restoration and SGR reset so the shell
# isn't left styled or with a hidden cursor.
like $seq, /"\e[?25h"/,   'cursor shown (DECTCEM set)';
like $seq, /"\e[0m"/,     'SGR reset';
like $seq, /"\e[?1049l"/, 'alternate screen exited (idempotent)';

# --- Shutdown exception isolation ---------------------------------------
#
# The shutdown method must keep running its terminal-cleanup steps even
# if an earlier step throws. The canonical failure mode in the wild is
# Notcurses-Native being reinstalled to a new bundled-binaries path
# mid-session: NativeCall's lazy dlopen then fails inside notcurses_stop
# (or inside Widget.destroy paths called from screen-manager.destroy),
# and a bare throw would skip restore-tty-state[2], the escape-sequence
# backstop, and the stderr restore — leaving the user staring at a
# wedged terminal.
#
# Constructing a real Selkie::App requires a working notcurses init, so
# we can't drive shutdown directly in a unit test. Instead, assert the
# structural invariants on the source — each throw-prone step is wrapped
# in a try, and the escape-sequence backstop sits at the end where it
# can always run. Brittle to surface formatting but cheap, and it locks
# in the contract documented in the shutdown Pod block.

my $src-path = $?FILE.IO.parent.parent.add('lib/Selkie/App.rakumod');
my $src = $src-path.slurp;
my $lifecycle-src-path = $?FILE.IO.parent.parent.add('lib/Selkie/App/Internal/ScreenModalLifecycle.rakumod');
my $lifecycle-src = $lifecycle-src-path.slurp;
my $shutdown-body = ($src ~~ /'method shutdown()' \s* '{' (.*?) ^^ '}' /).[0].Str;
ok $shutdown-body.defined && $shutdown-body.chars > 0,
    'shutdown method body extracted from source';

# Selkie 0.8.0 replaced the bare `try { }` blocks with `self!try-log(...)`
# which provides the same isolation (each step in its own CATCH) plus
# diagnostic logging instead of silently swallowing the exception. The
# invariant remains: each throw-prone shutdown step is wrapped so a
# failure doesn't strand the escape-sequence backstop below.
like $shutdown-body, /'!shutdown-screen-modal-lifecycle'/,
    'shutdown delegates modal/screen teardown to the lifecycle helper';

like $lifecycle-src, /'try-log(' <-[)]>* 'modal-destroy' <-[)]>* '$m.destroy'/,
    'modal-stack destroy is wrapped via !try-log';

like $lifecycle-src, /'try-log(' <-[)]>* 'screen-manager-destroy' <-[)]>* '$!screen-manager.destroy'/,
    'screen-manager.destroy is wrapped via !try-log';

like $shutdown-body, /'try-log(' <-[)]>* 'notcurses-stop' <-[)]>* 'notcurses_stop'/,
    'notcurses_stop is wrapped via !try-log';

# The tty restores must be step-isolated too. In the wild, the old
# spawn-based stty restore failed asynchronously (process cwd renamed
# away → posix_spawn ENOENT → X::Proc::Unsuccessful broken on a
# thread-pool thread), escaped its internal `try`, and propagated out
# of shutdown — skipping notcurses_stop and the escape-sequence
# backstop. Wedged terminal, the exact outcome the restore exists to
# prevent.
like $shutdown-body, /'try-log(' <-[)]>* 'restore-tty-pre-stop' <-[)]>* 'restore-tty-state'/,
    'pre-stop tty restore is wrapped via !try-log';
like $shutdown-body, /'try-log(' <-[)]>* 'restore-tty-post-stop' <-[)]>* 'restore-tty-state'/,
    'post-stop tty restore is wrapped via !try-log';

# --- Spawn-free termios capture/restore ---------------------------------
#
# The capture/restore pair must never spawn a child process: a deleted
# or renamed cwd makes every spawn fail with ENOENT, and shutdown is
# exactly the moment we can least afford that. Lock in the in-process
# tcgetattr/tcsetattr implementation.

my $terminal-src = $?FILE.IO.parent.parent
    .add('lib/Selkie/App/Internal/Terminal.rakumod').slurp;
my $platform-src = $?FILE.IO.parent.parent
    .add('lib/Selkie/App/Internal/TerminalPlatform.rakumod').slurp;

like   $platform-src, /'tcgetattr'/, 'adapter captures with in-process tcgetattr';
like   $platform-src, /'tcsetattr'/, 'adapter restores with in-process tcsetattr';
unlike $terminal-src, /'is native' | '\/dev\/tty' | 'try shell'/,
    'Terminal role delegates every platform-specific operation';
like $terminal-src, /'capture-controlling-terminal-state'/,
    'Terminal role delegates capture to the shared adapter';
like $terminal-src, /'restore-controlling-terminal-state'/,
    'Terminal role delegates restore to the shared adapter';

# Behavioral roundtrip via a probe class composing the role. Written to
# hold with or without a usable /dev/tty (headless CI has none): capture
# returns the undefined CArray[uint8] sentinel there and restore is a
# gated no-op, while on a real terminal the roundtrip re-applies the
# termios state we just read — idempotent either way.
my class TtyProbe does Selkie::App::Internal::Terminal {
    method capture()      { self!capture-tty-state }
    method restore()      { self!restore-tty-state }
    method adopt($state)  { $!saved-tty-state = $state }
}

my $probe = TtyProbe.new;
my $state;
lives-ok { $state = $probe.capture }, 'capture-tty-state lives';
ok ($state.defined ?? $state ~~ CArray[uint8] !! $state === CArray[uint8]),
    'capture returns a termios blob, or the undefined sentinel without a tty';
lives-ok { $probe.adopt($state); $probe.restore },
    'restore after capture lives (idempotent re-apply on a real tty)';
lives-ok { $probe.adopt(CArray[uint8]); $probe.restore },
    'restore with no saved state is a safe no-op';

subtest 'terminal platform selection is fail-closed on Windows' => {
    my %windows := terminal-platform-spec(True);
    my %posix := terminal-platform-spec(False);

    nok %windows<controlling-terminal>, 'Windows has no POSIX controlling-terminal API';
    is-deeply %windows<resize-signals>, ().List, 'Windows installs no SIGWINCH tap';
    is-deeply %windows<crash-signals>, ().List, 'Windows installs no POSIX fatal-signal taps';
    ok %posix<controlling-terminal>, 'POSIX enables controlling-terminal operations';
    is-deeply %posix<resize-signals>, <SIGWINCH>.List, 'POSIX selects SIGWINCH';
    is-deeply %posix<crash-signals>, <SIGABRT SIGTERM SIGHUP SIGQUIT>.List,
        'POSIX selects the recoverable fatal-signal set';

    if $*DISTRO.is-win {
        is terminal-resize-signals().elems, 0, 'host selector returns no Windows resize signal';
        is terminal-crash-signals().elems, 0, 'host selector returns no Windows crash signals';
    } else {
        isa-ok terminal-resize-signals().head, Signal,
            'host resize selector returns a Signal enum, not its integer value';
        ok terminal-crash-signals().all ~~ Signal,
            'host crash selector returns Signal enum values';
        lives-ok {
            my $tap = signal(terminal-resize-signals().head).tap: -> $ { };
            $tap.close;
        }, 'selected resize Signal can install and close a real tap';
    }

    if $*DISTRO.is-win {
        my $captured;
        lives-ok { $captured = capture-controlling-terminal-state },
            'Windows capture is a safe no-op without resolving POSIX symbols';
        ok $captured === CArray[uint8], 'Windows capture returns the undefined state sentinel';
        lives-ok { restore-controlling-terminal-state(CArray[uint8].allocate(256)) },
            'Windows restore ignores even a defined state without resolving POSIX symbols';
        lives-ok { write-controlling-terminal('not emitted') },
            'Windows terminal write is a safe no-op';
        lives-ok { disable-controlling-terminal-flow-control },
            'Windows flow-control setup is a safe no-op';
    } else {
        skip 'Windows-only lazy NativeCall guard', 5;
    }

    done-testing;
}

subtest 'debug defaults use the platform temporary directory' => {
    my $image-src = $?FILE.IO.parent.parent
        .add('lib/Selkie/Widget/Image.rakumod').slurp;
    like $src, /'$*TMPDIR.add("selkie-terminal-debug.'/,
        'terminal diagnostic defaults below the platform temp directory';
    like $image-src, /'$*TMPDIR.add("selkie-image-debug.'/,
        'image diagnostic defaults below the platform temp directory';
    unlike $src, /'"\/tmp\/selkie-terminal-debug.'/,
        'App contains no POSIX-only terminal debug default';
    unlike $image-src, /'"\/tmp\/selkie-image-debug.'/,
        'Image contains no POSIX-only image debug default';
    done-testing;
}

subtest 'raw POSIX descriptor ABI has one owner' => {
    my $posix-fd-src = $?FILE.IO.parent.parent
        .add('lib/Selkie/App/Internal/PosixFD.rakumod').slurp;
    my @adapter-paths = <
        lib/Selkie/App/Internal/ErrorLogPlatform.rakumod
        lib/Selkie/App/Internal/TerminalPlatform.rakumod
        lib/Selkie/Test/SnapshotPlatform.rakumod
    >;
    my $adapter-source = @adapter-paths.map({ $_.IO.slurp }).join("\n");

    like $posix-fd-src, /"symbol('dup')"/, 'shared ABI module owns dup';
    like $posix-fd-src, /"symbol('dup2')"/, 'shared ABI module owns dup2';
    like $posix-fd-src, /"symbol('close')"/, 'shared ABI module owns close';
    unlike $adapter-source,
        /"is symbol('dup')" | "is symbol('dup2')" | "is symbol('close')"/,
        'semantic adapters contain no duplicate raw POSIX descriptor declarations';
    done-testing;
}

done-testing;