diff --git a/cpanfile b/cpanfile index 5ff8009f0..e88a329bc 100644 --- a/cpanfile +++ b/cpanfile @@ -2,7 +2,7 @@ # Do not edit this file directly. To change prereqs, edit the `dist.ini` file. requires "App::Yath::Script" => "2.000011"; -requires "Atomic::Pipe" => "0.021"; +requires "Atomic::Pipe" => "0.026"; requires "B" => "0"; requires "Capture::Tiny" => "0.48"; requires "Carp" => "0"; @@ -40,7 +40,7 @@ requires "IO::Select" => "0"; requires "IO::Uncompress::Bunzip2" => "0"; requires "IO::Uncompress::Gunzip" => "0"; requires "IPC::Cmd" => "0"; -requires "IPC::Manager" => "0.000032"; +requires "IPC::Manager" => "0.000034"; requires "Importer" => "0.025"; requires "JSON::PP" => "0"; requires "LWP" => "0"; diff --git a/dist.ini b/dist.ini index 47803d9ba..d40d26615 100644 --- a/dist.ini +++ b/dist.ini @@ -76,7 +76,7 @@ goto::file = 0.005 mro = 0 parent = 0.241 App::Yath::Script = 2.000011 -Atomic::Pipe = 0.021 +Atomic::Pipe = 0.026 B = 0 Capture::Tiny = 0.48 Carp = 0 @@ -114,7 +114,7 @@ IO::Select = 0 IO::Uncompress::Bunzip2 = 0 IO::Uncompress::Gunzip = 0 IPC::Cmd = 0 -IPC::Manager = 0.000032 +IPC::Manager = 0.000034 Importer = 0.025 JSON::PP = 0 LWP = 0 diff --git a/lib/App/Yath2/Command/extract.pm b/lib/App/Yath2/Command/extract.pm index 49d90b4a3..9f852965e 100644 --- a/lib/App/Yath2/Command/extract.pm +++ b/lib/App/Yath2/Command/extract.pm @@ -64,7 +64,7 @@ sub run { my @rest; for my $arg (@$args) { if ($arg eq '--no-decompress') { $no_decompress = 1 } - else { push @rest => $arg } + else { push @rest => $arg } } @$args = @rest; @@ -90,9 +90,10 @@ sub run { my $count = 0; for my $rel ($la->list_files) { - my $is_zst = $rel =~ /\.zst\z/; - my $is_dict = $rel eq 'zstd-dict.bin'; - my $out_rel = ($no_decompress || !$is_zst || $is_dict) + my $is_zst = $rel =~ /\.zst\z/; + my $is_dict = $rel eq 'zstd-dict.bin'; + my $out_rel = + ($no_decompress || !$is_zst || $is_dict) ? $rel : do { (my $r = $rel) =~ s/\.zst\z//; $r }; @@ -115,7 +116,7 @@ sub run { # would still point at non-existent '.zst' paths. Rewrite # the keys to match the extracted shape so consumers # (yath replay, ad-hoc tooling) find the files. - if ($out_rel eq 'artifacts.json' + if ( $out_rel eq 'artifacts.json' || $out_rel =~ m{\Aruns/[^/]+/artifacts\.json\z}) { $bytes = _rewrite_manifest_keys($bytes); @@ -125,7 +126,7 @@ sub run { open(my $out, '>', $abs) or die "open $abs: $!\n"; binmode $out; print $out $bytes; - close $out or die "close $abs: $!\n"; + close $out or die "close $abs: $!\n"; $count++; } @@ -157,37 +158,60 @@ sub _rewrite_manifest_keys { # Decompress a possibly-multi-frame zstd byte string. The single # .json.zst snapshots produce one frame; the .jsonl.zst event -# streams concatenate one-shot frame per line, so the extracted -# plaintext is the concatenation of every frame's decoded payload. +# streams concatenate one self-contained zstd frame per record. # -# Without a dict we feed everything into a streaming Decompressor. -# With a dict the streaming binding does not accept dicts, so we -# walk frames using zstd_frame_size and decompress each with a -# reused DecompressionContext. +# Producers (the JSONL.zst writer, EventEmitter) do NOT embed an +# inter-record newline inside the compressed plaintext -- frames +# self-delimit -- so the extract command is responsible for +# reinserting exactly one newline between records when materializing +# extracted plaintext jsonl. We strip any trailing newlines from +# each frame's payload first so producers that did include one (the +# legacy plain JSONL writer through the same path, or older archives) +# still extract to a single-newline-per-record shape. +# +# Walks frames via zstd_frame_size for both the dict and no-dict +# paths; one frame is one record either way. sub _decompress_multi_frame { my ($bytes, $dict_bytes) = @_; - if ($dict_bytes) { - my $ddict = Compress::Zstd::DecompressionDictionary->new($dict_bytes); - my $dctx = Compress::Zstd::DecompressionContext->new; - my $out = ''; - while (length $bytes) { - my $size = zstd_frame_size($bytes); - die "tar.zidx: incomplete zstd frame in extract payload\n" - unless defined $size; - my $frame = substr($bytes, 0, $size); - substr($bytes, 0, $size) = ''; - my $plain = $dctx->decompress_using_dict($frame, $ddict); - die "tar.zidx: decompress_using_dict failed\n" unless defined $plain; - $out .= $plain; - } - return $out; + my $ddict; + $ddict = Compress::Zstd::DecompressionDictionary->new($dict_bytes) + if defined $dict_bytes; + my $dctx = Compress::Zstd::DecompressionContext->new if $ddict; + + my @records; + while (length $bytes) { + my $size = zstd_frame_size($bytes); + die "tar.zidx: incomplete zstd frame in extract payload\n" + unless defined $size; + my $frame = substr($bytes, 0, $size); + substr($bytes, 0, $size) = ''; + + my $plain = + $ddict + ? $dctx->decompress_using_dict($frame, $ddict) + : Compress::Zstd::decompress($frame); + die "tar.zidx: zstd decompress failed in extract payload\n" + unless defined $plain; + + push @records => $plain; } - my $d = Compress::Zstd::Decompressor->new; - $d->init; - my $out = $d->decompress($bytes); - die "tar.zidx: streaming decompress failed\n" unless defined $out; + return '' unless @records; + + # Single-frame snapshots (.json.zst): return the payload as-is; + # there is no inter-record story to enforce, and a snapshot's + # body may legitimately end without a newline. + return $records[0] if @records == 1; + + # Multi-frame streams (.jsonl.zst): one record per line, exactly + # one newline between records, trailing newline included so the + # file is canonical jsonl. + my $out = ''; + for my $r (@records) { + $r =~ s/\n+\z//; + $out .= $r . "\n"; + } return $out; } diff --git a/lib/App/Yath2/Command/test.pm b/lib/App/Yath2/Command/test.pm index 14578da91..20f9d65ea 100644 --- a/lib/App/Yath2/Command/test.pm +++ b/lib/App/Yath2/Command/test.pm @@ -27,6 +27,7 @@ use App::Yath2::OutputManager(); use App::Yath2::Options::Renderer(); use App::Yath2::Util::IPC qw/publish_ipc_file unlink_ipc_file/; use Scope::Guard (); +use Test2::Util qw/IS_WIN32/; use Getopt::Yath; include_options( @@ -113,10 +114,20 @@ sub run { my $workdir = $settings->workspace->workdir; + my @resources = (Test2::Harness2::Resource::JobCount->new(slots => 16)); + + if (!IS_WIN32 && $settings->can('runner') && @{$settings->runner->preloads // []}) { + require Test2::Harness2::Resource::Preload; + push @resources => Test2::Harness2::Resource::Preload->new( + preloads => $settings->runner->preloads, + preload_early => $settings->runner->preload_early // {}, + ); + } + my $spawn = Test2::Harness2->spawn( workdir => $workdir, protocol => $settings->ipc->protocol, - resources => [Test2::Harness2::Resource::JobCount->new(slots => 16)], + resources => \@resources, loggers => [ 'Test2::Harness2::Collector::Logger::JSONL', 'Test2::Harness2::Collector::Logger::JSON', @@ -204,6 +215,14 @@ sub run { # around to accept these requests. eval { $spawn->unsubscribe; 1 } or warn $@; + # Wait for the harness to drain any events still queued for us + # before we tell it to finish. The has_pending_messages request + # itself does not count as pending work (its response is queued + # AFTER the handler returns). Cap at 30 seconds; on timeout we + # proceed anyway because the run is over and any straggler + # events at that point are not worth blocking exit on. + eval { $spawn->wait_until_idle(30); 1 } or warn $@; + # Drop the streamer explicitly: it holds a reference to $spawn, # which is what keeps the IPC handle (and its AtomicPipe client) # alive. Without this, the client's pre_disconnect_hook fires diff --git a/lib/App/Yath2/Options/IPC.pm b/lib/App/Yath2/Options/IPC.pm index 6eaf0a5a0..45dbcf6a4 100644 --- a/lib/App/Yath2/Options/IPC.pm +++ b/lib/App/Yath2/Options/IPC.pm @@ -35,8 +35,9 @@ option_group {group => 'ipc', category => 'IPC Options'} => sub { option protocol => ( name => 'ipc-protocol', type => 'Scalar', - description => 'IPC::Manager client driver (default MessageFiles). ' . 'Use "+Some::Class" to force a fully qualified namespace.', + description => 'IPC::Manager client driver (default ConnectionUnix). ' . 'Use "+Some::Class" to force a fully qualified namespace.', long_examples => [ + ' ConnectionUnix', ' AtomicPipe', ' UnixSocket', ' JSONFile', @@ -45,7 +46,7 @@ option_group {group => 'ipc', category => 'IPC Options'} => sub { ' SQLite', ' +Custom::Driver', ], - default => sub { 'IPC::Manager::Client::MessageFiles' }, + default => sub { 'IPC::Manager::Client::ConnectionUnix' }, normalize => \&_normalize_protocol, ); diff --git a/lib/App/Yath2/Renderer/Default.pm b/lib/App/Yath2/Renderer/Default.pm index 58c5b59a9..0958ca121 100644 --- a/lib/App/Yath2/Renderer/Default.pm +++ b/lib/App/Yath2/Renderer/Default.pm @@ -680,8 +680,11 @@ sub render_parent { my $meth = $params{quiet} ? 'build_quiet' : 'build_event'; my @out; + my $ph = $f->{harness} || {}; for my $sf (@{$f->{parent}->{children}}) { - $sf->{harness} ||= $f->{harness}; + my $ch = $sf->{harness} //= {}; + $ch->{job_id} //= $ph->{job_id} if defined $ph->{job_id}; + $ch->{run_id} //= $ph->{run_id} if defined $ph->{run_id}; my $stree = $self->render_tree($sf); push @out => @{$self->$meth($sf, $stree)}; } diff --git a/lib/App/Yath2/Renderer/Summary.pm b/lib/App/Yath2/Renderer/Summary.pm index c875a0ceb..357a88b51 100644 --- a/lib/App/Yath2/Renderer/Summary.pm +++ b/lib/App/Yath2/Renderer/Summary.pm @@ -3,7 +3,7 @@ use strict; use warnings; use Test2::Util::Table qw/table/; -use Test2::Harness2::Util qw/clean_path/; +use Test2::Harness2::Util qw/clean_path render_duration/; use Test2::Harness2::Util::JSON qw/json_true json_false/; use List::Util qw/max/; @@ -91,14 +91,15 @@ sub render_summary { my $self = shift; my ($run_end) = @_; - my $pass = $run_end->{pass}; - my $pass_count = $run_end->{pass_count} // 0; - my $fail_count = $run_end->{fail_count} // 0; - my $total = $pass_count + $fail_count; - my $wall_time = $run_end->{wall_time}; - my $cpu_times = $run_end->{cpu_times}; - my $cpu_total = $run_end->{cpu_total}; - my $cpu_usage = $run_end->{cpu_usage}; + my $pass = $run_end->{pass}; + my $pass_count = $run_end->{pass_count} // 0; + my $fail_count = $run_end->{fail_count} // 0; + my $total = $pass_count + $fail_count; + my $wall_time = $run_end->{wall_time}; + my $cum_job_time = $run_end->{cumulative_job_time}; + my $cpu_times = $run_end->{cpu_times}; + my $cpu_total = $run_end->{cpu_total}; + my $cpu_usage = $run_end->{cpu_usage}; if (my $rows = $self->{+_FAILED_JOBS}) { if (@$rows) { @@ -114,16 +115,23 @@ sub render_summary { my @summary = ( $fail_count ? (" Fail Count: $fail_count") : (), " File Count: $total", - (defined $wall_time) + (defined $wall_time + ? sprintf(" Run Wall Time: %s", render_duration($wall_time)) + : ()), + (defined $cum_job_time || $cpu_times) ? ( - sprintf(" Wall Time: %.2f seconds", $wall_time), + "Aggregate Job Stats:", + (defined $cum_job_time + ? sprintf(" Cumulative Job Time: %s", render_duration($cum_job_time)) + : ()), ($cpu_times ? ( sprintf( - " CPU Time: %.2f seconds (usr: %.2fs | sys: %.2fs | cusr: %.2fs | csys: %.2fs)", - $cpu_total, @{$cpu_times}[0 .. 3] + " CPU Time: %s (usr: %s | sys: %s | cusr: %s | csys: %s)", + render_duration($cpu_total), + map { render_duration($_) } @{$cpu_times}[0 .. 3] ), - sprintf(" CPU Usage: %i%%", $cpu_usage), + sprintf(" CPU Usage: %i%%", $cpu_usage), ) : () ), @@ -165,10 +173,11 @@ sub write_summary_file { failed => $self->{+_FAILED_JOBS}, - (defined $run_end->{wall_time} ? (wall_time => $run_end->{wall_time}) : ()), - (defined $run_end->{cpu_total} ? (cpu_total => $run_end->{cpu_total}) : ()), - (defined $run_end->{cpu_usage} ? (cpu_usage => $run_end->{cpu_usage}) : ()), - (defined $run_end->{cpu_times} ? (cpu_times => $run_end->{cpu_times}) : ()), + (defined $run_end->{wall_time} ? (wall_time => $run_end->{wall_time}) : ()), + (defined $run_end->{cumulative_job_time} ? (cumulative_job_time => $run_end->{cumulative_job_time}) : ()), + (defined $run_end->{cpu_total} ? (cpu_total => $run_end->{cpu_total}) : ()), + (defined $run_end->{cpu_usage} ? (cpu_usage => $run_end->{cpu_usage}) : ()), + (defined $run_end->{cpu_times} ? (cpu_times => $run_end->{cpu_times}) : ()), ); require Test2::Harness2::Util::File::JSON; diff --git a/lib/App/Yath2/Streamer/Base.pm b/lib/App/Yath2/Streamer/Base.pm index 49ec70dea..f1e67b600 100644 --- a/lib/App/Yath2/Streamer/Base.pm +++ b/lib/App/Yath2/Streamer/Base.pm @@ -328,34 +328,41 @@ sub _harness_run_end_facet { my $results = ref($state->{results}) eq 'HASH' ? $state->{results} : {}; my $all_pass = 1; my ($fail_count, $pass_count) = (0, 0); - my @cpu_agg = (0, 0, 0, 0); - my $have_times = 0; + my @cpu_agg = (0, 0, 0, 0); + my $have_times = 0; + my $cum_job_wall = 0; + my $have_wall = 0; for my $jid (keys %$results) { next unless defined $results->{$jid}{completed_at}; if ($results->{$jid}{pass}) { $pass_count++ } else { $fail_count++; $all_pass = 0 } - if (my $t = $results->{$jid}{times}) { + if (my $t = $results->{$jid}{child_times}) { $cpu_agg[$_] += $t->[$_] for 0 .. 3; $have_times = 1; } + if (defined(my $w = $results->{$jid}{child_wall})) { + $cum_job_wall += $w; + $have_wall = 1; + } } my $stamp = _max_completed_at($results) // time; my $wall_time = defined $state->{created_at} ? ($stamp - $state->{created_at}) : undef; my %timing; + $timing{wall_time} = $wall_time if defined $wall_time; + $timing{cumulative_job_time} = $cum_job_wall if $have_wall; + if ($have_times) { my $cpu_total = $cpu_agg[0] + $cpu_agg[1] + $cpu_agg[2] + $cpu_agg[3]; - %timing = ( - wall_time => $wall_time, - cpu_times => \@cpu_agg, - cpu_total => $cpu_total, - cpu_usage => ($wall_time && $wall_time > 0) ? int($cpu_total / $wall_time * 100) : 0, - ); - } - elsif (defined $wall_time) { - %timing = (wall_time => $wall_time); + $timing{cpu_times} = \@cpu_agg; + $timing{cpu_total} = $cpu_total; + # CPU Usage relative to Run Wall: aggregate CPU-cores worth of + # work performed by all test jobs during the run. With high + # parallelism this can exceed 100% (one core = 100%). + $timing{cpu_usage} + = ($wall_time && $wall_time > 0) ? int($cpu_total / $wall_time * 100) : 0; } return { diff --git a/lib/App/Yath2/Tester.pm b/lib/App/Yath2/Tester.pm index f5e869726..56d8b261f 100644 --- a/lib/App/Yath2/Tester.pm +++ b/lib/App/Yath2/Tester.pm @@ -139,6 +139,17 @@ sub yath { $ENV{$_} = $env->{$_} for keys %$env; $ENV{YATH_COLOR} = 0; my $pid = start_process \@cmd => sub { + # When this test is itself running under an outer yath, that + # outer worker sets TMPDIR to a per-worker subdirectory like + # /tmp/yath-XXXXXXXX/tmp. The spawned inner yath places its + # IPC::Manager unix-socket route under TMPDIR. The sun_path + # budget on Linux is only 104 bytes, leaving no room for the + # 42-byte hashed peer-id under such a deep route, which makes + # the inner harness fail with "Cannot map peer id ... exceeds + # available budget". Reset TMPDIR to /tmp here in the spawned + # child so the inner yath gets a short route. See + # IPC::Manager::Client::ConnectionUnix::max_on_disk_name_length. + $ENV{TMPDIR} = '/tmp'; return unless $capture; swap_io(\*STDOUT, $wh); swap_io(\*STDERR, $wh); diff --git a/lib/Test2/Harness2.pm b/lib/Test2/Harness2.pm index 3cd78ccb8..2551b32d4 100644 --- a/lib/Test2/Harness2.pm +++ b/lib/Test2/Harness2.pm @@ -8,11 +8,12 @@ use Carp qw/croak/; use File::Copy qw/cp/; use File::Path qw/make_path/; use File::ShareDir (); -use File::Spec (); +use File::Spec (); use Scalar::Util qw/blessed/; use Time::HiRes qw/time/; use Test2::Util::UUID qw/gen_uuid/; use Test2::Harness2::Util qw/parse_exit tinysleep load_module/; +use Test2::Harness2::Util::IPC qw/ipc_default_spawn_args/; use POSIX qw/WNOHANG/; use Atomic::Pipe; @@ -141,9 +142,7 @@ sub init { } } else { - my $share = eval { - File::ShareDir::dist_file('Test2-Harness2', 'other/zstd.dict'); - }; + my $share = eval { File::ShareDir::dist_file('Test2-Harness2', 'other/zstd.dict'); }; $self->{+DICT_PATH} = (defined $share && -f $share) ? $share : undef; } @@ -225,6 +224,15 @@ sub _init_resources { my $has_limiter = grep { $_->is_job_limiter } @{$self->{+RESOURCES}}; push @{$self->{+RESOURCES}} => Test2::Harness2::Resource::JobCount->new(slots => 1) unless $has_limiter; + + for my $res (@{$self->{+RESOURCES}}) { + $res->set_ipcm_info($self->ipcm_info) + if $res->can('set_ipcm_info') && defined $self->ipcm_info; + $res->set_harness_name($self->{+NAME}) + if $res->can('set_harness_name') && defined $self->{+NAME}; + $res->set_logdir($self->{+LOGDIR}) + if $res->can('set_logdir') && defined $self->{+LOGDIR}; + } } sub start { @@ -243,7 +251,7 @@ sub start { # guard never fires in either the service child or the collector parent. my $ipcm_guard; unless ($args{ipcm_info}) { - $ipcm_guard = ipcm_spawn(); + $ipcm_guard = ipcm_spawn(ipc_default_spawn_args()); $args{ipcm_info} = $ipcm_guard->info; } @@ -319,7 +327,9 @@ sub spawn { # Spawn the IPC bus in the parent so both parent and child share the same # connection info. Use guard => 0 so the parent does not try to tear down # the bus when the Spawn object goes out of scope; the child owns it. - my @ipcm_args = (guard => 0); + # Caller-supplied $protocol is appended last so it overrides the default + # protocol from ipc_default_spawn_args(). + my @ipcm_args = (ipc_default_spawn_args(), guard => 0); push @ipcm_args => (protocol => $protocol) if defined $protocol && length $protocol; my $ipcm = ipcm_spawn(@ipcm_args); $args{ipcm_info} = $ipcm->info; @@ -460,6 +470,46 @@ sub request_handler_finish { return {ok => 1}; } +# Idle-check used by the test command (and any caller that wants to +# wait for the harness to drain before requesting termination). +# Returns {ok => 1, idle => 1} when there is no other pending work +# the harness still needs to do for the asking peer: +# +# - the harness's outbox to that peer is empty +# - no runs are active or queued +# - no in-flight subscription deltas remain +# +# The current request itself is NOT counted: the response that goes +# back is queued AFTER this handler returns, so at handler-time the +# outbox does not yet contain it. The caller therefore polls until +# idle == 1, then issues finish/terminate without racing pending +# events. +sub request_handler_has_pending_messages { + my ($self, $payload, $msg) = @_; + + my $peer = $payload->{peer} // ($msg ? $msg->from : undef) + or return {ok => 0, error => "'peer' is required (or supply a from-bearing msg)"}; + + my $client = $self->client; + + # IPC::Manager 0.000034 (cpanfile minimum) provides the full + # Outbox API as no-op fallbacks on every client backend, so + # pending_sends_to is always callable -- non-Outbox clients + # return 0 without walking anything. + my $pending = $client->pending_sends_to($peer); + + my $running = scalar keys %{$self->{+RUN_SERVICES} // {}}; + my $queued = scalar @{$self->{+QUEUE} // []}; + + return { + ok => 1, + idle => ($pending == 0 && $running == 0 && $queued == 0) ? 1 : 0, + pending => $pending, + running => $running, + queued => $queued, + }; +} + # Per-run pass/fail + per-job verdicts. A completed run's final # snapshot is stashed in COMPLETED_RUNS by _handle_run_state_update # at the moment it sees the run close out; this handler serves from @@ -515,6 +565,9 @@ sub run_on_general_message { return $self->_handle_resource_state_message($kind, $content) if defined $kind && $kind =~ m/^resource_(?:paused|resumed|ready|broken|permanent_broken)$/; + return $self->_handle_stage_message($kind, $content) + if defined $kind && $kind =~ m/^stage_(?:up|down)$/; + # Run-scoped collector_artifacts (run_id defined) flow to the run # service, which logs them as job_loggers on the run's own emitter. # Global collector_artifacts (no run_id -- e.g. from a resource- @@ -582,6 +635,21 @@ sub _handle_resource_state_message { return; } +sub _handle_stage_message { + my ($self, $kind, $content) = @_; + + my $stage = ref($content) eq 'HASH' ? $content->{stage} : undef; + return unless defined $stage; + + my ($res) = grep { $_->can('set_stage_up') } @{$self->{+RESOURCES} // []}; + return unless $res; + + $res->set_stage_up($stage) if $kind eq 'stage_up'; + $res->set_stage_down($stage) if $kind eq 'stage_down'; + + return; +} + sub _seed_artifacts_from_loggers { my $self = shift; @@ -673,10 +741,10 @@ sub _write_artifacts_manifest { my $path = "$self->{+LOGDIR}/artifacts.json.zst"; - my $dict = "$self->{+LOGDIR}/zstd-dict.bin"; + my $dict = "$self->{+LOGDIR}/zstd-dict.bin"; my @dict_args = -f $dict ? (dict_path => $dict) : (); - my $ok = eval { + my $ok = eval { write_json_zst_file_atomic($path, $self->{+ARTIFACTS}, @dict_args); 1; }; @@ -749,8 +817,8 @@ sub request_handler_subscribe { my $artifacts = $payload->{artifacts} ? 1 : 0; my @run_ids; - push @run_ids => $payload->{run} if defined $payload->{run}; - push @run_ids => @{$payload->{runs}} if ref($payload->{runs}) eq 'ARRAY'; + push @run_ids => $payload->{run} if defined $payload->{run}; + push @run_ids => @{$payload->{runs}} if ref($payload->{runs}) eq 'ARRAY'; # Validate every run_id up front. The harness knows about runs in # the live queue and in COMPLETED_RUNS (terminal snapshots). @@ -817,10 +885,10 @@ sub _notify_state_subscribers { $self->_send_to_subscriber( $peer => { - type => 'state', - item => 'run', - run_id => $run_id, - state => $run_data, + type => 'state', + item => 'run', + run_id => $run_id, + state => $run_data, }, ); } @@ -902,7 +970,7 @@ sub _send_artifact_snapshot { $artifacts = { map { $_ => $all->{$_} } grep { m{^runs/\Q$run_id\E(?:[./]|\z)} } - keys %$all + keys %$all }; } else { @@ -936,7 +1004,7 @@ sub _send_artifact_snapshot { sub _send_to_subscriber { my ($self, $peer, $payload) = @_; - my $ok = eval { $self->client->send_message($peer, $payload); 1 }; + my $ok = eval { $self->client->send_message($peer, $payload); 1 }; my $err = $@; return if $ok; @@ -973,8 +1041,8 @@ sub _drain_subscriber_retries { my $peer_gone = 0; for my $i (0 .. $#queue) { my $payload = $queue[$i]; - my $ok = eval { $self->client->send_message($peer, $payload); 1 }; - my $err = $@; + my $ok = eval { $self->client->send_message($peer, $payload); 1 }; + my $err = $@; next if $ok; @@ -1077,7 +1145,7 @@ sub _snapshot_run_results { # Entries without completed_at are queue-time or started-time # seeds (jobs that never finished or were skipped). Only # completed jobs contribute to the aggregate verdict. - next unless defined $results->{$jid}{completed_at}; + next unless defined $results->{$jid}{completed_at}; $all_pass = 0 unless $results->{$jid}{pass}; } @@ -1354,9 +1422,9 @@ sub _scheduler_started { sub _scheduler_mark_running { my ($self, $run_id, $job_id) = @_; my $s = $self->{+SCHEDULER}->{$run_id} or return; - $s->{pending} = [grep { $_ ne $job_id } @{$s->{pending}}]; + $s->{pending} = [grep { $_ ne $job_id } @{$s->{pending}}]; $s->{running}->{$job_id} = 1; - $s->{started} = 1; + $s->{started} = 1; return; } @@ -1817,15 +1885,30 @@ sub _launch_job { $res->assign(id => $assign_id, job => $job, env => \%env, %assign_args); } - # Delegate the actual Collector fork to the per-run supervisor so - # the test process runs under the run's subtree. The harness owns - # scheduling (resources assigned above) and the run service owns - # launch + reap + stdio logging. + # Check whether any resource wants to route this job to a preload + # stage rather than the run service. + my $stage_handle; + for my $res (@$resources) { + $stage_handle = $res->stage_handle_for_job($job) and last; + } + + # Delegate the actual Collector fork to either the preload stage + # service (when a stage handle is available) or the per-run + # supervisor. The harness owns scheduling (resources assigned + # above); the run service or stage owns launch + reap. my $launch_ok = eval { - my $handle = $self->_wait_for_run_service_ready($run_id); + my ($handle, $peer); + if ($stage_handle) { + $handle = $stage_handle; + $peer = $stage_handle->service_name; + } + else { + $handle = $self->_wait_for_run_service_ready($run_id); + $peer = "run-$run_id"; + } my $envelope = $handle->sync_request( - "run-$run_id", + $peer, { request => 'launch_job', run_id => $run_id, diff --git a/lib/Test2/Harness2/Collector.pm b/lib/Test2/Harness2/Collector.pm index b195d6581..ad446d0e2 100644 --- a/lib/Test2/Harness2/Collector.pm +++ b/lib/Test2/Harness2/Collector.pm @@ -21,7 +21,7 @@ use Test2::Harness2::Collector::FileLineReader; use Test2::Harness2::Collector::Handle; use Test2::Harness2::Util qw/load_module parse_exit tinysleep/; use Test2::Harness2::Util::JSON qw/encode_json encode_json_file decode_json/; -use Test2::Harness2::Util::IPC qw/pid_is_running set_procname swap_io/; +use Test2::Harness2::Util::IPC qw/pid_is_running set_procname swap_io ipc_default_connect_args atomic_pipe_compression_args apply_atomic_pipe_compression/; # This is the base class. Two subclasses add the test-vs-service # divergent behaviour: Test2::Harness2::Collector::Test carries an @@ -67,6 +67,8 @@ use Object::HashBase qw{ +_win32_job +_start_times + +_child_fork_times + +_child_fork_stamp }; # Default auditor accessors for the base class. Test2::Harness2::Collector::Test @@ -582,6 +584,14 @@ sub collect_from_file { sub _run_collector { my $self = shift; + # Reset CPU-time baseline now that we're in the process that will + # actually run the collection loop. _START_TIMES captured during + # init() was taken in whichever process called new(); the unix + # spawn path forks between init() and here, so the original + # baseline reflects the parent's accumulated times and produces + # negative deltas at finalize. + $self->{+_START_TIMES} = [times()]; + my ($child_pid, $out_r, $err_r, $started_child) = $self->_setup_child_handles(); $self->_set_procname($child_pid); @@ -725,7 +735,20 @@ sub _ipc_client { return $self->{_ipc_client} if $self->{_ipc_client}; require IPC::Manager; - return $self->{_ipc_client} = IPC::Manager->connect($self->bus_id, $self->{+IPCM_INFO}); + # listen=0 (from ipc_default_connect_args): the collector only + # ever sends UPWARD and never receives inbound traffic, so on + # ConnectionUnix it skips the listen socket entirely. Drivers + # that ignore the flag (MessageFiles, AtomicPipe, etc.) treat + # the kwarg as a no-op. + my $c = IPC::Manager->connect($self->bus_id, $self->{+IPCM_INFO}, ipc_default_connect_args()); + + # The collector runs an event loop. Sends never block: queued + # messages are flushed by the loop's per-iteration drain (see + # _run_collection_loop). Clients without Role::Outbox inherit + # the no-op set_send_blocking from IPC::Manager::Client base. + $c->set_send_blocking(0); + + return $self->{_ipc_client} = $c; } # Wait briefly for $target to register on the bus so the first @@ -821,16 +844,20 @@ sub _send_to { my $client = $self->_ipc_client; - # Send with one retry. The MessageFiles protocol's send_message + # Send with one retry. The MessageFiles protocol's try_send_message # croaks "Client does not exist" if the target's peer directory # is missing at send time; that can race with peer registration # at startup or peer teardown at shutdown. Re-check peer_active # once before giving up so the legitimately-late but still-alive # case stops emitting spurious warnings. + # + # try_send_message is non-blocking: queues on EAGAIN. The + # collection loop calls drain_pending each iteration to flush + # the queue when the transport reports writability. for my $attempt (1, 2) { my $ready = $self->_wait_for_ipc_target($target); - my $ok = eval { $client->send_message($target, $content); 1 }; + my $ok = eval { $client->try_send_message($target, $content); 1 }; return if $ok; my $err = $@; @@ -975,7 +1002,37 @@ sub _run_collection_loop { my $draining = 0; # Set when we got a signal/parent-gone and are finishing up while (1) { - $sel->can_read($cycle) if $sel->count; + # Flush any queued outbound IPC sends. The client is in + # send_blocking=0 mode (set by _ipc_client). The kernel may + # have made room in the FIFO since the previous iteration. + # have_pending_sends short-circuits on the first peer with a + # backlog; pending_sends would walk every peer summing queue + # depths, which is wasted work on the hot path. + my $client = $self->{_ipc_client}; + $client->drain_pending if $client && $client->have_pending_sends; + + # Build a write-side select set when the outbox has a + # backlog so the loop wakes the moment room appears, even + # on platforms without large pipe buffers. + my $write_sel; + if ($client && $client->have_writable_handles) { + require IO::Select; + my @wh = $client->writable_handles; + if (@wh) { + $write_sel = IO::Select->new; + $write_sel->add(@wh); + } + } + + if ($sel->count || $write_sel) { + require IO::Select; + IO::Select->select($sel->count ? $sel : undef, $write_sel, undef, $cycle); + } + + # Post-select drain: writable wake-up means the queue can + # advance now. Read-side wake-up may also have made room + # transitively. + $client->drain_pending if $client && $client->have_pending_sends; my $ok = eval { # Check for signal - kill child but keep draining handles @@ -1084,12 +1141,27 @@ sub _finalize_collection { $self->{+CHILD_EXIT} = $child_exit; my $end_times = [times()]; + my $end_stamp = time; my $start_times = $self->{+_START_TIMES}; my @cpu_times = map { $end_times->[$_] - $start_times->[$_] } 0 .. 3; my $px = parse_exit($child_exit); $px->{times} = \@cpu_times; + # Per-job child-process times: baseline taken just before the + # collected child was forked, end taken now (after waitpid), + # so cuser/csys captures the full child tree's CPU and the + # delta wall-clock spans the child's lifetime. Reported + # whether or not this collector is a test-job collector; + # only test-job collectors aggregate it (TestObserver + + # RunService + harness_run_end). + if (my $cf = $self->{+_CHILD_FORK_TIMES}) { + $px->{child_times} = [map { $end_times->[$_] - $cf->[$_] } 0 .. 3]; + } + if (defined(my $cs = $self->{+_CHILD_FORK_STAMP})) { + $px->{child_wall} = $end_stamp - $cs; + } + my $exit_event = Test2::Harness2::Event->new( event_id => gen_uuid(), stamp => time, @@ -1159,8 +1231,11 @@ sub _set_procname { sub _launch_child { my $self = shift; - my ($out_r, $out_w) = Atomic::Pipe->pair(mixed_data_mode => 1); - my ($err_r, $err_w) = Atomic::Pipe->pair(mixed_data_mode => 1); + # zstd compression on both pipes: write_message / write_burst + # frames compress transparently while plain print writes (the + # child's STDOUT/STDERR text stream) pass through uncompressed. + my ($out_r, $out_w) = Atomic::Pipe->pair(mixed_data_mode => 1, atomic_pipe_compression_args()); + my ($err_r, $err_w) = Atomic::Pipe->pair(mixed_data_mode => 1, atomic_pipe_compression_args()); # Save copies of the original STDOUT/STDERR before redirecting; restored # by both platform-specific launchers in the parent path. @@ -1199,6 +1274,13 @@ sub _launch_child_unix { my $cmd = $self->{+LAUNCH}; + # Baseline for the per-job (child-process) times reported on + # harness_process_exit. Captured pre-fork so the delta covers the + # entire watched-child tree (its CPU rolls into our cuser/csys at + # waitpid) plus any collector-loop CPU between fork and reap. + $self->{+_CHILD_FORK_TIMES} = [times()]; + $self->{+_CHILD_FORK_STAMP} = time; + my $pid = fork() // die "Failed to fork child process: $!"; if (!$pid) { @@ -1256,8 +1338,7 @@ sub _check_new_pgroup_supported_on_win32 { # is required for Invariant 1 (child-process isolation). my $has_win32_job = eval { require Win32::Job; 1 }; unless ($has_win32_job) { - croak "new_pgroup => 1 on Windows requires Win32::Job (not installed); " - . "install Win32::Job to enable process-group isolation"; + croak "new_pgroup => 1 on Windows requires Win32::Job (not installed); " . "install Win32::Job to enable process-group isolation"; } } @@ -1284,6 +1365,10 @@ sub _launch_child_win32 { STDOUT->autoflush(1); STDERR->autoflush(1); + # Baseline for child_times/child_wall. See _launch_child_unix. + $self->{+_CHILD_FORK_TIMES} = [times()]; + $self->{+_CHILD_FORK_STAMP} = time; + my $pid; my $ok; { @@ -1329,13 +1414,19 @@ sub _launch_child_win32_job { # local-env by temporarily setting the vars before spawning. local @ENV{keys %env} = values %env; + # Baseline for child_times/child_wall. See _launch_child_unix. + $self->{+_CHILD_FORK_TIMES} = [times()]; + $self->{+_CHILD_FORK_STAMP} = time; + # Pass the pipe write ends as the child's stdout/stderr. # Win32::Job->spawn accepts filehandles for stdin/stdout/stderr and # marks them inheritable before calling CreateProcess. - my $pid = $job->spawn($exe, $cmdline, { - stdout => $out_w->wh, - stderr => $err_w->wh, - }); + my $pid = $job->spawn( + $exe, $cmdline, { + stdout => $out_w->wh, + stderr => $err_w->wh, + } + ); croak "Win32::Job->spawn('$exe') failed: $^E" unless defined $pid && $pid > 0; @@ -1378,9 +1469,14 @@ sub _wrap_handle { return $handle if blessed($handle) && $handle->isa('Atomic::Pipe'); # Pipe or fifo filehandle -- wrap in Atomic::Pipe with mixed_data_mode + # plus the standard zstd-with-dict compression config so framed + # messages from the writer side decode here. from_fh does not + # take extra constructor params, so configure compression + # post-construction via set_compression / set_compression_dictionary_file. if (-p $handle) { my $ap = Atomic::Pipe->from_fh('<&', $handle); $ap->set_mixed_data_mode(); + apply_atomic_pipe_compression($ap); return $ap; } @@ -1404,15 +1500,22 @@ sub _read_handle { my $self = shift; my ($handle) = @_; - # Atomic::Pipe handles -- return [type, data] tuples so the caller can - # distinguish atomic message bursts (JSON events) from plain lines. + # Atomic::Pipe handles -- return [type, data, $compressed_or_undef] + # tuples so the caller can distinguish atomic message bursts + # (JSON events) from plain lines. With keep_compressed enabled, + # message and burst frames also carry the on-wire compressed + # bytes via a "compressed => $raw" pair on the return list; we + # promote that into the tuple so downstream consumers (event + # parser, zstd logger) can reuse the frame without recompressing. if (blessed($handle) && $handle->isa('Atomic::Pipe')) { my @items; while (1) { - my ($type, $data) = $handle->get_line_burst_or_data(); - last unless defined $type; - push @items => [$type, $data]; + my @res = $handle->get_line_burst_or_data(); + last unless defined $res[0]; + my ($type, $data, @rest) = @res; + my %extra = @rest; + push @items => [$type, $data, $extra{compressed}]; } push @items => undef if $handle->eof(); @@ -1429,7 +1532,7 @@ sub _ingest_item { my $self = shift; my ($buffer, $stream, $item, $merge_outputs, $parser) = @_; - my ($type, $data) = @$item; + my ($type, $data, $compressed) = @$item; my $stamp = time; if ($type eq 'message') { @@ -1446,7 +1549,12 @@ sub _ingest_item { return; } - push @{$buffer->{$stream}} => [$stamp, message => $decoded]; + # Stash $compressed alongside the decoded payload so the + # parser can attach it to the resulting Event for the JSONL + # zstd logger's reuse path. Sync markers on STDERR carry a + # compressed form too but the collector never logs them, so + # the bytes are kept for symmetry only. + push @{$buffer->{$stream}} => [$stamp, message => $decoded, $compressed]; my $event_id = ref($decoded) eq 'HASH' ? $decoded->{event_id} : undef; return unless defined $event_id; @@ -1483,16 +1591,17 @@ sub _flush_buffer { for my $stream (qw/stderr stdout/) { my $queue = $buffer->{$stream}; while (my $entry = shift @$queue) { - my ($stamp, $kind, $val) = @$entry; + my ($stamp, $kind, $val, $compressed) = @$entry; if ($kind eq 'message') { if ($stream eq 'stdout') { # A real event arrived via a burst -- feed it through # the parser so the harness facet still gets populated. my $event = $parser->parse_io( - stream => $stream, - event => $val, - stamp => $stamp, + stream => $stream, + event => $val, + stamp => $stamp, + compressed => $compressed, ); $self->_process_event($event) if $event; } @@ -1622,6 +1731,26 @@ sub _exit_mirroring_child { my $self = shift; my ($collector_ok) = @_; + # Drain any queued outbound IPC sends before exit. The collector + # was running in send_blocking=0 mode (set by _ipc_client) so + # events accumulated during the run are still in the client's + # outbox; without this drain they would be dropped when this + # process _exits. Loop until the queue clears or a 5s deadline + # is hit (avoid wedging an exit on a peer that isn't reading). + if (my $client = $self->{_ipc_client}) { + # The Outbox API (have_pending_sends + drain_pending) is + # provided as a no-op fallback by the IPC::Manager::Client + # base class for backends that do not consume Role::Outbox, + # so no can() gate is needed -- non-Outbox clients exit the + # loop after the first iteration when have_pending_sends + # returns 0. + my $deadline = time + 5; + while ($client->have_pending_sends && time < $deadline) { + last unless $client->drain_pending; + tinysleep(0.01) if $client->have_pending_sends; + } + } + POSIX::_exit(255) unless $collector_ok; if (defined(my $child_exit = $self->{+CHILD_EXIT})) { @@ -1716,17 +1845,26 @@ sub interpose { unless Long::Jump::havejump($jump_to); } - ($params{out_r}, $params{out_w}) = Atomic::Pipe->pair(mixed_data_mode => 1); - ($params{err_r}, $params{err_w}) = Atomic::Pipe->pair(mixed_data_mode => 1); + ($params{out_r}, $params{out_w}) = Atomic::Pipe->pair(mixed_data_mode => 1, atomic_pipe_compression_args()); + ($params{err_r}, $params{err_w}) = Atomic::Pipe->pair(mixed_data_mode => 1, atomic_pipe_compression_args()); open($params{orig_stdout}, '>&', \*STDOUT) or croak "Could not clone STDOUT: $!"; open($params{orig_stderr}, '>&', \*STDERR) or croak "Could not clone STDERR: $!"; + # Baseline for child_times/child_wall in the interpose path. The + # parent of this fork becomes the collector; the child continues + # as the watched process. Capture pre-fork so the post-waitpid + # delta covers the child's whole lifetime. + my @child_fork_times = times(); + my $child_fork_stamp = time; + my $pid = fork() // die "Failed to fork for interpose: $!"; # Parent becomes the collector and exits when done -- does not return if ($pid) { - $params{pid} = $pid; + $params{pid} = $pid; + $params{_child_fork_times} = \@child_fork_times; + $params{_child_fork_stamp} = $child_fork_stamp; $class->_interpose_parent(\%params); } diff --git a/lib/Test2/Harness2/Collector/Auditor/Test.pm b/lib/Test2/Harness2/Collector/Auditor/Test.pm index 89d4bc12c..e432a7456 100644 --- a/lib/Test2/Harness2/Collector/Auditor/Test.pm +++ b/lib/Test2/Harness2/Collector/Auditor/Test.pm @@ -193,6 +193,7 @@ sub _audit { my $st = $self->{+SUBTESTS}->{$nested + 1} ||= {}; $st->{event} = $event; $f->{harness_auditor}->{no_render} = 1; + $self->_drop_compressed_cache($event); # Only announce at this auditor's own nesting level -- nested # subtest_start events that slip past the from_tap gate above are @@ -226,6 +227,7 @@ sub _audit { if ($f->{from_tap} && $f->{harness}->{subtest_end} && !($self->{+SUBTESTS} && keys %{$self->{+SUBTESTS}})) { $f->{harness_auditor}->{no_render} = 1; + $self->_drop_compressed_cache($event); my $stamp = $f->{trace}->{stamp} // $f->{stamp} // $f->{harness}->{stamp} // time; @@ -272,6 +274,7 @@ sub _audit { $fd->{parent}->{children} ||= $st->{children}; $fd->{harness}->{closed_by} = $event; $fd->{harness}->{closed_by_eid} = $event->{event_id}; + $self->_drop_compressed_cache($se); my $pn = $n - 1; @@ -305,6 +308,13 @@ sub _subtest_process { my $self = shift; my ($f, $event) = @_; + # _subtest_process mutates $f (== $event->facet_data when an + # event is passed) extensively below: deleting harness.closed_by, + # toggling subtest_closed, pushing errors, etc. Drop the + # collector's cached on-wire compressed frame so a downstream + # zstd-aware logger recompresses against the post-audit body. + $self->_drop_compressed_cache($event) if $event; + my $closer = delete $f->{harness}->{closed_by}; unless ($event) { @@ -403,7 +413,9 @@ sub _subtest_process { exit => $px->{all}, codes => $px, stamp => $event->{stamp} // $f->{harness}->{stamp} // time, - (defined $px->{times} ? (times => $px->{times}) : ()), + (defined $px->{times} ? (times => $px->{times}) : ()), + (defined $px->{child_times} ? (child_times => $px->{child_times}) : ()), + (defined $px->{child_wall} ? (child_wall => $px->{child_wall}) : ()), }; push @{$f->{errors}} => $self->fail_error_facet_list; @@ -487,6 +499,21 @@ sub fail_error_facet_list { return @out; } +# Drop the collector's cached on-wire compressed JSON frame and the +# matching as_json cache from $event. Auditors call this immediately +# before mutating the event body so a downstream zstd-aware logger +# does not write stale bytes that no longer match the post-audit +# event. Tolerates plain hashref events (used by the unit tests) as +# well as blessed Test2::Harness2::Event instances since both are +# hashes underneath. +sub _drop_compressed_cache { + my ($self, $event) = @_; + return unless $event; + delete $event->{compressed_form}; + delete $event->{json}; + return; +} + 1; __END__ diff --git a/lib/Test2/Harness2/Collector/Logger/JSONL.pm b/lib/Test2/Harness2/Collector/Logger/JSONL.pm index d80137b50..cfda52c3c 100644 --- a/lib/Test2/Harness2/Collector/Logger/JSONL.pm +++ b/lib/Test2/Harness2/Collector/Logger/JSONL.pm @@ -54,9 +54,9 @@ sub output_files { # Locate the per-logdir zstd dictionary if one is present. Returns # undef when the run was started dict-less. sub _dict_path { - my $self = shift; + my $self = shift; my $logdir = $self->{+LOGDIR} or return undef; - my $path = "$logdir/zstd-dict.bin"; + my $path = "$logdir/zstd-dict.bin"; return -f $path ? $path : undef; } @@ -104,9 +104,30 @@ sub log_event { # the print operator. Both compress/append-on-print behavior is # identical from the caller's point of view. if (ref($fh) && $fh->isa('Test2::Harness2::Util::Zstd::Writer')) { - $fh->print($event->as_json . "\n"); + # Fast path: the collector caches the on-wire compressed + # JSON frame on the event when it arrived as a JSON burst + # over an Atomic::Pipe configured with the same level + dict + # we use here. The on-wire frame and the JSONL writer's + # frame compress the same plaintext (bare JSON, no trailing + # newline) so the cached bytes can be appended verbatim and + # the extra compress pass is skipped. Auditors that mutate + # the event clear this field, so a present compressed_form + # is authoritative. + # + # Records on disk do not carry an inter-record newline: + # zstd frames self-delimit, and the extract command is + # responsible for inserting exactly one newline between + # records when producing extracted plaintext jsonl. + if (defined(my $frame = $event->compressed_form)) { + $fh->print_raw_frame($frame); + } + else { + $fh->print($event->as_json); + } } else { + # Plain (uncompressed) jsonl: the file format itself relies + # on a newline between records. print $fh $event->as_json, "\n"; } } diff --git a/lib/Test2/Harness2/Collector/Observer/TestObserver.pm b/lib/Test2/Harness2/Collector/Observer/TestObserver.pm index 6c4eb074d..a144acb6d 100644 --- a/lib/Test2/Harness2/Collector/Observer/TestObserver.pm +++ b/lib/Test2/Harness2/Collector/Observer/TestObserver.pm @@ -228,7 +228,9 @@ sub _emit_completed { $payload{fail_count} = $auditor->fail_count if $auditor->can('fail_count'); } - $payload{times} = $exit_facet->{times} if $exit_facet->{times}; + $payload{times} = $exit_facet->{times} if $exit_facet->{times}; + $payload{child_times} = $exit_facet->{child_times} if $exit_facet->{child_times}; + $payload{child_wall} = $exit_facet->{child_wall} if defined $exit_facet->{child_wall}; $self->_send_to_run(\%payload); diff --git a/lib/Test2/Harness2/Collector/Parser/IOParser.pm b/lib/Test2/Harness2/Collector/Parser/IOParser.pm index 2df044d3d..030afd937 100644 --- a/lib/Test2/Harness2/Collector/Parser/IOParser.pm +++ b/lib/Test2/Harness2/Collector/Parser/IOParser.pm @@ -44,6 +44,15 @@ sub parse_io { $self->normalize_event(\%params, $event); + # Stash the on-wire compressed JSON frame the collector captured + # from Atomic::Pipe's keep_compressed read path. The bytes are + # only meaningful for events that came in as a JSON burst and + # whose body has not been mutated since; auditors that modify + # the event clear this field via Event::clear_compressed_form so + # downstream zstd-aware loggers do not write stale frames. + $event->{compressed_form} = $params{compressed} + if defined $params{compressed}; + return $event; } diff --git a/lib/Test2/Harness2/Collector/Preloaded.pm b/lib/Test2/Harness2/Collector/Preloaded.pm new file mode 100644 index 000000000..90c292f28 --- /dev/null +++ b/lib/Test2/Harness2/Collector/Preloaded.pm @@ -0,0 +1,155 @@ +package Test2::Harness2::Collector::Preloaded; +use strict; +use warnings; + +our $VERSION = '2.000011'; + +use Carp qw/croak/; +use POSIX (); + +use Test2::Harness2::Util::IPC qw/swap_io/; + +use parent 'Test2::Harness2::Collector::Test'; + +use Object::HashBase qw{ + {+TEST_FILE}; + + # Trigger the collector's launch path; _launch_child_unix replaces + # exec with goto::file so the forked child runs the test inline. + $self->{+LAUNCH} //= $self->{+TEST_FILE}; + + $self->SUPER::init(); +} + +sub _launch_child_unix { + my $self = shift; + my ($out_r, $out_w, $err_r, $err_w, $orig_stdout, $orig_stderr) = @_; + + my $test_file = $self->{+TEST_FILE}; + my $stage = $self->{+STAGE}; + + my $pid = fork() // die "Failed to fork preloaded test child: $!"; + + if (!$pid) { + # Child process: set up I/O, then run the test via goto::file + $out_r->close(); + $err_r->close(); + + swap_io(\*STDOUT, $out_w->wh); + swap_io(\*STDERR, $err_w->wh); + STDOUT->autoflush(1); + STDERR->autoflush(1); + + close($orig_stdout); + close($orig_stderr); + + POSIX::setpgid(0, 0) or warn "setpgid(0,0) failed: $!" + if $self->{+NEW_PGROUP}; + + my %env = $self->_child_env_overrides; + $ENV{$_} = $env{$_} for keys %env; + $ENV{T2_HARNESS_FORKED} = 1; + $ENV{T2_HARNESS_PRELOAD} = 1; + + $stage->do_post_fork() if $stage; + + if ($INC{'Test2/API.pm'}) { + Test2::API::test2_stop_preload(); + Test2::API::test2_post_preload_reset(); + Test2::API::test2_enable_trace_stamps(); + } + + $0 = $test_file; + + $stage->do_pre_launch() if $stage; + + require goto::file; + goto::file->import($test_file); + + # Execution of the test begins here (goto::file transfers control). + # When the test exits, the process exits. Any code below is unreachable. + POSIX::_exit(255); + } + + # Parent (collector): restore STDOUT/STDERR + open(STDOUT, '>&', $orig_stdout) or croak "Could not restore STDOUT: $!"; + open(STDERR, '>&', $orig_stderr) or croak "Could not restore STDERR: $!"; + + return $pid; +} + +1; + +__END__ + +=pod + +=encoding UTF-8 + +=head1 NAME + +Test2::Harness2::Collector::Preloaded - Collector that runs tests from a +preloaded fork using C. + +=head1 DESCRIPTION + +Extends L for the preload execution path. +Instead of C-ing the test script (which would discard the preloaded +C<%INC> state), this collector forks a child that uses L to swap +in the test script inline. The forked child inherits all preloaded modules +without needing a fresh C. + +=head1 ATTRIBUTES + +=over 4 + +=item test_file (required) + +Absolute path to the test script to execute. + +=item stage (optional) + +A L instance. When set, its +C and C callbacks fire at the appropriate +points in the child process. + +=back + +=head1 SOURCE + +The source code repository for Test2-Harness can be found at +L. + +=head1 MAINTAINERS + +=over 4 + +=item Chad Granum Eexodist@cpan.orgE + +=back + +=head1 AUTHORS + +=over 4 + +=item Chad Granum Eexodist@cpan.orgE + +=back + +=head1 COPYRIGHT + +Copyright Chad Granum Eexodist7@gmail.comE. + +This program is free software; you can redistribute it and/or modify it +under the same terms as Perl itself. + +See L + +=cut diff --git a/lib/Test2/Harness2/Event.pm b/lib/Test2/Harness2/Event.pm index 7c78bd2f6..46b68db9a 100644 --- a/lib/Test2/Harness2/Event.pm +++ b/lib/Test2/Harness2/Event.pm @@ -13,6 +13,7 @@ use Object::HashBase qw{ {+JSON} //= encode_json($_[0]) } +# Drop the cached on-wire compressed bytes (and the cached JSON +# encoding that pairs with them). Auditors and other consumers that +# mutate the event must call this so a downstream zstd-aware logger +# does not write stale compressed bytes that no longer match the +# (mutated) event body. +sub clear_compressed_form { + my $self = shift; + delete $self->{+COMPRESSED_FORM}; + delete $self->{+JSON}; + return; +} + sub TO_JSON { my $out = {%{$_[0]}}; delete $out->{+JSON}; + delete $out->{+COMPRESSED_FORM}; $out->{+FACET_DATA} = {%{$out->{+FACET_DATA}}} if $out->{+FACET_DATA}; diff --git a/lib/Test2/Harness2/Preload.pm b/lib/Test2/Harness2/Preload.pm new file mode 100644 index 000000000..161d0fe74 --- /dev/null +++ b/lib/Test2/Harness2/Preload.pm @@ -0,0 +1,276 @@ +package Test2::Harness2::Preload; +use strict; +use warnings; + +our $VERSION = '2.000011'; + +use Carp qw/croak/; + +use Test2::Harness2::Preload::Stage(); + +sub import { + my $class = shift; + my $caller = caller; + + my $instance = $class->new; + + my %exports; + + $exports{TEST2_HARNESS_PRELOAD} = sub { $instance }; + + $exports{stage} = sub { + my ($name, $code) = @_; + my @caller = caller(); + $instance->build_stage( + name => $name, + code => $code, + caller => \@caller, + ); + }; + + $exports{eager} = sub { + croak "No current stage" unless @{$instance->stack}; + $instance->stack->[-1]->set_eager(1); + }; + + $exports{default} = sub { + croak "No current stage" unless @{$instance->stack}; + $instance->set_default_stage($instance->stack->[-1]->name); + }; + + for my $hook (qw/pre_fork post_fork pre_launch/) { + my $meth = "add_${hook}_callback"; + $exports{$hook} = sub { + croak "No current stage" unless @{$instance->stack}; + $instance->stack->[-1]->$meth(@_); + }; + } + + $exports{watch} = sub { + croak "No current stage" unless @{$instance->stack}; + $instance->stack->[-1]->watch(@_); + }; + + $exports{preload} = sub { + croak "No current stage" unless @{$instance->stack}; + $instance->stack->[-1]->add_to_load_sequence(@_); + }; + + $exports{reload_inplace_check} = sub { + croak "No current stage" unless @{$instance->stack}; + $instance->stack->[-1]->set_reload_inplace_check(@_); + }; + + for my $name (keys %exports) { + no strict 'refs'; + *{"${caller}::${name}"} = $exports{$name}; + } +} + +use Object::HashBase qw{ + {+STAGE_LIST} //= []; + $self->{+STAGE_LOOKUP} //= {}; + $self->{+STACK} //= []; +} + +sub build_stage { + my $self = shift; + my %params = @_; + + my $caller = $params{caller} //= [caller()]; + + die "A coderef is required at $caller->[1] line $caller->[2].\n" + unless $params{code}; + + my $stage = Test2::Harness2::Preload::Stage->new( + stage_lookup => $self->{+STAGE_LOOKUP}, + %params, + ); + + my $stack = $self->{+STACK}; + push @$stack => $stage; + + my $ok = eval { $params{code}->($stage); 1 }; + my $err = $@; + + die "Mangled stack" unless @$stack && $stack->[-1] eq $stage; + pop @$stack; + + die $err unless $ok; + + if (@$stack) { + $stack->[-1]->add_child($stage); + } + else { + $self->add_stage($stage, $caller); + } + + return $stage; +} + +sub add_stage { + my $self = shift; + my ($stage, $caller) = @_; + + $caller //= [caller()]; + + my @all = ($stage, @{$stage->all_children}); + + for my $item (@all) { + my $name = $item->name; + + if (my $existing = $self->{+STAGE_LOOKUP}->{$name}) { + my $ncaller = $item->frame; + my $ecaller = $existing->frame; + die <<" EOT" +A stage named '$name' was already defined. + First at $ecaller->[1] line $ecaller->[2]. + Second at $ncaller->[1] line $ncaller->[2]. + Mixed at $caller->[1] line $caller->[2]. + EOT + } + + $self->{+STAGE_LOOKUP}->{$name} = $item; + } + + push @{$self->{+STAGE_LIST}} => $stage; +} + +sub merge { + my $self = shift; + my ($merge) = @_; + + my $caller = [caller()]; + + $self->add_stage($_, $caller) for @{$merge->{+STAGE_LIST}}; + + $self->{+DEFAULT_STAGE} //= $merge->default_stage; +} + +sub default_stage { + my $self = shift; + return $self->{+DEFAULT_STAGE} if $self->{+DEFAULT_STAGE}; + return $self->{+STAGE_LIST}[0]; +} + +sub set_default_stage { + my $self = shift; + my ($name) = @_; + + croak "Default stage already set to '$self->{+DEFAULT_STAGE}'" + if $self->{+DEFAULT_STAGE}; + + $self->{+DEFAULT_STAGE} = $name; +} + +sub eager_stages { + my $self = shift; + + my %eager; + + for my $root (@{$self->{+STAGE_LIST}}) { + for my $stage ($root, @{$root->all_children}) { + next unless $stage->eager; + $eager{$stage->name} = [map { $_->name } @{$stage->all_children}]; + } + } + + return \%eager; +} + +1; + +__END__ + +=pod + +=encoding UTF-8 + +=head1 NAME + +Test2::Harness2::Preload - DSL for building complex stage-based preload tools. + +=head1 DESCRIPTION + +L allows you to preload libraries for a performance boost. +This module provides tools to go further and build a more complex preload with +multiple I: each stage is its own process, and tests can be routed to a +specific stage. This allows for multiple preload states from which to run tests. + +=head1 SYNOPSIS + + package My::Preload; + use strict; + use warnings; + + use Test2::Harness2::Preload; + + stage Moose => sub { + preload 'Moose', 'Moose::Role'; + + eager(); # run child-stage tests here while child loads + default(); # use this stage when none is specified + + pre_fork sub { ... }; + post_fork sub { ... }; + pre_launch sub { ... }; + + stage Types => sub { + preload 'MooseX::Types'; + }; + }; + +=head1 EXPORTS + +=over 4 + +=item TEST2_HARNESS_PRELOAD() + +Returns the meta-object (instance of this class). Its presence is how +Test2::Harness2 distinguishes a preload library from a plain module. + +=item stage NAME => sub { ... } + +Creates a stage. Stages can be nested. + +=item preload @modules_or_coderefs + +Adds to the stage's load sequence. + +=item eager() + +Marks the active stage as eager. + +=item default() + +Designates the active stage as the default. + +=item pre_fork sub { ... } + +=item post_fork sub { ... } + +=item pre_launch sub { ... } + +Lifecycle callbacks around the test-process fork. + +=item watch $file => sub { ... } + +Register a file to watch for changes (requires a reload handler). + +=back + +=head1 SOURCE + +The source code repository for Test2-Harness can be found at +L. + +=cut diff --git a/lib/Test2/Harness2/Preload/Stage.pm b/lib/Test2/Harness2/Preload/Stage.pm new file mode 100644 index 000000000..6784063d4 --- /dev/null +++ b/lib/Test2/Harness2/Preload/Stage.pm @@ -0,0 +1,136 @@ +package Test2::Harness2::Preload::Stage; +use strict; +use warnings; + +our $VERSION = '2.000011'; + +use Carp qw/croak/; +use Test2::Harness2::Util qw/clean_path/; + +use Object::HashBase qw{ + {+FRAME} //= [caller(1)]; + + croak "'name' is a required attribute" unless $self->{+NAME}; + + croak "Stage name 'base' is reserved, pick another name" + if $self->{+NAME} eq 'base'; + croak "Stage name 'NOPRELOAD' is reserved, pick another name" + if $self->{+NAME} eq 'NOPRELOAD'; + + $self->{+CHILDREN} //= []; + + $self->{+PRE_FORK_CALLBACKS} //= []; + $self->{+POST_FORK_CALLBACKS} //= []; + $self->{+PRE_LAUNCH_CALLBACKS} //= []; + + $self->{+LOAD_SEQUENCE} //= []; + $self->{+WATCHES} //= {}; +} + +sub watch { + my $self = shift; + my ($file, $callback) = @_; + croak "The first argument must be a file" unless $file && -f $file; + croak "The callback argument is required" + unless $callback && ref($callback) eq 'CODE'; + + $file = clean_path($file); + + croak "There is already a watch on file '$file'" + if $self->{+WATCHES}->{$file}; + + $self->{+WATCHES}->{$file} = $callback; +} + +sub all_children { + my $self = shift; + + my @out = @{$self->{+CHILDREN}}; + + for (my $i = 0; $i < @out; $i++) { + push @out => @{$out[$i]->children}; + } + + return \@out; +} + +sub add_child { + my $self = shift; + my ($stage) = @_; + push @{$self->{+CHILDREN}} => $stage; +} + +sub add_pre_fork_callback { + my $self = shift; + my ($cb) = @_; + croak "Callback must be a coderef" unless ref($cb) eq 'CODE'; + push @{$self->{+PRE_FORK_CALLBACKS}} => $cb; +} + +sub add_post_fork_callback { + my $self = shift; + my ($cb) = @_; + croak "Callback must be a coderef" unless ref($cb) eq 'CODE'; + push @{$self->{+POST_FORK_CALLBACKS}} => $cb; +} + +sub add_pre_launch_callback { + my $self = shift; + my ($cb) = @_; + croak "Callback must be a coderef" unless ref($cb) eq 'CODE'; + push @{$self->{+PRE_LAUNCH_CALLBACKS}} => $cb; +} + +sub add_to_load_sequence { + my $self = shift; + + for my $item (@_) { + croak "Item '$item' is not a valid preload, must be a module name (scalar) or a coderef" + unless ref($item) eq 'CODE' || !ref($item); + push @{$self->{+LOAD_SEQUENCE}} => $item; + } +} + +sub do_pre_fork { my $self = shift; $_->(@_) for @{$self->{+PRE_FORK_CALLBACKS}} } +sub do_post_fork { my $self = shift; $_->(@_) for @{$self->{+POST_FORK_CALLBACKS}} } +sub do_pre_launch { my $self = shift; $_->(@_) for @{$self->{+PRE_LAUNCH_CALLBACKS}} } + +1; + +__END__ + +=pod + +=encoding UTF-8 + +=head1 NAME + +Test2::Harness2::Preload::Stage - Abstraction of a preload stage. + +=head1 DESCRIPTION + +Implementation detail of L. You are not intended to +directly use or modify instances of this class. See L +for documentation on writing a custom preload library. + +=head1 SOURCE + +The source code repository for Test2-Harness can be found at +L. + +=cut diff --git a/lib/Test2/Harness2/Resource/PipeLimits.pm b/lib/Test2/Harness2/Resource/PipeLimits.pm new file mode 100644 index 000000000..d2de83b39 --- /dev/null +++ b/lib/Test2/Harness2/Resource/PipeLimits.pm @@ -0,0 +1,219 @@ +package Test2::Harness2::Resource::PipeLimits; +use strict; +use warnings; + +our $VERSION = '2.000011'; + +use Carp qw/croak/; + +use Object::HashBase qw{ + {+HEADROOM} //= 32; + + # Fraction of the soft limit at which the user-facing one-shot + # warning fires. Crosses once: if usage drops back below and + # later climbs back, the warning does NOT re-fire (per spec: + # "warn only once"). + $self->{+WARN_THRESHOLD} //= 0.85; + + $self->{+POLL_INTERVAL} //= 2; + + # Sticky one-shot flag for the warning. Initialised to 0; flipped + # to 1 the first time live usage crosses warn_threshold * limit. + # Survives subsequent samples; the resource never warns again + # for the lifetime of this object. + $self->{+_WARNED} //= 0; +} + +# STUB: throttles new test launches when the user is close to their +# soft pipe-count limit, and emits a one-shot warning so the user +# knows to raise the limit (or kill whatever is hoarding pipes) +# before the throttle bites further runs. +# +# Why a separate resource from UnixLimits: +# Per-process pipe count is not directly an rlimit on most platforms. +# Linux exposes it via /proc/sys/fs/pipe-user-pages-{soft,hard} +# (system-wide, not rlimit-style); macOS / BSD impose it indirectly +# via RLIMIT_NOFILE and per-pipe file-descriptor cost. Tracking pipe +# count specifically -- not just open FDs -- needs its own sampler +# because the harness opens a *lot* of pipes per concurrent test +# (collector stdout/stderr capture, EventEmitter sync, IPC client +# socketpairs, ...) and a pipe-count cap can fire well before +# RLIMIT_NOFILE does. +# +# Scope: +# - Unix-family platforms only (Linux, the BSD variants, macOS, +# Solaris / illumos). Windows is out of scope -- the pipe-allocation +# accounting differs enough that it is a separate problem. +# +# Sampling sources by platform (suggested platform-specific +# subclasses, mirroring the UnixLimits.pm split): +# +# - Linux: +# - Soft cap: read /proc/sys/fs/pipe-user-pages-soft. +# - Live usage: walk /proc//fd looking for "pipe:..." +# symlinks; or aggregate /proc//fdinfo for kind=pipe. +# cgroup v2 io.max may layer on top in modern systemd user +# sessions -- worth respecting when present. +# - BSD (FreeBSD / OpenBSD / NetBSD / DragonFly): +# - Soft cap: sysctl kern.ipc.maxpipekva (system-wide page budget, +# not strictly per-user, but the closest analog). +# - Live usage: kinfo_getfile / libprocstat for per-process pipe +# FDs; then sum across our managed pid tree. +# - macOS (Darwin): proc_pidinfo + PROC_PIDLISTFDS, filter for +# PROX_FDTYPE_PIPE. +# - Solaris / illumos: prctl(2) doesn't expose a pipe rlimit per se; +# fall back to RLIMIT_NOFILE accounting plus /proc//fd kind +# filtering, and treat the soft cap as +# min(RLIMIT_NOFILE, sysconf(_SC_OPEN_MAX)). +# +# Intended implementation shape: +# 1. A `service_pipe_limits_monitor` child runs in the background +# polling every poll_interval seconds for (soft_limit, in_use). +# The monitor IPCs the latest sample to this resource. +# 2. available(%p) computes +# free = soft_limit - in_use - headroom +# and returns 0 when free < the job's pipe-cost estimate +# (typically 4-6 pipes per concurrent test: 2 for the collector +# stdout/stderr capture pair, 1-2 for EventEmitter sync, 1-2 for +# IPC client socketpairs). When 0, log throttling once per +# transition into "throttled" -- not every poll. +# 3. assign / release track per-assignment estimates against +# in_use_in_flight; the next monitor sample is authoritative. +# 4. After each sample the resource checks +# in_use >= warn_threshold * soft_limit +# If that's true and _WARNED is 0, emit a single warning event +# naming the soft limit, current usage, and the suggested action +# (raise pipe-user-pages-soft on Linux, etc.). Set _WARNED = 1 +# so the warning never re-fires for this resource instance. +# The warning goes out via emit_resource_warning (analogous to +# the existing diag/warn pattern in Disk / Memory) so the +# renderer can surface it once at the user. +# 5. teardown() is a no-op beyond stopping the monitor service. +sub available { croak __PACKAGE__ . "::available is not implemented yet" } +sub assign { croak __PACKAGE__ . "::assign is not implemented yet" } +sub release { croak __PACKAGE__ . "::release is not implemented yet" } + +sub status { + my $self = shift; + return { + resource => $self->resource_name, + headroom => $self->{+HEADROOM}, + warn_threshold => $self->{+WARN_THRESHOLD}, + poll_interval => $self->{+POLL_INTERVAL}, + warned => $self->{+_WARNED}, + broken => $self->is_broken, + paused => $self->is_paused, + permanent => $self->is_permanent_broken, + assignments => [], + }; +} + +1; + +__END__ + +=pod + +=encoding UTF-8 + +=head1 NAME + +Test2::Harness2::Resource::PipeLimits - (STUB) Throttle jobs and warn once when close to the user's soft pipe limit. + +=head1 STATUS + +Stub only. Placeholder for a resource that: + +=over 4 + +=item * + +Throttles new job launches (C returns 0) when the user's +soft pipe-count limit minus current usage drops below C. + +=item * + +Emits exactly one user-facing warning the first time usage crosses +C, advising the user to raise the +limit or release pipes before the throttle bites. The warning is +sticky -- it never re-fires for the lifetime of this resource +instance, even if usage briefly drops and climbs again. + +=back + +Intended to support Linux, the BSD variants (FreeBSD / OpenBSD / +NetBSD / DragonFly / macOS), and Solaris / illumos. Each platform's +sampler is a candidate for a dedicated subclass (e.g. +L, +L, +L) that plugs into +this base class's scheduling contract. + +Windows and other non-Unix platforms are explicitly out of scope +for this resource; pipe-allocation accounting on Win32 is +sufficiently different that it deserves its own implementation +rather than a shoehorned subclass here. + +=head1 ATTRIBUTES + +=over 4 + +=item headroom + +Pipes to leave free under the soft limit. C returns 0 +when C. Default: C<32>. + +=item warn_threshold + +Fraction of the soft limit at which the one-shot warning fires. +Default: C<0.85> (warn at 85% usage). + +=item poll_interval + +Seconds between samples of (soft_limit, in_use). Default: C<2>. + +=back + +=head1 SEE ALSO + +L -- sibling stub for +RLIMIT_NPROC / RLIMIT_NOFILE throttling. PipeLimits exists separately +because per-process pipe count is not exposed as an rlimit on most +platforms and needs its own platform-specific sampler. + +=head1 SOURCE + +L + +=head1 AUTHORS + +=over 4 + +=item Chad Granum Eexodist@cpan.orgE + +=back + +=head1 COPYRIGHT + +Copyright Chad Granum Eexodist7@gmail.comE. + +See L + +=cut diff --git a/lib/Test2/Harness2/Resource/Preload.pm b/lib/Test2/Harness2/Resource/Preload.pm new file mode 100644 index 000000000..e624a45f0 --- /dev/null +++ b/lib/Test2/Harness2/Resource/Preload.pm @@ -0,0 +1,302 @@ +package Test2::Harness2::Resource::Preload; +use strict; +use warnings; + +our $VERSION = '2.000011'; + +use Carp qw/croak/; + +use Test2::Harness2::Util qw/mod2file load_module/; + +use Test2::Harness2::Preload(); +use Test2::Harness2::Preload::Stage(); + +use Object::HashBase qw{ + {+PRELOADS} //= []; + $self->{+PRELOAD_EARLY} //= {}; + $self->{+HARNESS_NAME} //= 'harness'; + $self->{+STAGE_STATES} //= {}; + $self->{+JOB_STAGES} //= {}; + $self->{+STAGE_HANDLES} //= {}; + + my $tree = Test2::Harness2::Preload->new; + + for my $mod (@{$self->{+PRELOADS}}) { + my $ok = eval { require(mod2file($mod)); 1 }; + my $err = $@; + unless ($ok) { + warn "Failed to load preload module '$mod': $err"; + next; + } + $tree->merge($mod->TEST2_HARNESS_PRELOAD()) if $mod->can('TEST2_HARNESS_PRELOAD'); + } + + $self->{+STAGE_TREE} = $tree; + + $self->{+STAGE_STATES}{'preload-root'} = 'pending'; + $self->{+STAGE_STATES}{$_} = 'pending' for keys %{$tree->stage_lookup}; +} + +sub is_broken { $_[0]->{+BROKEN} ? 1 : 0 } +sub is_permanent_broken { $_[0]->{+PERMANENT_BROKEN} ? 1 : 0 } +sub is_paused { $_[0]->{+PAUSED} ? 1 : 0 } + +sub mark_broken { $_[0]->{+BROKEN} = 1 } + +sub mark_permanent_broken { + my $self = shift; + $self->{+BROKEN} = 1; + $self->{+PERMANENT_BROKEN} = 1; +} + +sub mark_paused { $_[0]->{+PAUSED} = 1 } + +sub mark_resumed { + my $self = shift; + $self->{+PAUSED} = 0; + $self->{+BROKEN} = 0 unless $self->{+PERMANENT_BROKEN}; +} + +sub set_ipcm_info { + my ($self, $info) = @_; + $self->{+IPCM_INFO} = $info; + $self->{+STAGE_HANDLES} = {}; +} + +sub needed { + my ($self, %p) = @_; + my $job = $p{job} or croak "'job' is required"; + return $job->test_file->check_feature('preload') ? 1 : 0; +} + +sub available { + my ($self, %p) = @_; + my $job = $p{job} or croak "'job' is required"; + + my $stage_name = $self->_stage_for_job($job); + my $state = $self->{+STAGE_STATES}{$stage_name} // 'pending'; + + return 0 unless $state eq 'up'; + return 1; +} + +sub assign { + my ($self, %p) = @_; + + my $id = $p{id} or croak "'id' is required"; + my $job = $p{job} or croak "'job' is required"; + + croak "duplicate assign for id '$id'" if exists $self->{+JOB_STAGES}{$id}; + + $self->{+JOB_STAGES}{$id} = $self->_stage_for_job($job); + return 1; +} + +sub release { + my ($self, %p) = @_; + my $id = $p{id} or croak "'id' is required"; + delete $self->{+JOB_STAGES}{$id}; + return 1; +} + +sub services { + my $self = shift; + + return ( + [ + 'Test2::Harness2::ResourceService::PreloadRoot', + name => 'preload-root', + preloads => $self->{+PRELOADS}, + preload_early => $self->{+PRELOAD_EARLY}, + harness_name => $self->{+HARNESS_NAME}, + (defined $self->{+LOGDIR} ? (logdir => $self->{+LOGDIR}) : ()), + ], + ); +} + +sub set_stage_up { + my ($self, $name) = @_; + $self->{+STAGE_STATES}{$name} = 'up'; + $self->{+BROKEN} = 0 unless $self->{+PERMANENT_BROKEN}; +} + +sub set_stage_down { + my ($self, $name) = @_; + $self->{+STAGE_STATES}{$name} = 'down'; +} + +sub stage_handle_for_job { + my ($self, $job) = @_; + + return undef unless $self->{+IPCM_INFO}; + + my $stage_name = $self->_stage_for_job($job); + return undef unless ($self->{+STAGE_STATES}{$stage_name} // '') eq 'up'; + + return $self->{+STAGE_HANDLES}{$stage_name} //= do { + require IPC::Manager::Service::Handle; + IPC::Manager::Service::Handle->new( + service_name => $stage_name, + ipcm_info => $self->{+IPCM_INFO}, + ); + }; +} + +sub status { + my $self = shift; + + return { + resource => $self->resource_name, + broken => $self->is_broken, + paused => $self->is_paused, + permanent => $self->is_permanent_broken, + stages => {%{$self->{+STAGE_STATES}}}, + }; +} + +sub _stage_for_job { + my ($self, $job) = @_; + + my $tf = $job->test_file; + my $requested = $tf->check_stage if $tf->can('check_stage'); + + if ($requested && exists $self->{+STAGE_STATES}{$requested}) { + return $requested; + } + + my $default = $self->{+STAGE_TREE}->default_stage; + if ($default) { + my $name = ref($default) ? $default->name : $default; + return $name if exists $self->{+STAGE_STATES}{$name}; + } + + return 'preload-root'; +} + +1; + +__END__ + +=pod + +=encoding UTF-8 + +=head1 NAME + +Test2::Harness2::Resource::Preload - Resource that routes tests through +preload stage services. + +=head1 DESCRIPTION + +Implements L for preload support. +Reads the preload DSL from the modules listed in C, builds +an internal stage tree, and declares a +L service. The harness +injects C after construction so the resource can build +L objects for routing C +requests to the appropriate stage service. + +Stage state (C / C / C) is updated by the harness +when it receives C / C service events from the +PreloadRoot service. + +=head1 ATTRIBUTES + +=over 4 + +=item preloads + +Arrayref of module names. Plain modules are loaded by the stage service; +modules that export C contribute stage-tree +definitions that drive per-test routing. + +=item preload_early + +Optional hashref of early-load modules passed through to the root +service (loaded before the preload recipe). + +=back + +=head1 METHODS + +Implements the L interface. See that +role for the contract on C, C, C, C, +and C. + +=over 4 + +=item $resource->set_ipcm_info($info) + +Inject the harness's C so the resource can create service +handles. Invalidates any cached stage handles. + +=item $resource->set_stage_up($name) + +Mark stage C<$name> as ready. Clears transient brokenness on the +resource (but not permanent brokenness). + +=item $resource->set_stage_down($name) + +Mark stage C<$name> as down. + +=item $handle_or_undef = $resource->stage_handle_for_job($job) + +Return an L for the stage that should +run C<$job>, or C if C has not been injected yet or +the target stage is not up. + +=back + +=head1 SOURCE + +The source code repository for Test2-Harness can be found at +L. + +=head1 MAINTAINERS + +=over 4 + +=item Chad Granum Eexodist@cpan.orgE + +=back + +=head1 AUTHORS + +=over 4 + +=item Chad Granum Eexodist@cpan.orgE + +=back + +=head1 COPYRIGHT + +Copyright Chad Granum Eexodist7@gmail.comE. + +This program is free software; you can redistribute it and/or modify it +under the same terms as Perl itself. + +See L + +=cut diff --git a/lib/Test2/Harness2/ResourceService/PreloadRoot.pm b/lib/Test2/Harness2/ResourceService/PreloadRoot.pm new file mode 100644 index 000000000..44674ed82 --- /dev/null +++ b/lib/Test2/Harness2/ResourceService/PreloadRoot.pm @@ -0,0 +1,228 @@ +package Test2::Harness2::ResourceService::PreloadRoot; +use strict; +use warnings; + +our $VERSION = '2.000011'; + +use Carp qw/croak/; +use POSIX (); + +use Test2::Harness2::Util qw/mod2file/; + +use Object::HashBase qw{ + {request}; + $inner = {request => $inner} unless ref($inner) eq 'HASH'; + + my $type = $inner->{request}; + my $handler = "request_handler_$type"; + + return {ok => 0, error => "missing request type"} unless defined $type; + return $self->$handler($inner) if $self->can($handler); + return {ok => 0, error => "unknown request '$type'"}; +} + +sub run_on_start { + my $self = shift; + + $ENV{T2_TRACE_STAMPS} = 1; + + if (eval { require Test2::API; 1 }) { + Test2::API::test2_start_preload(); + Test2::API::test2_enable_trace_stamps(); + } + + for my $mod (@{$self->{+PRELOADS}}) { + my $ok = eval { require(mod2file($mod)); 1 }; + my $err = $@; + warn "PreloadRoot: failed to load '$mod': $err" unless $ok; + } + + $self->_send_to_harness({kind => 'stage_up', stage => $self->{+NAME}, pid => $$}); +} + +sub run_on_cleanup { + my $self = shift; + $self->_send_to_harness({kind => 'stage_down', stage => $self->{+NAME}}); +} + +sub request_handler_launch_job { + my ($self, $payload) = @_; + + for my $req (qw/job_id run_id test_file/) { + return {ok => 0, error => "'$req' is required"} unless defined $payload->{$req}; + } + + my $job_id = $payload->{job_id}; + my $job_try = $payload->{job_try} // 0; + my $run_id = $payload->{run_id}; + my $env = $payload->{env} // {}; + my $auditor = $payload->{auditor}; + my $test_abs = $payload->{test_file}; + + return {ok => 0, error => "'test_file' must be absolute"} + unless $test_abs =~ m{^/}; + + pipe(my $r, my $w) // die "pipe: $!"; + + my $ipid = fork // die "Failed to fork intermediary: $!"; + + if ($ipid) { + close $w; + my $cpid_str = do { local $/; <$r> }; + close $r; + waitpid($ipid, 0); + + return {ok => 0, error => "failed to obtain collector pid from intermediary"} + unless defined $cpid_str && $cpid_str =~ m/^\d+$/; + + return {ok => 1, pid => 0 + $cpid_str}; + } + + # Intermediary child + close $r; + + my $handle; + my $spawn_ok = eval { + require Test2::Harness2::Collector::Preloaded; + $handle = Test2::Harness2::Collector::Preloaded->spawn( + new_pgroup => 1, + parent_pids => [$$], + env_vars => {T2_FORMATTER => 'Stream2', %$env}, + logdir => $self->{+LOGDIR}, + run_id => $run_id, + job_id => $job_id, + job_try => $job_try, + ipcm_info => $self->ipcm_info, + ipc_parent => "run-$run_id", + ipc_run => "run-$run_id", + ipc_harness => $self->{+HARNESS_NAME} // 'harness', + test_file => $test_abs, + (defined $auditor ? (auditor => $auditor) : ()), + ); + 1; + }; + my $spawn_err = $@; + + if ($spawn_ok && $handle) { + print $w $handle->pid; + } + else { + warn "PreloadRoot: collector spawn failed: $spawn_err"; + } + + close $w; + POSIX::_exit($spawn_ok ? 0 : 1); +} + +sub _send_to_harness { + my ($self, $msg) = @_; + + my $ok = eval { + require IPC::Manager::Service::Handle; + my $hname = $self->{+HARNESS_NAME} // 'harness'; + my $handle = IPC::Manager::Service::Handle->new( + service_name => $hname, + ipcm_info => $self->ipcm_info, + ); + $handle->client->send_message($hname, $msg); + 1; + }; + warn "PreloadRoot: could not notify harness: $@" unless $ok; +} + +1; + +__END__ + +=pod + +=encoding UTF-8 + +=head1 NAME + +Test2::Harness2::ResourceService::PreloadRoot - IPC::Manager service that +loads preload modules and launches tests from a forked state. + +=head1 DESCRIPTION + +This is the resource service that backs L. +It runs as a long-lived supervised subprocess managed by the harness. + +On startup (C) it enables the C preload mode, +loads every module listed in C, and sends a C message +to the harness so the preload resource can begin routing jobs here. + +When the harness sends a C request, C +performs the double-fork detachment pattern: + +=over 4 + +=item 1. + +Fork a short-lived B child. + +=item 2. + +The intermediary forks the B (a +L) and exits immediately, detaching +the collector from this service's process tree. + +=item 3. + +The stage reads the collector pid from a pipe and returns it as the +C response. + +=back + +On shutdown (C) it sends a C message to the +harness. + +=head1 SOURCE + +The source code repository for Test2-Harness can be found at +L. + +=head1 MAINTAINERS + +=over 4 + +=item Chad Granum Eexodist@cpan.orgE + +=back + +=head1 AUTHORS + +=over 4 + +=item Chad Granum Eexodist@cpan.orgE + +=back + +=head1 COPYRIGHT + +Copyright Chad Granum Eexodist7@gmail.comE. + +This program is free software; you can redistribute it and/or modify it +under the same terms as Perl itself. + +See L + +=cut diff --git a/lib/Test2/Harness2/Role/Resource.pm b/lib/Test2/Harness2/Role/Resource.pm index 804a84392..b88f72bf6 100644 --- a/lib/Test2/Harness2/Role/Resource.pm +++ b/lib/Test2/Harness2/Role/Resource.pm @@ -74,6 +74,10 @@ sub services { () } # (harness shutdown) or per-run (run completes). Default: no-op. sub teardown { } +# Resources that route test launches to a stage service override this. +# Returns undef (use the run service) or an IPC::Manager::Service::Handle. +sub stage_handle_for_job { undef } + 1; __END__ diff --git a/lib/Test2/Harness2/RunService.pm b/lib/Test2/Harness2/RunService.pm index bbe1f15ab..c1690d8e4 100644 --- a/lib/Test2/Harness2/RunService.pm +++ b/lib/Test2/Harness2/RunService.pm @@ -554,7 +554,9 @@ sub _handle_test_job_completed { $r->{codes} = $content->{codes}; $r->{pass_count} = $content->{pass_count}; $r->{fail_count} = $content->{fail_count}; - $r->{times} = $content->{times} if $content->{times}; + $r->{times} = $content->{times} if $content->{times}; + $r->{child_times} = $content->{child_times} if $content->{child_times}; + $r->{child_wall} = $content->{child_wall} if defined $content->{child_wall}; $r->{stamp} = $completed_at; $r->{completed_at} = $completed_at; diff --git a/lib/Test2/Harness2/Spawn.pm b/lib/Test2/Harness2/Spawn.pm index 0c505dd00..4d6de8b2a 100644 --- a/lib/Test2/Harness2/Spawn.pm +++ b/lib/Test2/Harness2/Spawn.pm @@ -6,6 +6,9 @@ our $VERSION = '2.000011'; use Carp qw/croak/; use POSIX qw/:sys_wait_h/; +use Time::HiRes (); + +use Test2::Harness2::Util::IPC qw/ipc_default_connect_args/; use Object::HashBase qw{ {+NAME} . '-spawn-' . $$; + my $client = IPC::Manager->connect($handle_name, $self->{+IPCM_INFO}, ipc_default_connect_args()); + return IPC::Manager::Service::Handle->new( service_name => $self->{+NAME}, + name => $handle_name, ipcm_info => $self->{+IPCM_INFO}, + client => $client, ); } @@ -88,7 +103,60 @@ sub _send_request_race_safe { die $err; } -sub finish { $_[0]->_send_request_race_safe('finish') } +sub finish { + my $self = shift; + # Drain queued events from the harness's outbox before asking + # it to terminate, so non-blocking sends made during the run + # are not dropped on exit. Cap at 30 s; on timeout we proceed + # because the run is over and any straggler events are not + # worth blocking exit on. Failures during the wait (peer-gone, + # etc.) are tolerated -- finish itself uses the race-safe send. + eval { $self->wait_until_idle(30); 1 } or warn $@; + return $self->_send_request_race_safe('finish'); +} + +# Single non-blocking idle check. Returns the harness's response +# hashref: { ok => 1, idle => 0|1, pending => N, running => N, +# queued => N }. The current request itself is excluded from the +# pending count (the response goes back AFTER the handler returns). +sub has_pending_messages { + my $self = shift; + return $self->_send_request('has_pending_messages'); +} + +# Poll until the harness reports idle for our peer, or $timeout +# seconds elapse. Returns 1 if idle was reached, 0 on timeout. The +# default timeout is 30 seconds; pass 0 for unbounded polling. Each +# poll uses a sync_request whose response itself does not count as +# pending work. +sub wait_until_idle { + my $self = shift; + my ($timeout) = @_; + $timeout //= 30; + + my $deadline; + $deadline = time + $timeout if $timeout; + + while (1) { + my $res; + my $ok = eval { $res = $self->has_pending_messages; 1 }; + # If the peer is gone or the request failed because the + # peer is no longer a valid recipient, treat that as idle: + # there is nothing more for us to wait for. + unless ($ok) { + return 1 if $@ =~ /not a valid message recipient/; + return 1 if $@ =~ $PEER_GONE; + die $@; + } + return 1 if $res && $res->{ok} && $res->{idle}; + + if (defined $deadline) { + return 0 if time >= $deadline; + } + + Time::HiRes::sleep(0.05); + } +} # Ask the harness to forward state and/or artifact updates to this # handle's IPC client. %params are the harness's subscribe payload: @@ -121,7 +189,6 @@ sub run_results { return $self->_send_request('run_results', \%args); } - sub terminate { my $self = shift; my $res; diff --git a/lib/Test2/Harness2/Util.pm b/lib/Test2/Harness2/Util.pm index e96105211..88be08bde 100644 --- a/lib/Test2/Harness2/Util.pm +++ b/lib/Test2/Harness2/Util.pm @@ -29,6 +29,7 @@ our @EXPORT_OK = qw{ open_file parse_exit read_file + render_duration render_status_data tinysleep unlock_file @@ -244,6 +245,7 @@ sub parse_exit { }; } + # Compression readers recognised by open_file(). Only used on read; writes # go through plain open() regardless of extension. my %COMPRESSION = ( diff --git a/lib/Test2/Harness2/Util/EventEmitter.pm b/lib/Test2/Harness2/Util/EventEmitter.pm index e79fc1e4c..71d1336ef 100644 --- a/lib/Test2/Harness2/Util/EventEmitter.pm +++ b/lib/Test2/Harness2/Util/EventEmitter.pm @@ -10,6 +10,7 @@ use Time::HiRes qw/time/; use Atomic::Pipe; use Test2::Util::UUID qw/gen_uuid/; +use Test2::Harness2::Util::IPC qw/apply_atomic_pipe_compression/; use Test2::Harness2::Util::JSON qw/encode_json/; use Object::HashBase qw{ @@ -46,8 +47,15 @@ sub _as_atomic_pipe { return $in if blessed($in) && $in->isa('Atomic::Pipe'); + # Match the collector's reader-side compression config so framed + # write_message / write_burst output decodes on the other end. + # Plain print writes (e.g. the user's STDOUT text) are not + # touched by Atomic::Pipe's compression and reach a non-perl + # downstream untouched. from_fh does not take constructor-time + # compression kwargs, so we configure it post-construction. my $apipe = Atomic::Pipe->from_fh('>&=', $in); $apipe->set_mixed_data_mode(); + apply_atomic_pipe_compression($apipe); return $apipe; } @@ -107,6 +115,14 @@ sub emit_raw { $event->{event_id} = $event_id; $event->{facet_data}{harness}{event_id} = $event_id; + # Atomic::Pipe message frames self-delimit, so no trailing + # newline is needed on the wire to separate records. The JSONL + # zstd logger writes one frame per event in the same shape, and + # the extract command is responsible for inserting exactly one + # newline between records when materializing extracted plaintext + # jsonl. The collector still caches the on-wire compressed + # bytes on the event so the logger can append them verbatim + # without recompressing. my $json = encode_json($event); $self->{+STDOUT_PIPE}->write_message($json); diff --git a/lib/Test2/Harness2/Util/File/JSONL/Zstd.pm b/lib/Test2/Harness2/Util/File/JSONL/Zstd.pm index 91f477b84..ef6a554be 100644 --- a/lib/Test2/Harness2/Util/File/JSONL/Zstd.pm +++ b/lib/Test2/Harness2/Util/File/JSONL/Zstd.pm @@ -47,7 +47,12 @@ sub write { ($self->{+DICT_PATH} ? (dict_path => $self->{+DICT_PATH}) : ()), ); for my $entry (@_) { - $writer->print($self->encode($entry)); + # Bare json -- zstd frames self-delimit, and the inherited + # encode() would append a "\n" that has no separator role + # under the framed format. The extract command is + # responsible for inserting newlines when producing + # extracted plaintext jsonl. + $writer->print(encode_json($entry)); } $writer->close; @@ -102,7 +107,7 @@ sub poll_with_index { $pos = $end; my $decoded; - my $ok = eval { $decoded = $self->decode($line); 1 }; + my $ok = eval { $decoded = $self->decode($line); 1 }; my $err = $@; unless ($ok) { confess "$self->{+NAME} ($start -> $end): $err" @@ -113,7 +118,7 @@ sub poll_with_index { } my $row = [$start, $end, $decoded]; - push @out => $row; + push @out => $row; push @$buffer => $row if $peek; } diff --git a/lib/Test2/Harness2/Util/IPC.pm b/lib/Test2/Harness2/Util/IPC.pm index 865b43378..be39b8c7f 100644 --- a/lib/Test2/Harness2/Util/IPC.pm +++ b/lib/Test2/Harness2/Util/IPC.pm @@ -6,6 +6,7 @@ our $VERSION = '2.000011'; use Carp qw/croak confess/; use Errno qw/ESRCH/; +use File::ShareDir (); # /proc layouts we know how to parse: Linux's multi-line "PPid:" form # and FreeBSD/DragonFlyBSD's single-line positional form. Solaris, @@ -13,8 +14,20 @@ use Errno qw/ESRCH/; # doesn't handle -- on those platforms we fall through to ps even if # /proc happens to be mounted, so we can't misread a binary status # blob as text and return garbage. -use constant HAS_PARSEABLE_PROC => - ($^O eq 'linux' || $^O eq 'freebsd' || $^O eq 'dragonfly'); +use constant HAS_PARSEABLE_PROC => ($^O eq 'linux' || $^O eq 'freebsd' || $^O eq 'dragonfly'); + +# IPC::Manager protocol used as the default for harness IPC. The +# ConnectionUnix driver gives us per-peer SOCK_STREAM connections +# with optional listen sockets, so transient peers (collectors, the +# parent-side spawn handle) can skip listening while services keep +# listen=1 to accept inbound traffic. +use constant IPC_DEFAULT_PROTOCOL => 'IPC::Manager::Client::ConnectionUnix'; + +# Zstd compression level. 3 is Compress::Zstd's library default and +# the same value JSON::Zstd uses when nothing is supplied; we set it +# explicitly so the spec is self-describing and a future tuning +# change is a one-line edit here. +use constant IPC_DEFAULT_ZSTD_LEVEL => 3; use Importer Importer => 'import'; @@ -24,8 +37,107 @@ our @EXPORT_OK = qw{ start_process swap_io list_direct_children + ipc_default_protocol + ipc_default_serializer + ipc_default_spawn_args + ipc_default_connect_args + ipc_zstd_dict_path + atomic_pipe_compression_args + apply_atomic_pipe_compression }; +sub ipc_default_protocol { IPC_DEFAULT_PROTOCOL } + +# Path to the install-shipped zstd compression dictionary +# (share/other/zstd.dict, exposed via File::ShareDir). Returns undef +# when the dictionary is unavailable; the serializer then falls back +# to dictless compression. Both peers must resolve the same path +# with identical content -- File::ShareDir guarantees that for any +# install that ships the share file. +sub ipc_zstd_dict_path { + my $dict = eval { File::ShareDir::dist_file('Test2-Harness2', 'other/zstd.dict') }; + return undef unless defined $dict && -f $dict && -r _; + return $dict; +} + +# Default serializer spec for harness IPC: JSON::Zstd at level 3 +# with the install-shipped dictionary. The ['Class', %args] form +# tells IPC::Manager to construct one configured instance and share +# it across peers (see IPC::Manager::Serializer::JSON::Zstd). +sub ipc_default_serializer { + my $dict = ipc_zstd_dict_path(); + my @args = (level => IPC_DEFAULT_ZSTD_LEVEL); + push @args => (dictionary => $dict) if defined $dict; + return ['JSON::Zstd', @args]; +} + +# Default kwargs for ipcm_spawn(): protocol + serializer. +sub ipc_default_spawn_args { + return ( + protocol => ipc_default_protocol(), + serializer => ipc_default_serializer(), + ); +} + +# Default kwargs for ipcm_connect() from non-services. The +# ConnectionUnix driver builds a listen socket by default so other +# peers can connect back; collectors and the parent-side spawn +# handle never receive inbound traffic and skip the socket. Services +# (Role::Service consumers) keep listen=1 by default so peers can +# reach them. +sub ipc_default_connect_args { + return (listen => 0); +} + +# Default kwargs for Atomic::Pipe constructors (pair, from_fh, +# from_fd, read_fifo, write_fifo). Enables zstd at level 3 with the +# install-shipped dictionary so write_message / write_burst / +# get_line_burst_or_data traffic compresses transparently. Plain +# print writes (and any byte stream from a non-perl downstream that +# inherits the fd) remain uncompressed -- Atomic::Pipe's compression +# only applies to the framed message and burst paths, so a pipe can +# still double as STDOUT/STDERR for an unaware reader. +# +# Both ends of the pipe must be configured identically; mismatched +# dictionaries silently decode to garbage (raw zstd dictionaries do +# not embed a dict-ID). When the install-shipped dictionary is +# unavailable the helper falls back to dictless compression, which +# is still wire-symmetric as long as every endpoint resolves the +# same way. +sub atomic_pipe_compression_args { + my %args = ( + compression => 'zstd', + compression_level => IPC_DEFAULT_ZSTD_LEVEL, + # keep_compressed exposes the on-wire compressed bytes + # alongside the decompressed payload from + # get_line_burst_or_data, so the collector can stash the + # compressed frame on the event and a downstream zstd logger + # can write it verbatim instead of recompressing. + keep_compressed => 1, + ); + + my $dict = ipc_zstd_dict_path(); + $args{compression_dictionary_file} = $dict if defined $dict; + + return %args; +} + +# Configure compression on an existing Atomic::Pipe instance to +# match atomic_pipe_compression_args(). Atomic::Pipe::from_fh and +# from_fd do not accept constructor-time compression kwargs, so +# wrappers (e.g. promoting STDOUT or wrapping a child's pipe handle) +# enable compression after construction. Idempotent. +sub apply_atomic_pipe_compression { + my ($pipe) = @_; + return unless $pipe; + $pipe->set_compression('zstd', IPC_DEFAULT_ZSTD_LEVEL); + if (my $dict = ipc_zstd_dict_path()) { + $pipe->set_compression_dictionary_file($dict); + } + $pipe->set_keep_compressed(1); + return; +} + sub pid_is_running { my ($pid) = @_; diff --git a/lib/Test2/Harness2/Util/Zstd/Reader.pm b/lib/Test2/Harness2/Util/Zstd/Reader.pm index c7aea8e7d..ce9ebcec6 100644 --- a/lib/Test2/Harness2/Util/Zstd/Reader.pm +++ b/lib/Test2/Harness2/Util/Zstd/Reader.pm @@ -7,54 +7,42 @@ our $VERSION = '2.000011'; use Carp qw/croak/; use Compress::Zstd (); -use Compress::Zstd::Decompressor; use Compress::Zstd::DecompressionContext; use Test2::Harness2::Util::Zstd (); # A reader for a multi-frame zstd file produced by # Test2::Harness2::Util::Zstd::Writer (or any other producer that -# emits one self-contained zstd frame per record). +# emits one self-contained zstd frame per record). One frame is one +# record; readline yields the decoded payload of the next frame as +# a "line". # -# Two implementations live behind one interface: -# -# * No-dict path: feed raw bytes through a long-lived -# Compress::Zstd::Decompressor (the streaming binding handles -# concatenated frames natively) and split the decoded output on -# "\n" to yield jsonl lines. -# -# * Dict path: the streaming binding does not accept a dict, so we -# walk the raw bytes one frame at a time. Frame boundaries come -# from Test2::Harness2::Util::Zstd::zstd_frame_size, which parses -# the zstd frame header per RFC 8878. Each isolated frame goes to -# DecompressionContext->decompress_using_dict with a reused -# context and dict instance. +# Both the dict and no-dict paths walk raw bytes one frame at a +# time. Frame boundaries come from +# Test2::Harness2::Util::Zstd::zstd_frame_size, which parses the +# zstd frame header per RFC 8878. The records themselves are not +# required to carry inter-record newlines; producers that omit +# them and consumers that need newline-separated plaintext (the +# extract command, for instance) negotiate that separately. sub _open { my ($class, $path, %opts) = @_; - croak "path is required" unless defined $path; + croak "path is required" unless defined $path; croak "no such file: $path" unless -e $path; open(my $fh, '<', $path) or croak "open '$path': $!"; binmode $fh; my $self = bless { - path => $path, - fh => $fh, - ddict => Test2::Harness2::Util::Zstd::_load_ddict(%opts), - line_buf => '', - raw_buf => '', + path => $path, + fh => $fh, + ddict => Test2::Harness2::Util::Zstd::_load_ddict(%opts), + records => [], + raw_buf => '', + dctx => Compress::Zstd::DecompressionContext->new, } => $class; - if ($self->{ddict}) { - $self->{dctx} = Compress::Zstd::DecompressionContext->new; - } - else { - $self->{decompressor} = Compress::Zstd::Decompressor->new; - $self->{decompressor}->init; - } - return $self; } @@ -80,33 +68,7 @@ sub _read_more { return $n; } -sub _refill_line_buf { - my ($self) = @_; - - if ($self->{ddict}) { - return $self->_refill_with_dict; - } - return $self->_refill_no_dict; -} - -sub _refill_no_dict { - my ($self) = @_; - - my $progress = $self->_read_more; - return 0 unless $progress; - - # Hand the freshly-read raw bytes to the streaming decompressor. - # It returns whatever decoded plaintext is available; partial - # frames are buffered internally so we can call repeatedly. - my $out = $self->{decompressor}->decompress($self->{raw_buf}); - $self->{raw_buf} = ''; - return $progress unless defined $out && length $out; - - $self->{line_buf} .= $out; - return $progress; -} - -sub _refill_with_dict { +sub _refill_records { my ($self) = @_; my $progress = $self->_read_more; @@ -114,19 +76,24 @@ sub _refill_with_dict { my $dctx = $self->{dctx}; my $ddict = $self->{ddict}; - # Walk frames out of raw_buf using zstd_frame_size for exact - # boundaries -- no magic-byte scan. + # Walk complete frames out of raw_buf using zstd_frame_size for + # exact boundaries (no magic-byte scan). Each frame's decoded + # payload becomes one record returned by readline. while (length $self->{raw_buf}) { my $size = Test2::Harness2::Util::Zstd::zstd_frame_size($self->{raw_buf}); last unless defined $size; my $frame = substr($self->{raw_buf}, 0, $size); - my $plain = $dctx->decompress_using_dict($frame, $ddict); - croak "decompress_using_dict failed in '$self->{path}'" + substr($self->{raw_buf}, 0, $size) = ''; + + my $plain = + $ddict + ? $dctx->decompress_using_dict($frame, $ddict) + : Compress::Zstd::decompress($frame); + croak "zstd decompress failed in '$self->{path}'" unless defined $plain; - $self->{line_buf} .= $plain; - substr($self->{raw_buf}, 0, $size) = ''; + push @{$self->{records}} => $plain; } return $progress; @@ -135,29 +102,12 @@ sub _refill_with_dict { sub readline { my ($self) = @_; - while (1) { - my $nl = index($self->{line_buf}, "\n"); - if ($nl >= 0) { - my $line = substr($self->{line_buf}, 0, $nl + 1); - substr($self->{line_buf}, 0, $nl + 1) = ''; - return $line; - } - - # No newline yet; try to pull more bytes off disk. If the - # refill made no progress, we have nothing right now -- yield - # whatever partial line buffer we have (treating it as the - # final unterminated line) or undef. The caller can poll - # again later if a writer is still appending. - my $progress = $self->_refill_line_buf; + while (!@{$self->{records}}) { + my $progress = $self->_refill_records; last unless $progress; } - if (length $self->{line_buf}) { - my $line = $self->{line_buf}; - $self->{line_buf} = ''; - return $line; - } - + return shift @{$self->{records}} if @{$self->{records}}; return undef; } @@ -182,7 +132,7 @@ __END__ =head1 NAME -Test2::Harness2::Util::Zstd::Reader - Line-oriented reader for multi-frame zstd files. +Test2::Harness2::Util::Zstd::Reader - Frame-oriented reader for multi-frame zstd files. =head1 SYNOPSIS @@ -198,12 +148,13 @@ this class is not meant to be instantiated directly. Wraps a multi-frame zstd file (one self-contained frame per record, as produced by L) and yields -decoded lines via L. Without a dict, uses -L's streaming interface (handles -concatenated frames natively). With a dict, walks frames using -C and decompresses -each via L's -C with a reused context. +each frame's decoded payload via L. Frames are +located using C +(RFC 8878 frame-header parser); each frame is decompressed +independently. With a dict, frames go through +L's C +with a reused context; without a dict, through +L. The reader recovers from sticky-EOF state on every refill so writers appending more bytes between reads stay visible to the next readline @@ -213,11 +164,13 @@ call -- usable as a tail-style reader on a live append-safe file. =over 4 -=item $line = $r->readline +=item $record = $r->readline -Returns the next decoded line including its trailing C<"\n">, or -C when no more bytes are available. A partial trailing line -(no terminating newline) is returned once at EOF. +Returns the decoded payload of the next zstd frame (the next +record), or C when no complete frame is available. +Producers control whether records carry trailing newlines: this +reader does not require any inter-record delimiter and does not +add or strip newlines. =item $r->close diff --git a/lib/Test2/Harness2/Util/Zstd/Writer.pm b/lib/Test2/Harness2/Util/Zstd/Writer.pm index e62d39a74..697b73f9f 100644 --- a/lib/Test2/Harness2/Util/Zstd/Writer.pm +++ b/lib/Test2/Harness2/Util/Zstd/Writer.pm @@ -39,13 +39,37 @@ sub _open { } sub print { - my $self = shift; + my $self = shift; my $payload = join '', @_; - my $frame - = $self->{cdict} + my $frame = + $self->{cdict} ? $self->{cctx}->compress_using_dict($payload, $self->{cdict}) : Compress::Zstd::compress($payload, $self->{level}); + return $self->_emit_frame($frame); +} + +sub say { + my $self = shift; + return $self->print(@_, "\n"); +} + +# Append a fully-formed zstd frame to the file without re-compressing. +# Caller is responsible for ensuring the frame was produced with a +# matching level + dictionary so the file's reader can decode it. +# Used by callers that already hold a compressed frame (e.g. the +# collector caches the on-wire frame from Atomic::Pipe and the JSONL +# logger writes it verbatim). +sub print_raw_frame { + my $self = shift; + my ($frame) = @_; + croak "frame is required" unless defined $frame; + return $self->_emit_frame($frame); +} + +sub _emit_frame { + my ($self, $frame) = @_; + my $fh = $self->{fh}; my $len = length $frame; my $sent = syswrite($fh, $frame); @@ -57,11 +81,6 @@ sub print { return 1; } -sub say { - my $self = shift; - return $self->print(@_, "\n"); -} - sub close { my $self = shift; my $fh = delete $self->{fh} or return 1; diff --git a/t/AI/integration/preload_basic.t b/t/AI/integration/preload_basic.t new file mode 100644 index 000000000..820340f3f --- /dev/null +++ b/t/AI/integration/preload_basic.t @@ -0,0 +1,95 @@ +use Test2::V0; +use File::Temp qw/tempdir/; +use Time::HiRes qw/time sleep/; + +use Test2::Util qw/IS_WIN32/; +plan skip_all => 'preload requires Unix (fork + goto::file)' if IS_WIN32; + +use lib 't/lib'; +use Test2::Harness2::TestFile; +use Test2::Harness2::Test::Loggers qw/classic_harness_loggers classic_test_loggers/; +use Test2::Harness2::Test::SpawnRace qw/finish_and_wait/; + +use Test2::Harness2; +use Test2::Harness2::Resource::JobCount; +use Test2::Harness2::Resource::Preload; + +sub wait_until { + my ($check, $timeout_sec) = @_; + my $deadline = time + $timeout_sec; + while (time < $deadline) { + return 1 if $check->(); + sleep(0.05); + } + return 0; +} + +subtest 'test launched via preload stage sees env var and preloaded module' => sub { + my $dir = tempdir(CLEANUP => 1); + + # The test script verifies it is running in preload mode and that the + # preloaded module is visible in %INC (inherited from the stage fork). + my $tf_path = "$dir/preloaded_test.t"; + open my $fh, '>', $tf_path or die "Cannot write test file: $!"; + print $fh "use Test2::V0;\n"; + print $fh "ok(\$ENV{T2_HARNESS_PRELOAD}, 'T2_HARNESS_PRELOAD env set by Collector::Preloaded');\n"; + print $fh "ok(\$INC{'Scalar/Util.pm'}, 'Scalar::Util preloaded into %INC by the stage service');\n"; + print $fh "done_testing;\n"; + close $fh; + + my $preload_res = Test2::Harness2::Resource::Preload->new( + preloads => ['Scalar::Util'], + ); + + my $spawn = Test2::Harness2->spawn( + workdir => $dir, + resources => [ + Test2::Harness2::Resource::JobCount->new(slots => 4), + $preload_res, + ], + loggers => classic_harness_loggers($dir), + test_loggers => classic_test_loggers(), + ); + + my $queued = $spawn->queue_test_run( + files => [Test2::Harness2::TestFile->new(file => $tf_path)], + ); + ok($queued->{ok}, 'run queued') or diag explain $queued; + my $run_id = $queued->{run_id}; + + # Poll until the run reports complete. The preload stage needs to come + # up before the scheduler dispatches the job, which adds a few seconds. + my $service_gone_re = qr/peer .* went away|is not a valid message recipient/; + my $timeout = 60; + my $deadline = time + $timeout; + my $final; + my $service_gone; + while (time < $deadline) { + my $resp = eval { $spawn->run_results(run_id => $run_id) }; + my $err = $@; + if (!defined $resp) { + last unless $err =~ $service_gone_re; + $service_gone = 1; + last; + } + next unless $resp->{ok}; + if (($resp->{state} // '') eq 'complete') { + $final = $resp; + last; + } + sleep(0.1); + } + + ok(defined $final || $service_gone, 'run completed or service exited cleanly'); + ok(!$service_gone, 'service did not disappear unexpectedly') + unless defined $final; + + finish_and_wait($spawn); + + SKIP: { + skip 'no final result (service gone before reporting)', 1 unless $final; + ok($final->{pass}, 'run passed — test assertions verified preload mode'); + } +}; + +done_testing; diff --git a/t/AI/unit/App/Yath2/Renderer/Summary.t b/t/AI/unit/App/Yath2/Renderer/Summary.t index 48f352055..f80ff7f2f 100644 --- a/t/AI/unit/App/Yath2/Renderer/Summary.t +++ b/t/AI/unit/App/Yath2/Renderer/Summary.t @@ -79,10 +79,10 @@ subtest 'render_summary shows timing when present' => sub { }); close STDOUT; open(STDOUT, '>&', \*STDERR); - like($buf, qr/Wall Time.*10\.50 seconds/, 'wall time shown'); + like($buf, qr/Wall Time.*10\.5000s/, 'wall time shown'); like($buf, qr/CPU Time/, 'cpu time shown'); like($buf, qr/CPU Usage.*71%/, 'cpu usage shown'); - like($buf, qr/usr:.*2\.10/, 'user time shown'); + like($buf, qr/usr:.*2\.10000s/, 'user time shown'); }; subtest 'render_summary prints fail result' => sub { diff --git a/t/AI/unit/Collector.t b/t/AI/unit/Collector.t index d807261e6..3086158ba 100644 --- a/t/AI/unit/Collector.t +++ b/t/AI/unit/Collector.t @@ -38,9 +38,16 @@ BEGIN { *IPC::Manager::connect = sub { return bless {}, 'T2H2_TestNoopClient'; }; - *T2H2_TestNoopClient::send_message = sub { return }; - *T2H2_TestNoopClient::peer_active = sub { 1 }; - *T2H2_TestNoopClient::disconnect = sub { return }; + *T2H2_TestNoopClient::send_message = sub { return }; + *T2H2_TestNoopClient::try_send_message = sub { return 1 }; + *T2H2_TestNoopClient::peer_active = sub { 1 }; + *T2H2_TestNoopClient::disconnect = sub { return }; + *T2H2_TestNoopClient::pending_sends = sub { 0 }; + *T2H2_TestNoopClient::have_pending_sends = sub { 0 }; + *T2H2_TestNoopClient::drain_pending = sub { 0 }; + *T2H2_TestNoopClient::have_writable_handles = sub { 0 }; + *T2H2_TestNoopClient::writable_handles = sub { () }; + *T2H2_TestNoopClient::set_send_blocking = sub { return }; } my $IS_WIN32 = $^O eq 'MSWin32'; @@ -1695,6 +1702,8 @@ subtest '_send_logger_metadata groups metadata and registers under the collector push @{$self->{sent}} => {to => $to, payload => $payload}; return; }; + *T2H2_FakeClient_LMeta::try_send_message = sub { my $self = shift; $self->send_message(@_); 1 }; + *T2H2_FakeClient_LMeta::set_send_blocking = sub { return }; # Two JSONL loggers at different paths to show class keys map to arrayrefs. my $jsonl_a = Test2::Harness2::Collector::Logger::JSONL->new( @@ -1757,6 +1766,8 @@ subtest '_send_logger_metadata omits loggers whose metadata is undef' => sub { push @{$self->{sent}} => $payload; return; }; + *T2H2_FakeClient_Omit::try_send_message = sub { my $self = shift; $self->send_message(@_); 1 }; + *T2H2_FakeClient_Omit::set_send_blocking = sub { return }; # JSONL produces metadata; the bare role default is undef. my $jsonl = Test2::Harness2::Collector::Logger::JSONL->new( @@ -1800,6 +1811,8 @@ subtest '_send_logger_metadata still fires when every logger returns undef' => s push @{$self->{sent}} => $payload; return; }; + *T2H2_FakeClient_Empty::try_send_message = sub { my $self = shift; $self->send_message(@_); 1 }; + *T2H2_FakeClient_Empty::set_send_blocking = sub { return }; my $silent = T2H2_SilentLogger->new; @@ -1824,8 +1837,10 @@ subtest '_send_logger_metadata warns on IPC failure, does not propagate' => sub local *IPC::Manager::connect = sub { return bless {}, 'T2H2_FakeClient_Die'; }; - local *T2H2_FakeClient_Die::peer_active = sub { 1 }; - local *T2H2_FakeClient_Die::send_message = sub { die "no route to peer\n" }; + local *T2H2_FakeClient_Die::peer_active = sub { 1 }; + local *T2H2_FakeClient_Die::send_message = sub { die "no route to peer\n" }; + local *T2H2_FakeClient_Die::try_send_message = sub { my $self = shift; $self->send_message(@_); 1 }; + local *T2H2_FakeClient_Die::set_send_blocking = sub { return }; my $jsonl = Test2::Harness2::Collector::Logger::JSONL->new( ipcm_info => {}, output_file => '/tmp/x.jsonl', diff --git a/t/AI/unit/Collector/Logger/JSONL_compressed_form.t b/t/AI/unit/Collector/Logger/JSONL_compressed_form.t new file mode 100644 index 000000000..e5e72fe78 --- /dev/null +++ b/t/AI/unit/Collector/Logger/JSONL_compressed_form.t @@ -0,0 +1,119 @@ +use Test2::V0; + +use Compress::Zstd (); +use File::Temp qw/tempdir/; + +use Test2::Harness2::Event; +use Test2::Harness2::Collector::Logger::JSONL; +use Test2::Harness2::Util::Zstd qw/open_zstd_reader/; + +# Sanity: Event class exposes the new compressed_form slot, holds +# arbitrary bytes verbatim, drops it from the JSON encoding, and +# clear_compressed_form wipes both it and the as_json cache. + +subtest 'Event compressed_form: round-trip' => sub { + my $bytes = "\x00\x01\x02fake-frame"; + my $e = Test2::Harness2::Event->new( + event_id => 'abc', + facet_data => {harness => {kind => 'demo'}}, + compressed_form => $bytes, + ); + is($e->compressed_form, $bytes, 'accessor returns the stored bytes'); + + my $json = $e->as_json; + unlike( + $json, + qr/\Qfake-frame\E/, + 'JSON encoding does not leak compressed_form bytes', + ); + unlike($json, qr/compressed_form/, 'no compressed_form key in JSON'); + + # The cached JSON pairs with the cached frame; clearing the frame + # invalidates the JSON cache so a subsequent mutation gets a + # fresh encoding. + $e->clear_compressed_form; + is($e->compressed_form, undef, 'compressed_form cleared'); + is($e->{json}, undef, 'as_json cache invalidated'); + ok($e->as_json, 'as_json regenerates'); +}; + +# JSONL logger fast-path: if the event has compressed_form bytes the +# logger appends them to the .zst file verbatim instead of running +# the payload through Compress::Zstd a second time. The file must +# still decode back to the same JSON the event would have produced. + +subtest 'JSONL logger writes cached compressed frame verbatim' => sub { + my $dir = tempdir(CLEANUP => 1); + my $path = "$dir/events.jsonl.zst"; + + my $logger = Test2::Harness2::Collector::Logger::JSONL->new( + ipcm_info => 'unused', + output_file => $path, + ); + $logger->startup; + + my $payload = qq[{"event_id":"abc","stamp":1.5,"facet_data":{"harness":{"event_id":"abc"}}}]; + my $frame = Compress::Zstd::compress($payload, 3); + + my $event = Test2::Harness2::Event->new( + event_id => 'abc', + stamp => 1.5, + facet_data => {harness => {event_id => 'abc'}}, + compressed_form => $frame, + ); + + $logger->log_event($event); + $logger->shutdown; + + # Reading back the file decompresses to the same payload. + # Producers may or may not include a trailing newline inside + # the compressed payload; the JSON parser ignores leading and + # trailing whitespace either way, so the test strips it before + # comparing the bytes. + my $reader = open_zstd_reader($path); + my $line = $reader->readline; + s/\A\s+|\s+\z//g for $line; + is( + $line, + '{"event_id":"abc","stamp":1.5,"facet_data":{"harness":{"event_id":"abc"}}}', + 'cached compressed frame round-trips through the file', + ); + + # The on-disk file should be byte-identical to the cached frame + # (no extra recompression or wrapping). + open(my $fh, '<', $path) or die "open $path: $!"; + binmode $fh; + local $/; + my $disk = <$fh>; + close $fh; + is($disk, $frame, 'on-disk bytes equal the cached compressed frame'); +}; + +# Without compressed_form, the logger falls back to compressing the +# event's JSON itself. The output must still decode round-trip. + +subtest 'JSONL logger compresses normally without compressed_form' => sub { + my $dir = tempdir(CLEANUP => 1); + my $path = "$dir/events.jsonl.zst"; + + my $logger = Test2::Harness2::Collector::Logger::JSONL->new( + ipcm_info => 'unused', + output_file => $path, + ); + $logger->startup; + + my $event = Test2::Harness2::Event->new( + event_id => 'def', + stamp => 2.5, + facet_data => {harness => {event_id => 'def'}}, + ); + + $logger->log_event($event); + $logger->shutdown; + + my $reader = open_zstd_reader($path); + my $line = $reader->readline; + like($line, qr/"event_id":"def"/, 'fallback path still writes a decodable frame'); +}; + +done_testing; diff --git a/t/AI/unit/Collector/Parser/IOParser_compressed_form.t b/t/AI/unit/Collector/Parser/IOParser_compressed_form.t new file mode 100644 index 000000000..716da48c2 --- /dev/null +++ b/t/AI/unit/Collector/Parser/IOParser_compressed_form.t @@ -0,0 +1,67 @@ +use Test2::V0; + +use Atomic::Pipe; + +use Test2::Harness2::Collector::Parser::IOParser; +use Test2::Harness2::Util::IPC qw/atomic_pipe_compression_args/; +use Test2::Harness2::Util::JSON qw/encode_json decode_json/; + +# Verify the keep_compressed read path on Atomic::Pipe surfaces the +# on-wire frame, the IOParser accepts a `compressed` named param, and +# the resulting Event carries the bytes verbatim. This matches the +# collector's _read_handle / _ingest_item / _flush_buffer plumbing. + +subtest 'Atomic::Pipe in keep_compressed mode emits compressed bytes' => sub { + my ($r, $w) = Atomic::Pipe->pair(mixed_data_mode => 1, atomic_pipe_compression_args()); + + # No trailing newline -- frames self-delimit on the wire. + my $payload = encode_json({event_id => 'roundtrip', stamp => 0.5}); + $w->write_message($payload); + + my @res = $r->get_line_burst_or_data(); + my ($type, $data, %extra) = @res; + + is($type, 'message', 'message type from compressed burst'); + is($data, $payload, 'decompressed payload matches'); + ok(defined $extra{compressed}, 'compressed bytes are surfaced') + or diag("got tuple: " . join(', ', map { defined $_ ? $_ : 'undef' } @res)); + isnt($extra{compressed}, $data, 'compressed bytes differ from plaintext'); + + my $parser = Test2::Harness2::Collector::Parser::IOParser->new( + ipcm_info => 'unused', + ); + + my $event = $parser->parse_io( + stream => 'stdout', + event => decode_json($data), + stamp => 1.0, + compressed => $extra{compressed}, + ); + + is( + $event->compressed_form, $extra{compressed}, + 'parser stores compressed bytes on the event' + ); +}; + +# A burst with no compressed entry (e.g. came from a non-keep_compressed +# pipe) must not leave a stray compressed_form on the event. + +subtest 'parse_io without compressed leaves event clean' => sub { + my $parser = Test2::Harness2::Collector::Parser::IOParser->new( + ipcm_info => 'unused', + ); + + my $event = $parser->parse_io( + stream => 'stdout', + event => {event_id => 'no-frame'}, + stamp => 1.0, + ); + + is( + $event->compressed_form, undef, + 'compressed_form stays undef when not supplied' + ); +}; + +done_testing; diff --git a/t/AI/unit/Collector/burst_sync.t b/t/AI/unit/Collector/burst_sync.t index 911de19b1..7a03d434b 100644 --- a/t/AI/unit/Collector/burst_sync.t +++ b/t/AI/unit/Collector/burst_sync.t @@ -25,9 +25,16 @@ BEGIN { *IPC::Manager::connect = sub { return bless {}, 'T2H2_BurstSync_NoopClient'; }; - *T2H2_BurstSync_NoopClient::send_message = sub { return }; - *T2H2_BurstSync_NoopClient::peer_active = sub { 1 }; - *T2H2_BurstSync_NoopClient::disconnect = sub { return }; + *T2H2_BurstSync_NoopClient::send_message = sub { return }; + *T2H2_BurstSync_NoopClient::try_send_message = sub { return 1 }; + *T2H2_BurstSync_NoopClient::peer_active = sub { 1 }; + *T2H2_BurstSync_NoopClient::disconnect = sub { return }; + *T2H2_BurstSync_NoopClient::pending_sends = sub { 0 }; + *T2H2_BurstSync_NoopClient::have_pending_sends = sub { 0 }; + *T2H2_BurstSync_NoopClient::drain_pending = sub { 0 }; + *T2H2_BurstSync_NoopClient::have_writable_handles = sub { 0 }; + *T2H2_BurstSync_NoopClient::writable_handles = sub { () }; + *T2H2_BurstSync_NoopClient::set_send_blocking = sub { return }; } my $tmpdir = tempdir(CLEANUP => 1); diff --git a/t/AI/unit/Harness2/Collector/Preloaded.t b/t/AI/unit/Harness2/Collector/Preloaded.t new file mode 100644 index 000000000..70a3eed3e --- /dev/null +++ b/t/AI/unit/Harness2/Collector/Preloaded.t @@ -0,0 +1,22 @@ +use Test2::V0; + +use Test2::Util qw/IS_WIN32/; +plan skip_all => 'Collector::Preloaded requires Unix (fork + goto::file)' if IS_WIN32; + +use Test2::Harness2::Collector::Preloaded; + +subtest 'inherits from Collector::Test' => sub { + ok( + Test2::Harness2::Collector::Preloaded->isa('Test2::Harness2::Collector::Test'), + 'is a Collector::Test', + ); +}; + +subtest 'init croaks without test_file' => sub { + my $ok = eval { Test2::Harness2::Collector::Preloaded->new; 1 }; + my $err = $@; + ok(!$ok, 'dies without test_file'); + like($err, qr/test_file/, 'error mentions test_file'); +}; + +done_testing; diff --git a/t/AI/unit/Harness2/Preload.t b/t/AI/unit/Harness2/Preload.t new file mode 100644 index 000000000..482fee649 --- /dev/null +++ b/t/AI/unit/Harness2/Preload.t @@ -0,0 +1,134 @@ +use Test2::V0; + +use Test2::Harness2::Preload; +use Test2::Harness2::Preload::Stage; + +# Each package installs DSL via 'use' at compile time; the stage() calls +# below run at file-load time (before any subtest). + +{ package My::Preload::One; + use Test2::Harness2::Preload; + stage 'Alpha' => sub { preload 'Scalar::Util'; }; } + +{ package My::Preload::Two; + use Test2::Harness2::Preload; + stage 'Foo' => sub {}; + stage 'Bar' => sub {}; } + +{ package My::Preload::Nested; + use Test2::Harness2::Preload; + stage 'Root' => sub { stage 'Child' => sub {}; }; } + +{ package My::Preload::ExplicitDefault; + use Test2::Harness2::Preload; + stage 'First' => sub {}; + stage 'Second' => sub { default(); }; } + +{ package My::Preload::ImplicitDefault; + use Test2::Harness2::Preload; + stage 'Alpha' => sub {}; + stage 'Beta' => sub {}; } + +{ package My::Preload::Eager; + use Test2::Harness2::Preload; + stage 'EagerStage' => sub { eager(); }; + stage 'QuietStage' => sub {}; } + +{ package My::Preload::DupFirst; + use Test2::Harness2::Preload; + stage 'Dup' => sub {}; } # first registration; second call in subtest should croak + +{ package My::Preload::BadCode; + use Test2::Harness2::Preload; } # no stages yet; used for error-propagation test + +{ package My::Preload::MergeA; + use Test2::Harness2::Preload; + stage 'StageA' => sub {}; } + +{ package My::Preload::MergeB; + use Test2::Harness2::Preload; + stage 'StageB' => sub {}; } + +# --------------------------------------------------------------------------- + +subtest 'TEST2_HARNESS_PRELOAD returns the Preload instance' => sub { + my $p = My::Preload::One::TEST2_HARNESS_PRELOAD(); + isa_ok($p, 'Test2::Harness2::Preload'); +}; + +subtest 'exports land in caller namespace' => sub { + for my $name (qw/ TEST2_HARNESS_PRELOAD stage preload eager default + pre_fork post_fork pre_launch watch reload_inplace_check /) { + ok(My::Preload::One->can($name), "$name exported"); + } +}; + +subtest 'stage_list and stage_lookup reflect top-level stages' => sub { + my $p = My::Preload::Two::TEST2_HARNESS_PRELOAD(); + is(scalar @{$p->stage_list}, 2, 'two top-level stages'); + is($p->stage_list->[0]->name, 'Foo', 'first stage is Foo'); + is($p->stage_list->[1]->name, 'Bar', 'second stage is Bar'); + ok(exists $p->stage_lookup->{Foo}, 'Foo in lookup'); + ok(exists $p->stage_lookup->{Bar}, 'Bar in lookup'); +}; + +subtest 'nested stage goes into lookup but not top-level list' => sub { + my $p = My::Preload::Nested::TEST2_HARNESS_PRELOAD(); + is(scalar @{$p->stage_list}, 1, 'one top-level stage'); + ok(exists $p->stage_lookup->{Root}, 'Root in lookup'); + ok(exists $p->stage_lookup->{Child}, 'Child also in lookup'); +}; + +subtest 'explicit default_stage' => sub { + my $p = My::Preload::ExplicitDefault::TEST2_HARNESS_PRELOAD(); + is($p->default_stage, 'Second', 'explicit default_stage is Second'); +}; + +subtest 'implicit default_stage falls back to first stage' => sub { + my $p = My::Preload::ImplicitDefault::TEST2_HARNESS_PRELOAD(); + my $ds = $p->default_stage; + my $name = ref($ds) ? $ds->name : $ds; + is($name, 'Alpha', 'implicit default is the first stage'); +}; + +subtest 'eager_stages' => sub { + my $p = My::Preload::Eager::TEST2_HARNESS_PRELOAD(); + my $eager = $p->eager_stages; + ok(exists $eager->{EagerStage}, 'eager stage in eager_stages'); + ok(!exists $eager->{QuietStage}, 'non-eager stage absent'); +}; + +subtest 'duplicate stage name croaks' => sub { + my $ok = eval { My::Preload::DupFirst::stage('Dup', sub {}); 1 }; + my $err = $@; + ok(!$ok, 'second registration of Dup dies'); + like($err, qr/already defined/, 'error says "already defined"'); +}; + +subtest 'stage code error propagates' => sub { + my $ok = eval { My::Preload::BadCode::stage('Baddie', sub { die "intentional error\n" }); 1 }; + my $err = $@; + ok(!$ok, 'stage with dying code propagates error'); + like($err, qr/intentional error/, 'correct error text'); +}; + +subtest 'set_default_stage croaks when called a second time' => sub { + my $p = Test2::Harness2::Preload->new; + $p->set_default_stage('First'); + my $ok = eval { $p->set_default_stage('Second'); 1 }; + my $err = $@; + ok(!$ok, 'second call to set_default_stage dies'); + like($err, qr/already set/, 'error says "already set"'); +}; + +subtest 'merge combines two preload trees' => sub { + my $combined = Test2::Harness2::Preload->new; + $combined->merge(My::Preload::MergeA::TEST2_HARNESS_PRELOAD()); + $combined->merge(My::Preload::MergeB::TEST2_HARNESS_PRELOAD()); + + is(scalar @{$combined->stage_list}, 2, 'two stages after merge'); + ok(exists $combined->stage_lookup->{StageA}, 'StageA in merged tree'); + ok(exists $combined->stage_lookup->{StageB}, 'StageB in merged tree'); +}; + +done_testing; diff --git a/t/AI/unit/Harness2/Preload/Stage.t b/t/AI/unit/Harness2/Preload/Stage.t new file mode 100644 index 000000000..b3cdffbf1 --- /dev/null +++ b/t/AI/unit/Harness2/Preload/Stage.t @@ -0,0 +1,118 @@ +use Test2::V0; + +use Test2::Harness2::Preload::Stage; + +subtest 'constructor requires a name' => sub { + my $ok = eval { Test2::Harness2::Preload::Stage->new; 1 }; + my $err = $@; + ok(!$ok, 'dies without name'); + like($err, qr/required/, 'error mentions "required"'); +}; + +subtest 'reserved name "base" is rejected' => sub { + my $ok = eval { Test2::Harness2::Preload::Stage->new(name => 'base'); 1 }; + my $err = $@; + ok(!$ok, 'dies on reserved name "base"'); + like($err, qr/reserved/, 'error mentions "reserved"'); +}; + +subtest 'reserved name "NOPRELOAD" is rejected' => sub { + my $ok = eval { Test2::Harness2::Preload::Stage->new(name => 'NOPRELOAD'); 1 }; + my $err = $@; + ok(!$ok, 'dies on reserved name "NOPRELOAD"'); + like($err, qr/reserved/, 'error mentions "reserved"'); +}; + +subtest 'defaults are sensible' => sub { + my $s = Test2::Harness2::Preload::Stage->new(name => 'Foo'); + is($s->name, 'Foo', 'name set'); + is($s->children, [], 'children empty'); + is($s->load_sequence, [], 'load_sequence empty'); + is($s->pre_fork_callbacks, [], 'pre_fork_callbacks empty'); + is($s->post_fork_callbacks, [], 'post_fork_callbacks empty'); + is($s->pre_launch_callbacks, [], 'pre_launch_callbacks empty'); + is($s->watches, {}, 'watches empty'); + ok(!$s->eager, 'not eager by default'); +}; + +subtest 'add_child and all_children' => sub { + my $root = Test2::Harness2::Preload::Stage->new(name => 'Root'); + my $child = Test2::Harness2::Preload::Stage->new(name => 'Child'); + my $grand = Test2::Harness2::Preload::Stage->new(name => 'Grand'); + + $root->add_child($child); + $child->add_child($grand); + + is(scalar @{$root->children}, 1, 'root has one direct child'); + is($root->children->[0]->name, 'Child', 'direct child is Child'); + + my $all = $root->all_children; + is(scalar @$all, 2, 'all_children returns direct + transitive descendants'); + my %names = map { $_->name => 1 } @$all; + ok($names{Child}, 'Child in all_children'); + ok($names{Grand}, 'Grand in all_children'); +}; + +subtest 'add_to_load_sequence' => sub { + my $s = Test2::Harness2::Preload::Stage->new(name => 'Seq'); + $s->add_to_load_sequence('Scalar::Util', 'List::Util'); + my $cb = sub { 1 }; + $s->add_to_load_sequence($cb); + + is(scalar @{$s->load_sequence}, 3, 'three items in sequence'); + is($s->load_sequence->[0], 'Scalar::Util', 'first item'); + is($s->load_sequence->[1], 'List::Util', 'second item'); + is($s->load_sequence->[2], $cb, 'third is coderef'); +}; + +subtest 'add_to_load_sequence rejects invalid items' => sub { + my $s = Test2::Harness2::Preload::Stage->new(name => 'Bad'); + my $ok = eval { $s->add_to_load_sequence({}); 1 }; + ok(!$ok, 'dies on hashref'); + like($@, qr/not a valid preload/, 'error mentions "not a valid preload"'); +}; + +subtest 'do_pre_fork fires callbacks in order' => sub { + my $s = Test2::Harness2::Preload::Stage->new(name => 'CBOrder'); + my @log; + $s->add_pre_fork_callback(sub { push @log => 'a' }); + $s->add_pre_fork_callback(sub { push @log => 'b' }); + $s->do_pre_fork; + is(\@log, [qw/a b/], 'pre_fork callbacks fired in order'); +}; + +subtest 'do_post_fork fires callbacks in order' => sub { + my $s = Test2::Harness2::Preload::Stage->new(name => 'PostFork'); + my @log; + $s->add_post_fork_callback(sub { push @log => 1 }); + $s->add_post_fork_callback(sub { push @log => 2 }); + $s->do_post_fork; + is(\@log, [1, 2], 'post_fork callbacks fired in order'); +}; + +subtest 'do_pre_launch fires callbacks in order' => sub { + my $s = Test2::Harness2::Preload::Stage->new(name => 'PreLaunch'); + my @log; + $s->add_pre_launch_callback(sub { push @log => 'x' }); + $s->add_pre_launch_callback(sub { push @log => 'y' }); + $s->do_pre_launch; + is(\@log, [qw/x y/], 'pre_launch callbacks fired in order'); +}; + +subtest 'add_*_callback rejects non-coderefs' => sub { + my $s = Test2::Harness2::Preload::Stage->new(name => 'NoRef'); + for my $method (qw/add_pre_fork_callback add_post_fork_callback add_pre_launch_callback/) { + my $ok = eval { $s->$method('not a code'); 1 }; + ok(!$ok, "$method rejects non-coderef"); + like($@, qr/coderef/, 'error mentions "coderef"'); + } +}; + +subtest 'eager flag' => sub { + my $s = Test2::Harness2::Preload::Stage->new(name => 'EagerOne'); + ok(!$s->eager, 'not eager initially'); + $s->set_eager(1); + ok($s->eager, 'eager after set_eager(1)'); +}; + +done_testing; diff --git a/t/AI/unit/Harness2/Resource/Preload.t b/t/AI/unit/Harness2/Resource/Preload.t new file mode 100644 index 000000000..2caecc1ee --- /dev/null +++ b/t/AI/unit/Harness2/Resource/Preload.t @@ -0,0 +1,208 @@ +use Test2::V0; + +# Use the production TestFile from lib/ (not the t/lib stub) so that +# check_feature('preload') returns the %DEFAULTS value of 1 when the +# feature is not explicitly set -- the stub version has no defaults. +use Test2::Harness2::TestFile; +use Test2::Harness2::Run::Job; +use Test2::Harness2::Resource::Preload; + +# Fake preload module with one named stage for routing tests. +{ + package My::Resource::Test::StagePreload; + use Test2::Harness2::Preload; + stage 'Alpha' => sub {}; +} +$INC{'My/Resource/Test/StagePreload.pm'} = 1; + +sub make_job { + my (%tf_attrs) = @_; + my $tf = Test2::Harness2::TestFile->new(file => 't/x.t', %tf_attrs); + return Test2::Harness2::Run::Job->new(test_file => $tf, run_id => 'r'); +} + +subtest 'construction with empty preloads' => sub { + my $r = Test2::Harness2::Resource::Preload->new(preloads => []); + ok($r, 'constructed without error'); + is($r->resource_name, 'preload', 'resource_name is "preload"'); +}; + +subtest 'consumes Role::Resource' => sub { + require Role::Tiny; + my $r = Test2::Harness2::Resource::Preload->new(preloads => []); + ok( + Role::Tiny::does_role($r, 'Test2::Harness2::Role::Resource'), + 'Preload consumes Role::Resource', + ); +}; + +subtest 'is_job_limiter is false' => sub { + my $r = Test2::Harness2::Resource::Preload->new(preloads => []); + ok(!$r->is_job_limiter, 'not a job limiter'); +}; + +subtest 'needed: returns 1 by default, 0 when preload feature disabled' => sub { + my $r = Test2::Harness2::Resource::Preload->new(preloads => []); + + my $j_default = make_job(); + my $j_disabled = make_job(features => {preload => 0}); + + is($r->needed(job => $j_default), 1, 'needed=1 when preload is enabled (default)'); + is($r->needed(job => $j_disabled), 0, 'needed=0 when preload explicitly disabled'); +}; + +subtest 'available: defers while stage is pending, grants when up' => sub { + my $r = Test2::Harness2::Resource::Preload->new(preloads => []); + my $j = make_job(); + + is($r->available(job => $j), 0, 'available=0 while stage is pending'); + + $r->set_stage_up('preload-root'); + is($r->available(job => $j), 1, 'available=1 after stage comes up'); +}; + +subtest 'assign and release bookkeeping' => sub { + my $r = Test2::Harness2::Resource::Preload->new(preloads => []); + $r->set_stage_up('preload-root'); + + my $j = make_job(); + is($r->assign(id => 'x1', job => $j), 1, 'assign returns 1'); + + ok(exists $r->{'job_stages'}{'x1'}, 'job_stages entry created'); + is($r->{'job_stages'}{'x1'}, 'preload-root', 'job mapped to preload-root stage'); + + $r->release(id => 'x1'); + ok(!exists $r->{'job_stages'}{'x1'}, 'job_stages entry removed after release'); +}; + +subtest 'duplicate assign id is rejected' => sub { + my $r = Test2::Harness2::Resource::Preload->new(preloads => []); + $r->set_stage_up('preload-root'); + my $j = make_job(); + $r->assign(id => 'dup', job => $j); + my $ok = eval { $r->assign(id => 'dup', job => $j); 1 }; + ok(!$ok, 'second assign with same id dies'); + like($@, qr/duplicate assign/, 'error mentions "duplicate assign"'); +}; + +subtest 'services() returns single PreloadRoot entry with correct keys' => sub { + my $r = Test2::Harness2::Resource::Preload->new( + preloads => ['Scalar::Util'], + preload_early => {SomeModule => [1]}, + harness_name => 'test-harness', + ); + + my @svc = $r->services; + is(scalar @svc, 1, 'one service entry'); + + my ($class, %args) = @{$svc[0]}; + is($class, 'Test2::Harness2::ResourceService::PreloadRoot', 'correct service class'); + is($args{name}, 'preload-root', 'name is preload-root'); + is($args{harness_name}, 'test-harness', 'harness_name forwarded'); + is($args{preloads}, ['Scalar::Util'], 'preloads forwarded'); + ok(exists $args{preload_early}, 'preload_early forwarded'); +}; + +subtest 'status reports stage states and health flags' => sub { + my $r = Test2::Harness2::Resource::Preload->new(preloads => []); + my $s = $r->status; + is($s->{resource}, 'preload', 'resource key present'); + is($s->{broken}, 0, 'not broken initially'); + is($s->{paused}, 0, 'not paused initially'); + is($s->{permanent}, 0, 'not permanently broken initially'); + ok(exists $s->{stages}{'preload-root'}, 'preload-root in stages'); + is($s->{stages}{'preload-root'}, 'pending', 'initial state is pending'); +}; + +subtest 'set_stage_up / set_stage_down transitions' => sub { + my $r = Test2::Harness2::Resource::Preload->new(preloads => []); + is($r->status->{stages}{'preload-root'}, 'pending', 'starts pending'); + + $r->set_stage_up('preload-root'); + is($r->status->{stages}{'preload-root'}, 'up', 'up after set_stage_up'); + + $r->set_stage_down('preload-root'); + is($r->status->{stages}{'preload-root'}, 'down', 'down after set_stage_down'); +}; + +subtest 'brokenness / paused states' => sub { + my $r = Test2::Harness2::Resource::Preload->new(preloads => []); + ok($r->is_usable, 'usable when healthy'); + + $r->mark_broken; + ok($r->is_broken, 'broken after mark_broken'); + ok(!$r->is_usable, 'not usable when broken'); + + $r->mark_resumed; + ok(!$r->is_broken, 'no longer broken after mark_resumed'); + ok($r->is_usable, 'usable after mark_resumed'); + + $r->mark_paused; + ok($r->is_paused, 'paused after mark_paused'); + ok(!$r->is_usable, 'not usable when paused'); + + $r->mark_resumed; + ok(!$r->is_paused, 'not paused after mark_resumed'); +}; + +subtest 'mark_permanent_broken survives mark_resumed' => sub { + my $r = Test2::Harness2::Resource::Preload->new(preloads => []); + $r->mark_permanent_broken; + ok($r->is_broken, 'broken after mark_permanent_broken'); + ok($r->is_permanent_broken, 'permanent_broken set'); + + $r->mark_resumed; + ok($r->is_permanent_broken, 'permanent_broken survives resume'); + ok($r->is_broken, 'is_broken persists too (set by permanent_broken)'); +}; + +subtest 'stage_handle_for_job returns undef without ipcm_info' => sub { + my $r = Test2::Harness2::Resource::Preload->new(preloads => []); + $r->set_stage_up('preload-root'); + my $j = make_job(); + is($r->stage_handle_for_job($j), undef, 'undef when ipcm_info not set'); +}; + +subtest '_stage_for_job routes to named stage when job check_stage matches' => sub { + my $r = Test2::Harness2::Resource::Preload->new( + preloads => ['My::Resource::Test::StagePreload'], + ); + + # Stage 'Alpha' is in the stage tree; bring it up so available() returns 1. + $r->set_stage_up('Alpha'); + + my $j_alpha = make_job(stage => 'Alpha'); + is($r->available(job => $j_alpha), 1, 'available=1 for job routed to named stage Alpha'); + + $r->assign(id => 's1', job => $j_alpha); + is($r->{'job_stages'}{'s1'}, 'Alpha', 'job assigned to Alpha stage'); +}; + +subtest '_stage_for_job falls back to preload-root when requested stage is unknown' => sub { + my $r = Test2::Harness2::Resource::Preload->new(preloads => []); + $r->set_stage_up('preload-root'); + + # 'Nonexistent' is not in the stage_states; should fall back to preload-root. + my $j = make_job(stage => 'Nonexistent'); + is($r->available(job => $j), 1, 'available=1 after falling back to preload-root'); + + $r->assign(id => 'fb1', job => $j); + is($r->{'job_stages'}{'fb1'}, 'preload-root', 'fallback job assigned to preload-root'); +}; + +subtest '_stage_for_job uses default stage from tree when no stage requested' => sub { + my $r = Test2::Harness2::Resource::Preload->new( + preloads => ['My::Resource::Test::StagePreload'], + ); + + # Alpha is the first (and only) stage, so it becomes the implicit default. + $r->set_stage_up('Alpha'); + + my $j = make_job(); # no stage preference + is($r->available(job => $j), 1, 'available=1 routed to tree default stage Alpha'); + + $r->assign(id => 'def1', job => $j); + is($r->{'job_stages'}{'def1'}, 'Alpha', 'default-routed job assigned to Alpha'); +}; + +done_testing; diff --git a/t/AI/unit/Harness2/Role/Collector/Observer.t b/t/AI/unit/Harness2/Role/Collector/Observer.t index c3ddeec11..c5690dc59 100644 --- a/t/AI/unit/Harness2/Role/Collector/Observer.t +++ b/t/AI/unit/Harness2/Role/Collector/Observer.t @@ -67,9 +67,16 @@ BEGIN { require IPC::Manager; no warnings 'once', 'redefine'; *IPC::Manager::connect = sub { bless {}, 'T2H2_ObsNoopClient' }; - *T2H2_ObsNoopClient::send_message = sub { }; - *T2H2_ObsNoopClient::peer_active = sub { 1 }; - *T2H2_ObsNoopClient::disconnect = sub { }; + *T2H2_ObsNoopClient::send_message = sub { }; + *T2H2_ObsNoopClient::try_send_message = sub { 1 }; + *T2H2_ObsNoopClient::peer_active = sub { 1 }; + *T2H2_ObsNoopClient::disconnect = sub { }; + *T2H2_ObsNoopClient::pending_sends = sub { 0 }; + *T2H2_ObsNoopClient::have_pending_sends = sub { 0 }; + *T2H2_ObsNoopClient::drain_pending = sub { 0 }; + *T2H2_ObsNoopClient::have_writable_handles = sub { 0 }; + *T2H2_ObsNoopClient::writable_handles = sub { () }; + *T2H2_ObsNoopClient::set_send_blocking = sub { return }; } my $CAN_FORK = $Config{d_fork}; diff --git a/t/AI/unit/Harness2/Role/Service.t b/t/AI/unit/Harness2/Role/Service.t index 3735a1d5d..70175ad7b 100644 --- a/t/AI/unit/Harness2/Role/Service.t +++ b/t/AI/unit/Harness2/Role/Service.t @@ -71,6 +71,13 @@ use Test2::Harness2::Role::Service; push @{$self->{+CALLS}} => "on_reaped:$pid"; return; } + + # The role's run_on_start eagerly calls $self->client to register + # on the IPC bus. With no real ipcm_info plumbed in, the default + # implementation tries to read stats files off disk and warns + # "Failed to initialise IPC client in run_on_start: malformed JSON + # ...". Stub it out so unit tests don't depend on a live bus. + sub client { return undef } } subtest 'IPC::Manager::Role::Service contract defaults' => sub { diff --git a/t/AI/unit/Harness2/Spawn/finish_race.t b/t/AI/unit/Harness2/Spawn/finish_race.t index c7e3210dd..8afb45622 100644 --- a/t/AI/unit/Harness2/Spawn/finish_race.t +++ b/t/AI/unit/Harness2/Spawn/finish_race.t @@ -29,6 +29,7 @@ subtest 'finish() returns cleanly on success' => sub { my @called; no warnings 'redefine'; + local *Test2::Harness2::Spawn::wait_until_idle = sub { 1 }; local *Test2::Harness2::Spawn::_send_request = sub { push @called => $_[1]; return {ok => 1}; @@ -46,6 +47,7 @@ subtest 'finish() absorbs peer-gone "went away" error' => sub { my $spawn = make_spawn(); no warnings 'redefine'; + local *Test2::Harness2::Spawn::wait_until_idle = sub { 1 }; local *Test2::Harness2::Spawn::_send_request = sub { die "$PEER_GONE_AWAIT\n" }; my $ok = eval { $spawn->finish; 1 }; @@ -59,6 +61,7 @@ subtest 'finish() absorbs peer-gone "not a valid recipient" error' => sub { my $spawn = make_spawn(); no warnings 'redefine'; + local *Test2::Harness2::Spawn::wait_until_idle = sub { 1 }; local *Test2::Harness2::Spawn::_send_request = sub { die "$PEER_GONE_RECIP\n" }; my $ok = eval { $spawn->finish; 1 }; @@ -72,6 +75,7 @@ subtest 'finish() propagates non-peer-gone errors' => sub { my $spawn = make_spawn(); no warnings 'redefine'; + local *Test2::Harness2::Spawn::wait_until_idle = sub { 1 }; local *Test2::Harness2::Spawn::_send_request = sub { die "$OTHER_ERROR\n" }; my $ok = eval { $spawn->finish; 1 }; diff --git a/t/AI/unit/Harness2/Util/IPC_defaults.t b/t/AI/unit/Harness2/Util/IPC_defaults.t new file mode 100644 index 000000000..78bda7063 --- /dev/null +++ b/t/AI/unit/Harness2/Util/IPC_defaults.t @@ -0,0 +1,108 @@ +use Test2::V0; + +use Test2::Harness2::Util::IPC qw{ + ipc_default_protocol + ipc_default_serializer + ipc_default_spawn_args + ipc_default_connect_args + ipc_zstd_dict_path + atomic_pipe_compression_args + apply_atomic_pipe_compression +}; + +use Atomic::Pipe; + +subtest 'ipc_default_protocol returns ConnectionUnix class' => sub { + is( + ipc_default_protocol(), + 'IPC::Manager::Client::ConnectionUnix', + 'fully qualified ConnectionUnix class name', + ); +}; + +subtest 'ipc_default_serializer is JSON::Zstd at level 3' => sub { + my $spec = ipc_default_serializer(); + is(ref $spec, 'ARRAY', 'arrayref form so IPC::Manager builds an instance'); + + my ($class, %args) = @$spec; + is($class, 'JSON::Zstd', 'JSON::Zstd serializer'); + is($args{level}, 3, 'level 3'); + + if (defined $args{dictionary}) { + ok(-f $args{dictionary}, "dictionary path is a real file ($args{dictionary})"); + ok(-r _, 'dictionary is readable'); + } + else { + # Acceptable when running uninstalled without ShareDir staging. + is(ipc_zstd_dict_path(), undef, 'no dictionary => helper agrees'); + } +}; + +subtest 'ipc_default_spawn_args bundles protocol and serializer' => sub { + my %args = ipc_default_spawn_args(); + is($args{protocol}, ipc_default_protocol(), 'protocol matches helper'); + is( + $args{serializer}, + ipc_default_serializer(), + 'serializer matches helper', + ); +}; + +subtest 'ipc_default_connect_args turns off listen for non-services' => sub { + my %args = ipc_default_connect_args(); + is($args{listen}, 0, 'listen=0 (collectors and spawn handles do not accept inbound)'); +}; + +subtest 'atomic_pipe_compression_args: zstd at level 3' => sub { + my %args = atomic_pipe_compression_args(); + is($args{compression}, 'zstd', 'compression algo'); + is($args{compression_level}, 3, 'level 3'); + + if (defined $args{compression_dictionary_file}) { + ok(-f $args{compression_dictionary_file}, 'dict path is a real file'); + } + else { + is(ipc_zstd_dict_path(), undef, 'no dict file => helper agrees'); + } +}; + +subtest 'pair() with compression args round-trips a message' => sub { + my ($r, $w) = Atomic::Pipe->pair(mixed_data_mode => 1, atomic_pipe_compression_args()); + is($w->compression, 'zstd', 'writer end has compression on'); + is($r->compression, 'zstd', 'reader end has compression on'); + + $w->write_message('{"hello":"world"}'); + my ($type, $data) = $r->get_line_burst_or_data(); + is($type, 'message', 'reader saw a framed message'); + is($data, '{"hello":"world"}', 'payload decoded back to plaintext'); +}; + +subtest 'apply_atomic_pipe_compression: enables zstd post-construct' => sub { + my ($r, $w) = Atomic::Pipe->pair(mixed_data_mode => 1); + is($w->compression, undef, 'pair starts dict-less'); + + apply_atomic_pipe_compression($w); + apply_atomic_pipe_compression($r); + + is($w->compression, 'zstd', 'writer enabled after apply'); + is($w->compression_level, 3, 'level 3 enabled'); + + $w->write_message('{"k":"v"}'); + my ($type, $data) = $r->get_line_burst_or_data(); + is($type, 'message', 'message frame emerged after enabling compression'); + is($data, '{"k":"v"}', 'roundtrip decode matches'); +}; + +subtest 'ipc_zstd_dict_path: undef when share file missing' => sub { + # No way to force the share dir to be missing in-process, so just + # assert the contract: either undef or a readable file path. + my $p = ipc_zstd_dict_path(); + if (defined $p) { + ok(-f $p && -r _, "share-supplied dictionary is readable ($p)"); + } + else { + pass('no installed share dict: helper returned undef'); + } +}; + +done_testing; diff --git a/t/Yath/integration/replay.t b/t/Yath/integration/replay.t index 0865a841b..1888f4ca7 100644 --- a/t/Yath/integration/replay.t +++ b/t/Yath/integration/replay.t @@ -2,13 +2,10 @@ # HARNESS-DURATION-SLOW use Test2::V0; -use File::Temp qw/tempdir/; use File::Spec; use lib 't/lib'; use Test2::Harness2::Test::Yath qw/yath/; -use Test2::Harness2::Util::File::JSONL; -use Test2::Harness2::Util::JSON qw/decode_json/; my $dir = __FILE__; $dir =~ s{\.t$}{}g; @@ -16,12 +13,15 @@ $dir =~ s{^\./}{}; sub clean_output { my $out = shift; + $out->{output} =~ s/^.*duration.*$//m; $out->{output} =~ s/^.*Wrote log file:.*$//m; $out->{output} =~ s/^.*Wrote archive:.*$//m; $out->{output} =~ s/^.*Symlinked to:.*$//m; $out->{output} =~ s/^.*Linked log file:.*$//m; - $out->{output} =~ s/^\s*Wall Time:.*seconds//m; + $out->{output} =~ s/^\s*(?:Run\s+)?Wall Time:.*$//m; + $out->{output} =~ s/^\s*Cumulative Job Time:.*$//m; + $out->{output} =~ s/^\s*Aggregate Job Stats:\s*$//m; $out->{output} =~ s/^\s*CPU Time:.*s\)//m; $out->{output} =~ s/^\s*CPU Usage:.*%//m; $out->{output} =~ s/^\s*-+$//m; @@ -34,10 +34,22 @@ sub clean_output { $out->{output} =~ s/^FIXME: publish should send log to server$//gm; # Normalize display job numbers: parallel jobs complete in non-deterministic - # order so the renderer assigns job 1/2/... differently each run. Replace - # all "job N" sequences with "job N" sentinel so both sides match. + # order so the renderer assigns job 1/2/... differently each run. $out->{output} =~ s/\bjob\s+\d+\b/job N/g; + # Strip absolute-path prefix from any repo-rooted `t/...` + # reference anywhere in the output. The recorded archive + # carries paths from whichever machine generated it (e.g. + # /home/teo/git/Test2-Harness/t/Yath/integration/replay/fail.tx); + # the golden file holds the canonical repo-relative form + # (t/Yath/integration/replay/fail.tx). Catches the diag line + # `( DIAG ) job N at /t/... line N`, the failure + # summary table cells `| /t/... |`, and any other + # context where a future renderer change might surface an + # absolute path -- so the test stays portable across + # machines without case-by-case patching. + $out->{output} =~ s{/(?:[^/\s|]+/)+(t/[\w./-]+)}{$1}g; + my @lines; my $start; for my $line (split /\n/, $out->{output}) { @@ -48,54 +60,50 @@ sub clean_output { push @lines => $line; } - # Sort consecutive PASSED/FAILED job-status lines so that parallel - # completion order does not break the comparison. - my @normalized; - my @status_group; - for my $line (@lines) { - if ($line =~ /^\(\s*(?:PASSED|FAILED)\s*\)/) { - push @status_group => $line; - } - else { - if (@status_group) { - push @normalized => sort @status_group; - @status_group = (); - } - push @normalized => $line; - } - } - push @normalized => sort @status_group if @status_group; - - $out->{output} = join "\n" => @normalized; + $out->{output} = join "\n" => @lines; } -my $out1 = yath( - command => 'test', - args => [$dir, '--ext=tx'], - log => 1, +my $archive = File::Spec->catfile($dir, 'run.yath'); +my $golden = File::Spec->catfile($dir, 'expected_output.txt'); + +open(my $fh, '<', $golden) or die "Cannot read golden file '$golden': $!"; +my $expected = do { local $/; <$fh> }; +close($fh); +chomp $expected; + +yath( + command => 'replay', + args => [$archive], exit => T(), test => sub { my $out = shift; clean_output($out); - - like($out->{output}, qr{FAILED.*fail\.tx}, "'fail.tx' was seen as a failure when reading the log"); - like($out->{output}, qr{PASSED.*pass\.tx}, "'pass.tx' was not seen as a failure when reading the log"); - + is($out->{output}, $expected, "Replay output matches committed golden"); }, ); -my $logfile = $out1->{log}->name; +my $all_events_archive = File::Spec->catfile($dir, 'all_events.yath'); yath( command => 'replay', - args => [$logfile], - exit => $out1->{exit}, - test => sub { - my $out2 = shift; - clean_output($out2); - clean_output($out1); - - is($out2->{output}, $out1->{output}, "Replay has identical output to original"); + args => [$all_events_archive, '-v'], + exit => T(), + test => sub { + my $out = shift; + + my %seen; + for my $line (split /\n/, $out->{output}) { + if ($line =~ /^[\(\[\{<](.*?)[\)\]\}>]/) { + my $tag = $1; + $tag =~ s/^\s+|\s+$//g; + $seen{$tag}++; + } + } + + for my $tag (qw/ PASS FAIL PLAN NOTE DIAG TODO SKIP REASON FAILED /) { + ok($seen{$tag}, "saw event type '$tag'"); + } + ok($seen{'! PASS !'}, "saw amnestied assertion '! PASS !'"); }, ); diff --git a/t/Yath/integration/replay/all_events.tx b/t/Yath/integration/replay/all_events.tx new file mode 100644 index 000000000..0198f965e --- /dev/null +++ b/t/Yath/integration/replay/all_events.tx @@ -0,0 +1,21 @@ +use Test2::V0; + +plan 4; + +note 'this is a note'; +diag 'this is a diagnostic'; + +ok 1, 'passing assertion'; + +todo 'expected failure' => sub { + ok 0, 'todo failure'; +}; + +SKIP: { + skip 'skip reason', 1; + ok 1, 'this is never run'; +} + +ok 0, 'real failure'; + +done_testing; diff --git a/t/Yath/integration/replay/all_events.yath b/t/Yath/integration/replay/all_events.yath new file mode 100644 index 000000000..93f152d7c Binary files /dev/null and b/t/Yath/integration/replay/all_events.yath differ diff --git a/t/Yath/integration/replay/expected_output.txt b/t/Yath/integration/replay/expected_output.txt new file mode 100644 index 000000000..ce6d16898 --- /dev/null +++ b/t/Yath/integration/replay/expected_output.txt @@ -0,0 +1,17 @@ +( FAILED ) job N t/Yath/integration/replay/fail.tx +( PASSED ) job N t/Yath/integration/replay/pass.tx +[ FAIL ] job N + Fail +( DIAG ) job N Failed test 'Fail' +( DIAG ) job N at t/Yath/integration/replay/fail.tx line 3. +< REASON > job N Test script returned error (Err: 1) +< REASON > job N Assertion failures were encountered (Count: 1) +The following jobs failed: ++--------------------------------------+-----------------------------------+ +| Job ID | Test File | ++--------------------------------------+-----------------------------------+ +| 019DC98E-B2C3-7025-A50A-0717DEFCC238 | t/Yath/integration/replay/fail.tx | ++--------------------------------------+-----------------------------------+ +Yath Result Summary +Fail Count: 1 +File Count: 2 +--> Result: FAILED <-- diff --git a/t/Yath/integration/replay/run.yath b/t/Yath/integration/replay/run.yath new file mode 100644 index 000000000..849f83759 Binary files /dev/null and b/t/Yath/integration/replay/run.yath differ