GraphQL-Houtou

 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";



( run in 1.851 second using v1.01-cache-2.11-cpan-4ab04211f4c )