view release on metacpan or search on metacpan
docs/execution-benchmark.md view on Meta::CPAN
# checkpoint åãã®ç¹°ãè¿ãå®è¡
perl util/execution-benchmark-checkpoint.pl --repeat=5 --count=-3
```
åç¬ target ã® profiling / å®è¡ç¢ºèª:
```sh
perl util/profile-execution-target.pl \
--case nested_variable_object \
--target houtou_runtime_cached_perl \
--iterations 300
perl util/profile-execution-target.pl \
--case nested_variable_object \
--target houtou_runtime_native_bundle \
--iterations 300
```
## Compared Targets
ç¾å¨ã® benchmark script ãæ¯è¼ãã target ã¯æ¬¡ã§ãã
- `upstream_string`
- `upstream_ast`
- `houtou_runtime_cached_perl`
- `houtou_runtime_native_bundle`
docs/memory-leak-check.md view on Meta::CPAN
valgrind needed) and is asserted per scenario by
`t/54_frame_leak_regression.t`. The R5 leak hunt (2026-07-18) used
these counters to pinpoint the fast-lane croak path-frame leak and the
abandoned-request reference cycle. The permanent regression coverage is
retained in `t/54_frame_leak_regression.t`.
## Soak test
```sh
perl -Iblib/lib -Iblib/arch util/soak-test.pl \
[--iterations N] [--warmup N] [--max-growth-kb KB] [--scenario name]
```
Simulates a prefork web worker: warmup, snapshot RSS, run N mixed
requests, assert RSS growth stays under the gate. Scenarios:
- `varying_variables` â fresh variables every request
- `program_cache_eviction` â distinct query strings beyond the cache max
- `specialized_directives` â runtime directives with varying variables
- `resolver_error` â resolver die captured into the errors envelope
- `escaped_die` â coercion die propagating out of execute (croak path)
- `async_promise` â Promise::XS-backed resolvers
- `persisted_bundle` â precompiled native bundle execution
## Known per-scenario growth (2026-07-19, 4000 iterations after warmup)
| scenario | growth | status |
|---|---|---|
| varying_variables | +16 KB | clean |
| specialized_directives | +32 KB | clean |
| persisted_bundle | +16 KB | clean |
| escaped_die | +0 KB | fixed in the Phase B batch (was +5472 KB) |
| resolver_error | +16 KB | fixed (was +496 KB, ~125 B/req, on 2026-07-05) |
| async_promise | +16 KB | fixed (was +1696 KB, ~425 B/req, on 2026-07-05) |
| program_cache_eviction | +0 KB | fixed (was +432 KB, ~110 B/req, on 2026-07-05) |
docs/memory-leak-check.md view on Meta::CPAN
Two cross-cutting leaks were found and fixed while attributing the table
above (both pre-existing on main):
- the parser leaked one empty location hash per parse
(`gql_make_current_location` abandoned a fresh HV on its EOF fallthrough)
- `cursor_restore_copy` zeroed the live cursor's refcount at every block
exit (it delegated to `snapshot_copy`, whose `Zero(dst)` wiped it); the
next unsigned decrement underflowed and the 48-byte cursor struct leaked
on every exec-state request across all scenarios
The CI gate (`--max-growth-kb 2048` over 20k mixed iterations) is
calibrated against the clean baseline: the full mixed run measures
+488 KB on the Linux CI runner and +448 KB on the macOS development
host (2026-07-19), so 2048 KB leaves >4x headroom for platform noise
while still catching a reintroduced per-request leak of ~80 B/req.
## Local harness cases
```sh
perl util/leak-check.pl # all cases
perl util/leak-check.pl --case promise # focused
t/54_frame_leak_regression.t view on Meta::CPAN
my $r = $pool_schema->execute(
'query Q($ids: [String], $p: String) { items(ids: $ids) { label(prefix: $p) } }',
variables => { ids => [ map { "id$_" } 1 .. 10 ], p => $prefix },
);
if ($prefix eq 'boom') {
ok $r->{errors} && @{ $r->{errors} } == 10, "iteration $i: every item errors" or last;
} else {
is scalar(@{ $r->{data}{items} }), 10, "iteration $i: every item resolved" or last;
}
}
assert_no_live_frames('200 args-bearing resolver iterations (LazyInfo pool)');
};
subtest 'cancelling a request with a suspended list-item child block stays clean' => sub {
# Phase 11: a list item's own child block now suspends via a raw
# block_frame_t linked directly into list_pending (gql_runtime_vm_
# list_pending_link_child_frame), not a Promise::XS. Abandoning the
# request while that item is still genuinely pending - on_stall dies,
# or a stall is detected - must reach and release that linked child
# frame via gql_runtime_vm_cancel_frame_tree's new LIST_PENDING_PTR
# recursion, or it (and the list_pending itself) leaks.
t/59_on_stall_native_drive.t view on Meta::CPAN
'query q($tk: String, $ak: String) { post { title(key: $tk) author { name(key: $ak) } } }',
variables => { tk => 't1', ak => 'a1' },
context => { titles => $titles, authors => $authors },
on_stall => GraphQL::Houtou::DataLoader->on_stall_for($titles, $authors),
);
is_deeply $r, { data => { post => { title => 'title:t1', author => { name => 'author:a1' } } } },
'both loaders settle across on_stall_for rounds';
assert_no_live_frames('after multi-loader request');
};
subtest 'repeated nested-suspend requests stay clean over many iterations' => sub {
for my $i (1 .. 200) {
my $loader = GraphQL::Houtou::DataLoader->new(
batch => sub { my ($ids) = @_; return [ map { "v:$_" } @$ids ] },
);
my $r = $runtime->execute_document(
$QUERY,
variables => { id => "u$i", k => "k$i" },
context => { loader => $loader },
on_stall => GraphQL::Houtou::DataLoader->on_stall_for($loader),
);
is_deeply $r, {
data => { user => { name => "n:u$i", team => { name => "v:k$i" } } },
}, "iteration $i resolved" or last;
}
assert_no_live_frames('200 native-drive iterations');
};
# Item 1 (Phase 9): shapes the fast lane does not cover (a runtime directive
# present, or the nesting-depth guard exceeded) fall back to the generic
# executor, which still builds its own Promise::XS internally - but that
# promise is now driven from C too (gql_runtime_vm_drive_promise_with_on_stall_sv),
# not handed back to Perl's _settle_result. These pin the same settle/
# reject/stall/cleanup contract for that fallback path that the subtests
# above already pin for the fast lane.
subtest 'a runtime-directive sibling forces the generic-executor fallback, still driven natively' => sub {
util/generate-nytprof-snapshot.pl view on Meta::CPAN
use 5.014;
use strict;
use warnings;
use Cwd qw(abs_path getcwd);
use File::Path qw(make_path);
use File::Spec;
use Getopt::Long qw(GetOptions);
use POSIX qw(strftime);
my $iterations = 200;
my $outdir;
GetOptions(
'iterations=i' => \$iterations,
'outdir=s' => \$outdir,
) or die "Usage: $0 [--iterations N] [--outdir DIR]\n";
my $root = abs_path(getcwd());
$outdir ||= File::Spec->catdir($root, 'profile', 'nytprof', strftime('%Y%m%d-%H%M%S', localtime));
make_path($outdir);
my @cases = (
[qw(simple_scalar upstream_ast upstream_string houtou_facade_ast houtou_facade_string houtou_prepared_ir houtou_compiled_ir houtou_xs_ast houtou_xs_string)],
[qw(nested_variable_object upstream_ast upstream_string houtou_facade_ast houtou_facade_string houtou_prepared_ir houtou_compiled_ir houtou_xs_ast houtou_xs_string)],
[qw(list_of_objects upstream_ast upstream_string houtou_facade_ast houtou_facade_string houtou_prepared_ir houtou_compiled_ir houtou_xs_ast houtou_xs_string)],
[qw(abstract_with_fragment upstream_ast upstream_string houtou_facade_ast houtou_facade_string houtou_prepared_ir houtou_compiled_ir houtou_xs_ast houtou_xs_string)],
util/generate-nytprof-snapshot.pl view on Meta::CPAN
run(
{
PATH => File::Spec->catdir($root, 'local', 'bin') . ':' . ($ENV{PATH} // ''),
PERL5LIB => File::Spec->catdir($root, 'local', 'lib', 'perl5')
. (($ENV{PERL5LIB} && length $ENV{PERL5LIB}) ? ':' . $ENV{PERL5LIB} : ''),
NYTPROF => "file=$raw_file:start=begin",
},
[ 'perl', '-d:NYTProf', 'util/profile-execution-target.pl',
'--case', $case_name,
'--target', $target_name,
'--iterations', $iterations,
],
$profile_stdout,
$profile_stderr,
);
run(
{
PATH => File::Spec->catdir($root, 'local', 'bin') . ':' . ($ENV{PATH} // ''),
PERL5LIB => File::Spec->catdir($root, 'local', 'lib', 'perl5')
. (($ENV{PERL5LIB} && length $ENV{PERL5LIB}) ? ':' . $ENV{PERL5LIB} : ''),
util/generate-nytprof-snapshot.pl view on Meta::CPAN
$html_stderr,
);
push @generated, [ $case_name, $target_name ];
}
}
my $readme = File::Spec->catfile($outdir, 'README.md');
open my $fh, '>', $readme or die "open $readme: $!";
print {$fh} "# NYTProf Snapshot\n\n";
print {$fh} "Generated with `util/generate-nytprof-snapshot.pl --iterations $iterations`.\n\n";
for my $entry (@generated) {
my ($case_name, $target_name) = @$entry;
print {$fh} "- `$case_name / $target_name`: `$case_name/$target_name/html/index.html`\n";
}
close $fh;
print "$outdir\n";
sub run {
my ($env, $argv, $stdout_path, $stderr_path) = @_;
util/leak-check.pl view on Meta::CPAN
oneof => {
description => 'oneOf coercion including croaking error paths',
command => [ qw(perl -Iblib/lib -Iblib/arch t/33_oneof_input_objects.t) ],
},
croak_safety => {
description => 'escaped die recovery on the exec-state lane',
command => [ qw(perl -Iblib/lib -Iblib/arch t/34_exec_state_croak_safety.t) ],
},
soak => {
description => 'long-running worker RSS soak (short profile)',
command => [ qw(perl -Iblib/lib -Iblib/arch util/soak-test.pl --iterations 3000 --warmup 1000) ],
},
);
my @case_names = @requested_cases
? @requested_cases
: qw(parser_public execution vm_execute promise aliases persisted oneof croak_safety soak);
for my $name (@case_names) {
die "Unknown leak-check case: $name\n" unless exists $cases{$name};
}
die "Unknown backend: $backend\n" unless $backend eq 'asan' || $backend eq 'leaks';
util/parser-fuzz.pl view on Meta::CPAN
# C and faces untrusted input directly, so a crash is a downed worker. This
# mutates known-good documents (the vendored fixtures) with byte-level and
# structural corruptions and asserts the parser never crashes: every input
# either parses or raises a normal error. Run it under ASan to turn latent
# memory bugs into hard failures:
#
# perl Build.PL --config optimize="-O2 -g -fsanitize=address -fno-omit-frame-pointer" \
# --config lddlflags="-shared -fsanitize=address"
# ./Build
# LD_PRELOAD=$(gcc -print-file-name=libasan.so) \
# perl -Iblib/lib -Iblib/arch util/parser-fuzz.pl --iterations 50000
#
# A crash here is a SIGSEGV/SIGABRT taking the whole process down; the exit
# status reflects that. Clean parse errors are the expected outcome and are
# not failures.
use Getopt::Long qw(GetOptions);
use GraphQL::Houtou qw(parse);
my $iterations = 20000;
my $seed = defined $ENV{FUZZ_SEED} ? $ENV{FUZZ_SEED} : time ^ $$;
GetOptions(
'iterations=i' => \$iterations,
'seed=i' => \$seed,
) or die "usage: $0 [--iterations N] [--seed N]\n";
srand($seed);
print "parser-fuzz: iterations=$iterations seed=$seed\n";
# Seed corpus: the vendored fixtures plus a spread of small documents that
# exercise every token kind and construct.
my @corpus;
for my $path (glob 't/*.graphql') {
open my $fh, '<', $path or next;
local $/;
push @corpus, scalar <$fh>;
}
push @corpus,
util/parser-fuzz.pl view on Meta::CPAN
my $s = $_[0];
return $s if length($s) < 2;
my $i = int(rand(length $s));
my $len = 1 + int(rand(length($s) - $i));
substr($s, $i, $len) = '';
$s;
},
);
my ($parsed, $errored) = (0, 0);
for my $n (1 .. $iterations) {
my $input = $corpus[int(rand(@corpus))];
# Apply 1-3 mutations.
for (1 .. 1 + int(rand(3))) {
$input = $mutators[int(rand(@mutators))]->($input);
}
my $ok = eval { parse($input); 1 };
if ($ok) { $parsed++ } else { $errored++ }
# A crash (SIGSEGV/SIGABRT) never returns here; it kills the process and
# the non-zero exit is the signal. eval only catches Perl-level die.
}
util/profile-execution-target.pl view on Meta::CPAN
use GraphQL::Houtou::Promise::PromiseXS qw(
maybe_get_promise_xs
);
use GraphQL::Houtou::Type::Interface ();
use GraphQL::Houtou::Type::Object ();
use GraphQL::Houtou::Type::Scalar ();
use GraphQL::Houtou::Type::Union ();
my $case_name;
my $target;
my $iterations = 300;
GetOptions(
'case=s' => \$case_name,
'target=s' => \$target,
'iterations=i' => \$iterations,
) or die usage();
die usage() if !$case_name || !$target;
sub upstream_promise_xs_code {
require Promise::XS;
return {
resolve => sub { Promise::XS::resolved(@_) },
reject => sub { Promise::XS::rejected(@_) },
all => sub {
util/profile-execution-target.pl view on Meta::CPAN
houtou_runtime_native_bundle => sub {
return maybe_get_promise_xs($native_bundle->execute);
},
);
my $runner = $dispatch{$target};
my $expected = $dispatch{$target}->();
die "Sanity check failed for $case_name/$target\n" if !defined $expected;
DB::enable_profile() if DB->can('enable_profile');
for (1 .. $iterations) {
my $got = $runner->();
require Data::Dumper;
local $Data::Dumper::Sortkeys = 1;
die "Result mismatch for $case_name/$target\n"
if Data::Dumper::Dumper($got) ne Data::Dumper::Dumper($expected);
}
DB::disable_profile() if DB->can('disable_profile');
print(
DB->can('enable_profile')
? "profiled case=$case_name target=$target iterations=$iterations\n"
: "executed case=$case_name target=$target iterations=$iterations (DB profile hooks unavailable)\n"
);
sub usage {
return "Usage: $0 --case NAME --target NAME [--iterations N]\n";
}
util/profile-parser.pl view on Meta::CPAN
use strict;
use warnings;
use FindBin qw($Bin);
use Getopt::Long qw(GetOptions);
use lib "$Bin/../lib";
use GraphQL::Houtou qw(parse_with_options);
my $file = 't/kitchen-sink.graphql';
my $iterations = 200;
my $no_location = 0;
GetOptions(
'file=s' => \$file,
'iterations=i' => \$iterations,
'no-location!' => \$no_location,
) or die "Usage: $0 [--file path] [--iterations N] [--no-location]\n";
open my $fh, '<', $file or die "Failed to open $file: $!";
my $source = do { local $/; <$fh> };
for (1 .. $iterations) {
parse_with_options($source, {
no_location => $no_location,
});
}
print "profiled parser=graphql-perl-xs no_location=$no_location file=$file iterations=$iterations\n";
util/soak-test.pl view on Meta::CPAN
# Long-running worker soak test.
#
# Simulates the request patterns a prefork web worker sees and asserts that
# resident memory stops growing once the process is warmed up. Scenarios
# cover the paths where native allocations churn per request: fresh
# variables, program cache eviction, specialized (runtime directive)
# programs, resolver/coercion error paths including escaped dies, async
# Promise::XS execution, and persisted bundles.
#
# perl -Iblib/lib -Iblib/arch util/soak-test.pl
# perl -Iblib/lib -Iblib/arch util/soak-test.pl --iterations 100000 \
# --warmup 10000 --max-growth-kb 8192 --scenario varying_variables
use 5.014;
use strict;
use warnings;
use FindBin qw($Bin);
use File::Spec;
use Getopt::Long qw(GetOptions);
BEGIN {
util/soak-test.pl view on Meta::CPAN
}
use GraphQL::Houtou qw(build_native_runtime compile_native_bundle);
use GraphQL::Houtou::DataLoader;
use GraphQL::Houtou::Schema;
use GraphQL::Houtou::Type::Object;
use GraphQL::Houtou::Type::InputObject;
use GraphQL::Houtou::Type::Scalar qw($String $Int $ID);
use GraphQL::Houtou::Directive;
my $iterations = 20000;
my $warmup = 5000;
my $max_growth_kb = 8192;
my @requested;
GetOptions(
'iterations=i' => \$iterations,
'warmup=i' => \$warmup,
'max-growth-kb=i' => \$max_growth_kb,
'scenario=s@' => \@requested,
) or die "Usage: $0 [--iterations N] [--warmup N] [--max-growth-kb KB] [--scenario name]\n";
sub rss_kb {
if ($^O eq 'linux') {
open my $fh, '<', '/proc/self/status' or die "cannot read /proc/self/status: $!";
while (my $line = <$fh>) {
return $1 if $line =~ /^VmRSS:\s+(\d+)\s+kB/;
}
die "VmRSS not found in /proc/self/status\n";
}
my $rss = qx{ps -o rss= -p $$};
util/soak-test.pl view on Meta::CPAN
}
sub run_mixed {
my ($count) = @_;
for my $i (1 .. $count) {
my $scenario = $scenarios{ $names[ $i % @names ] };
$scenario->($i);
}
}
printf "soak: scenarios=%s warmup=%d iterations=%d max-growth=%dKB\n",
join(',', @names), $warmup, $iterations, $max_growth_kb;
run_mixed($warmup);
my $baseline_kb = rss_kb();
printf "soak: rss after warmup: %d KB\n", $baseline_kb;
run_mixed($iterations);
my $final_kb = rss_kb();
my $growth_kb = $final_kb - $baseline_kb;
printf "soak: rss after %d iterations: %d KB (growth %+d KB)\n",
$iterations, $final_kb, $growth_kb;
if ($growth_kb > $max_growth_kb) {
die sprintf "soak FAILED: RSS grew %d KB (> %d KB) over %d iterations\n",
$growth_kb, $max_growth_kb, $iterations;
}
say "soak PASSED";