Selkie.git | t/ | 105-screen-subscription-lifecycle.rakutest
use Test;
use lib 'lib';
use Selkie::Store;
use Selkie::Trace;
use Selkie::Widget;
use Selkie::Container;
use Selkie::ScreenManager;
use Selkie::Theme;
use Selkie::App::Internal::ScreenModalLifecycle;
use Selkie::App::Internal::FocusTree;
use Selkie::Widget::Border;
use Selkie::Widget::CardList;
use Selkie::Widget::Text;
use Selkie::Sizing;
=begin pod
C<ScreenManager.remove-screen> used to call plain C<Container.destroy>
with no tree-wide unsubscribe. C<destroy> only reaches what each
container's own C<destroy> reaches, and the containers that hold
children outside C<@!children> are exactly the ones it misses —
measured at ~7 leaked subscriptions per editor open/close cycle in
App::Cantina. Every leaked subscription then pays pull-check cost on
every subsequent store tick, forever.
The regression test is the cycle: add + switch + remove must return the
subscription count to its baseline, for every container shape that has
historically been missed.
=end pod
class W does Selkie::Widget {
method render() { self.clear-dirty }
}
class Screen does Selkie::Container {
method render() { self!render-children; self.clear-dirty }
}
# The screen-swap half of the lifecycle lives on Selkie::App, which
# needs a live notcurses context to construct. Compose the two internal
# roles directly instead — the same technique the roles were split out
# for — and stub the terminal so no plane is ever allocated.
class FakeApp
does Selkie::App::Internal::ScreenModalLifecycle
does Selkie::App::Internal::FocusTree
{
has $.store = Selkie::Store.new;
has $.theme = Selkie::Theme.default;
method !terminal-size(--> List) { (24, 80) }
method screen-manager() { self!screen-manager }
method register(Str:D $name, $root) {
$root.set-store($.store);
self!screen-manager.add-screen($name, $root);
}
method switch-screen(Str:D $name) { self!switch-screen($name) }
}
plan 7;
subtest 'remove-screen unsubscribes a plain container tree' => {
plan 3;
my $s = Selkie::Store.new;
my $sm = Selkie::ScreenManager.new;
my $main = Screen.new;
$main.set-store($s);
$sm.add-screen('main', $main);
my $editor = Screen.new;
$editor.set-store($s);
my $a = $editor.add(W.new);
my $b = $editor.add(W.new);
$sm.add-screen('editor', $editor);
$s.subscribe('a', ['x'], $a);
$s.subscribe('b', ['y'], $b);
$s.subscribe('e', ['z'], $editor);
is $s.subscription-count, 3, 'three subscriptions live';
$sm.switch-to('editor');
$sm.switch-to('main');
$sm.remove-screen('editor');
is $s.subscription-count, 0, 'remove-screen unsubscribed the tree';
nok $sm.has-screen('editor'), 'and dropped the registration';
};
subtest 'children held outside @!children are reached' => {
plan 4;
# Border keeps its child under .content; CardList keeps its cards in
# @!items and has no destroy override at all. Both are invisible to
# a naive .children cascade, and both are common on a real screen.
#
# Counts are compared against a baseline rather than absolute
# numbers: Border registers a focus subscription of its own when a
# store is attached, and that is exactly the sort of framework-owned
# sub the leak used to strand.
my $s = Selkie::Store.new;
my $sm = Selkie::ScreenManager.new;
$sm.add-screen('main', Screen.new);
my $baseline = $s.subscription-count;
my $root = Screen.new;
$root.set-store($s);
my $inner = W.new;
my $border = Selkie::Widget::Border.new(sizing => Sizing.flex);
$border.set-content($inner);
$root.add($border);
my $list = Selkie::Widget::CardList.new(sizing => Sizing.flex);
my $card-body = Selkie::Widget::Text.new(text => 'card', sizing => Sizing.fixed(1));
my $card-root = Selkie::Widget::Border.new(sizing => Sizing.fixed(3));
$card-root.set-content($card-body);
$list.add-item($card-body, root => $card-root, height => 3);
$root.add($list);
$root.set-store($s); # re-propagate to everything added since
$sm.add-screen('editor', $root);
$s.subscribe('border-content', ['x'], $inner);
$s.subscribe('card-root', ['x'], $card-root);
$s.subscribe('card-body', ['x'], $card-body);
$s.subscribe('list', ['x'], $list);
ok $s.subscription-count >= $baseline + 4,
'the screen contributed at least the four explicit subscriptions';
$sm.remove-screen('editor');
is $s.subscription-count, $baseline,
'Border .content and CardList cards were both unsubscribed';
# The same coverage through the Container-level cascade, which now
# shares the walk.
my $root2 = Screen.new;
$root2.set-store($s);
my $inner2 = W.new;
my $border2 = Selkie::Widget::Border.new(sizing => Sizing.flex);
$border2.set-content($inner2);
$root2.add($border2);
$root2.set-store($s);
$s.subscribe('nested', ['x'], $inner2);
ok $s.subscription-count >= $baseline + 1,
'nested subscription registered';
$root2.clear;
is $s.subscription-count, $baseline,
'Container.clear reaches .content too';
};
subtest 'repeat add/switch/remove cycles do not accumulate' => {
plan 1;
# The actual leak signature: the count creeps up per cycle.
my $s = Selkie::Store.new;
my $sm = Selkie::ScreenManager.new;
my $main = Screen.new;
$main.set-store($s);
$sm.add-screen('main', $main);
$s.subscribe('main', ['m'], $main);
my $baseline = $s.subscription-count;
my @counts;
for ^8 -> $i {
my $editor = Screen.new;
$editor.set-store($s);
my $body = $editor.add(W.new);
my $border = Selkie::Widget::Border.new(sizing => Sizing.flex);
my $deep = W.new;
$border.set-content($deep);
$editor.add($border);
# A CardList in the tree is what turns this from a formality
# into a regression test: it holds its cards in @!items and
# inherits no destroy override, so the destroy cascade alone
# never reaches them.
my $list = Selkie::Widget::CardList.new(sizing => Sizing.flex);
my $card = Selkie::Widget::Text.new(text => "card $i",
sizing => Sizing.fixed(1));
$list.add-item($card, root => $card, height => 1);
$editor.add($list);
$editor.set-store($s);
$sm.add-screen('editor', $editor);
$s.subscribe("ed-body-{$i}", ['x'], $body);
$s.subscribe("ed-deep-{$i}", ['y'], $deep);
$s.subscribe("ed-card-{$i}", ['c'], $card);
$s.subscribe("ed-root-{$i}", ['z'], $editor);
$sm.switch-to('editor');
$sm.switch-to('main');
$sm.remove-screen('editor');
$s.tick;
@counts.push: $s.subscription-count;
}
is-deeply @counts, [$baseline xx 8].Array,
'subscription count returns to baseline after every cycle';
};
subtest 'ScreenManager.destroy unsubscribes every screen' => {
plan 2;
my $s = Selkie::Store.new;
my $sm = Selkie::ScreenManager.new;
for <a b c> -> $name {
my $root = Screen.new;
$root.set-store($s);
my $kid = $root.add(W.new);
$sm.add-screen($name, $root);
$s.subscribe("{$name}-kid", ['x'], $kid);
}
is $s.subscription-count, 3, 'one subscription per screen';
$sm.destroy;
is $s.subscription-count, 0, 'shutdown left nothing behind';
};
subtest 'remove-screen still refuses the active screen' => {
plan 3;
my $s = Selkie::Store.new;
my $sm = Selkie::ScreenManager.new;
my $main = Screen.new;
$main.set-store($s);
$sm.add-screen('main', $main);
$s.subscribe('main', ['x'], $main);
my $res = $sm.remove-screen('main');
ok $res ~~ Failure, 'removing the active screen fails';
$res.so; # defuse
is $s.subscription-count, 1,
'and does not unsubscribe anything on the way out';
ok $sm.has-screen('main'), 'the screen is still registered';
};
subtest 'the swap path is instrumented' => {
plan 3;
# `screen.switch` / `screen.remove` spans: the 295ms close frame in
# the 2026-08 capture was a black box inside ui.input.dispatch.
my $path = $*TMPDIR.add("selkie-screen-spans-{$*PID}-{now.Int}.json").Str;
Selkie::Trace.init(mode => 'trace', trace-path => $path);
my $sm = Selkie::ScreenManager.new;
$sm.add-screen('main', Screen.new);
$sm.add-screen('editor', Screen.new);
$sm.switch-to('editor');
$sm.switch-to('main');
$sm.remove-screen('editor');
Selkie::Trace.shutdown;
my $json = $path.IO.slurp;
$path.IO.unlink if $path.IO.e;
like $json, /'"screen.remove"'/, 'screen.remove span recorded';
like $json, /'"screen"' \s* ':' \s* '"editor"'/,
'the span names the screen it removed';
like $json, /'trace/shutdown'/, 'the trace file is otherwise intact';
};
subtest 'screen.switch spans the whole swap' => {
plan 4;
my $path = $*TMPDIR.add("selkie-switch-span-{$*PID}-{now.Int}.json").Str;
my $app = FakeApp.new;
$app.register('main', Screen.new);
$app.register('editor', Screen.new);
Selkie::Trace.init(mode => 'trace', trace-path => $path);
$app.switch-screen('editor');
$app.switch-screen('main');
Selkie::Trace.shutdown;
my $json = $path.IO.slurp;
$path.IO.unlink if $path.IO.e;
like $json, /'"screen.switch"'/, 'screen.switch span recorded';
is +$json.comb(/'"screen.switch"'/), 2, 'one span per swap';
like $json, /'"to"' \s* ':' \s* '"editor"'/,
'the span names the incoming screen';
like $json, /'"subscriptions"'/,
'and reports the live subscription count, so the leak is visible '
~ 'in the capture itself';
};
done-testing;