Imported from matsumotory/ngx_mruby (
AGENTS.md). Install upstream withnpx skills add matsumotory/ngx_mruby. Copyright stays with the author.
AGENTS.md
Instructions for AI coding agents (and a quick reference for humans) working on
ngx_mruby. Tool-specific notes live in separate files (for example CLAUDE.md).
What ngx_mruby is
ngx_mruby embeds the mruby VM in nginx so that HTTP phase handlers, header/body filters, SSL handshake handlers and stream (TCP/UDP) handlers can be written in Ruby. It is built as a static or dynamic module of unpatched nginx.
src/http/,src/stream/: the C module (HTTP and stream are separate code paths)mrbgems/: Ruby-side gems bundled into the buildbuild_config.rb: mruby build configuration and the third-party mrbgems fetched at build timemruby/,dependence/ngx_devel_kit/: vendored upstream trees (see "Do not")test/: integration tests that start a real nginx (test/conf/,test/html/,test/t/)docs/: user documentation (install, directives, classes and methods) and release notes (docs/releases/)
Design principles
The owner decided these on 2026-10-04. They hold on master and next:
- One
mrb_stateper worker, shared by the requests of that worker, and nomrb_stateper request. A state per request would be safer, but it would be slow, and sharing objects between states is costly. Changes keep this balance of performance, security and convenience in mind. - No blocking.
- Keep the existing performance.
- Measure performance regularly.
The rest of this section is how sessions apply these decisions. It is their
procedure, not a decision of the owner, and it changes through PRs to this
file. Read it before you change src/, mrbgems/ or the build.
-
Where the state is. nginx creates the
mrb_stateof anhttp {}block (and one for eachstream {}block) while it reads the configuration, and each worker gets its own copy when it is forked. The state of a request lives in the request's module context (ngx_http_get_module_ctx; in stream, the session's). The server configuration, which all requests and connections of the server share, holds only state whose lifetime is explicit: it is set before the code that uses it and cleared after that code. Nothing fixed (a class reference, a symbol name, a buffer) is looked up or allocated per request when it can be prepared once at initialization. -
What the rule against blocking covers. ngx_mruby's own code (
src/,mrbgems/) waits only through nginx: its events and timers, the return codesNGX_DONEandNGX_AGAIN, and the fiber-basedNginx::Async(Nginx::Stream::Asyncin stream), which suspends the Ruby code and resumes it from an nginx timer or at the end of a subrequest. A change adds no other wait: not for the network, a process or a timer, and not for a local file on the request path unless the PR gives the reason. These places block today:- a handler script without
cacheormruby_cache onis read from disk on every request (fopen), and when a script givesNginx::SSLthe paths of a certificate and a key, they are read during the handshake (BIO_new_file,SSL_use_PrivateKey_file); - the bundled
auto-sslgem runs the dehydrated ACME client and waits for it to finish; - an operator's script blocks its worker when it uses mruby-sleep, mruby-socket or mruby-io, which the default build includes on both branches, or matsumotory/mruby-redis (a hiredis client), which the default build includes again since 2.7.2. The rule is for ngx_mruby's own code, not for such scripts.
- a handler script without
-
Which changes are measured. A PR that changes an input of the measured binary is compared with its base in the callgrind lane ("Performance comparison with callgrind" in
docs/test/README.md). The inputs are those that "In CI" indocs/test/README.mdlists:src/,mrbgems/,mruby/,dependence/,build_config.rb,configure,config.in,Makefile.in,build.sh,nginx_version, and onnextthe gem lockbuild_config.rb.lock(mastercommits no lock). Theperfjob of.github/workflows/test.ymlruns the comparison on such PRs, onmasterand onnext.- The author quotes the comparison in the PR body, under "Leak /
performance impact" (see
.github/PULL_REQUEST_TEMPLATE.md): the table with the header lines ofreport.txtabove it (the line withN,WARMUPand the thresholds, and the lines on the gems; in the job's log), the run of theperfjob, and the commits that its log prints (base: ..., head: ...). When the base branch gains a change to a measured input before the merge, the comparison is made and quoted again by the CI run that condition 2 of "Review and merge" asks for. - The lane builds nginx without
--with-streamand has no stream scenario, so for a change tosrc/stream/alone both builds are the same binary. Such a PR says so and states instead what the change adds per session, as read from the diff (calls, allocations, copies). - The reviewer writes, for the scenario with the largest change and for
each row that is not
ok: its base and head Ir per request without GC, and the change in instructions per request and in percent. - A scenario at
WARN(3% or more instructions per request without GC than the base) orFAIL(5% or more) holds the merge until the owner accepts the difference on the PR; the session asks the owner.ERRORmeans that the scenario was not measured: fix the run or run it again. - The scenarios with an upstream vary with when the upstream's response
arrives; the report prints their relay calls per request
(
non_buffered_calls). Such a scenario atWARNorFAILis measured a second time only when the first report's line for it ends with the notethe change of this scenario may come from when the response arrived, which the report prints when the difference of those calls alone makes half of theWARNthreshold or more ("Agent proxy scenarios in the comparison" indocs/test/README.md). Both runs are quoted, and the second replaces the first. In every other case, theWARNorFAILgoes to the owner without a second run. - A session does not merge a PR while the latest run of its
perfjob reportsFAILorERROR, except for aFAILthat the owner accepted on the PR. This holds althoughci-okdoes not need the job yet (onnext, "In CI" indocs/test/README.mdstill calls the job advisory; a follow-up PR tonextaligns that text). The plan (Pillar E ofdocs/proposals/v3-plan.mdonnext) adds the job to theneedsofci-okonce it has run on code changes without false alarms; from then on aFAILor anERRORalso failsci-ok.
- The author quotes the comparison in the PR body, under "Leak /
performance impact" (see
-
Before a tag. Each PR is compared only with its base, so changes that stay below
WARNcan add up. The check PR of "Before the tag" (seedocs/releases/README.md) therefore also compares its head with the tag where the range of the check starts. The base is that tag, and the head must contain the tip of its base branch, so update the branch first. From a checkout of the check PR's head commit (<base>ismasterornext):git fetch origin <base> --tags git merge-base --is-ancestor origin/<base> HEAD # exits 1 when the branch is behind: update it first D=$HOME/ngx_mruby-perf # any directory outside the checkout rm -rf "$D" && mkdir -p "$D/base" "$D/head" git archive <tag> | tar -x -C "$D/base" git archive HEAD | tar -x -C "$D/head" cd "$D/head" && sh test/perf/compare.sh "$D/base"The last line needs Linux and valgrind. On macOS, run this line in its place; it runs
compare.shin a Linux container with$Dmounted at the same path:docker run --rm -v "$D:$D" -w "$D/head" -e D="$D" ubuntu:22.04 sh -c 'apt-get -qq update && DEBIAN_FRONTEND=noninteractive apt-get -qq install -y build-essential rake ruby bison git gperf wget ca-certificates zlib1g-dev libpcre3-dev libssl-dev valgrind >/dev/null && sh test/perf/compare.sh "$D/base"'The report is in
$D/head/build_perf/report.txt. The measurement (test/perf/, the scenarios intest/soak/) comes from the head, so a tag from before the lane was added is measured as well. The check PR quotes the table as above, and aWARNorFAILthere goes to the owner. -
Planned, not in place yet: a benchmark of the test nginx for throughput and latency (Pillar E and section 8 of
docs/proposals/v3-plan.mdonnext). Where its results are recorded is decided when it is added.
Branches and pull requests
| Branch | Role | Who changes it |
|---|---|---|
master |
2.x line until v3 is promoted, then 3.x | PRs only; merged as described in "Review and merge" |
next |
v3 development (pre-release tags vX.Y.Z-alpha.N/-beta.N/-rc.N) |
PRs only; merged as described in "Review and merge" |
v2.x |
2.x maintenance; an automatic mirror of master until v3 is promoted |
Mirror automation; after promotion, PRs merged as described in "Review and merge" |
The 2.x line (master until v3 is promoted, then v2.x) puts compatibility
first; this is the owner's decision of 2026-10-04. It takes stability fixes
(crashes, hangs, leaks, build fixes) and security fixes, and changes behavior
that a configuration, script or build can observe only as far as the fix of a
defect requires, with its entry under "Behavior changes: read before
upgrading". New behavior, API cleanups and features go to next.
- 2.x fixes: PR with base
masteruntil v3 is promoted (afterwards: basev2.x). A fix that also applies to v3 still goes tomaster; it reachesnextwhenmasteris merged intonext, so do not open a duplicate PR unless asked. - v3 work: PR with base
next. - Never push to
master,nextorv2.xdirectly. Until v3 is promoted,v2.xis written only by the mirror automation: never commit to it or open PRs against it. - Topic branches:
claude/<topic>(other tools:<tool>/<topic>). Backports to 2.x:backport/v2.x/<topic>. - Never create branches named like tags (
v2.7.1,v3.0.0-rc.1), and never createnext/*orv2.x/*branches (they clash with the existing branch refs). - Agents open PRs and fill in every section of the PR template. Open them as drafts while CI and the review below are running.
- A PR that changes behavior a configuration, script or build can observe (a
bug fix, a raised build requirement and a change of the bundled mruby or
default mrbgems included; see
docs/releases/README.mdfor what counts) adds its entry under "Behavior changes: read before upgrading" indocs/releases/<version>.md, in the same PR: before, now, why it changed, what is affected, what to do. The same PR adds the entry in Japanese todocs/releases/<version>.ja.md, and creates that file by translating the whole English file if the release has none yet (see "Who writes the entries, and when" indocs/releases/README.md). Users have built on the old behavior, so the new release must warn them. - Agents never create, move or delete tags, and never publish releases or
security advisories. Those stay with the owner. Before a tag, a PR checks
the release notes against every PR merged since the previous release (see
"Before the tag" in
docs/releases/README.md), and compares the performance with that release (see "Design principles").
Review and merge
Every PR gets a review by an agent (or person) that did not write it, with
this checklist: the change is inside the agreed scope (docs/proposals/ on
next, an issue, or a fix with evidence); the code and tests are correct when read
against src/ and, where it matters, the nginx source; a bug fix has its
regression test in a commit of its own before the fix commit, and the
reviewer has run the suite at both commits (failing, then passing, with
results matching what the author quoted); expectations assert
on real responses, not only on "not 500"; the CI result on the head commit;
when an input of the measured binary changed, the performance comparison
quoted in the PR body and the numbers that "Design principles" asks the
reviewer to write;
compatibility notes in the PR body match the diff; a change of behavior that a
configuration, script or build can observe has an entry under "Behavior
changes: read before upgrading" in docs/releases/<version>.md with every
line of the template, and the same entry in Japanese in
docs/releases/<version>.ja.md (see "Who writes the entries, and when" in
docs/releases/README.md), and docs/directives/,
docs/class_and_method/ or docs/install/ describe the new behavior; a PR
that moves the pin of one of the owner's gems links the
gem's PR, which has its review and the suite results (see "Other
repositories"); nothing in the diff, the
commit messages or the PR text discloses an unpublished vulnerability or a
secret; and docs/ is updated when a directive, Ruby method, build option or
the test harness changes. The reviewer posts the findings as a PR comment,
split into "must fix" (blocks the merge) and "should fix". A behavior change
of the PR without that entry, an entry of the PR that lacks a line of the
template, and an entry of the PR whose Japanese version is missing or says
something other than the English entry are each a "must fix" (which entries
count as the PR's own, also when the PR creates the Japanese file of an
earlier release file, is in "Who writes the entries, and when" in
docs/releases/README.md). A missing "New features", "Fixes" or "Build and
test changes" entry, and a missing or different Japanese version of one, are
a "should fix". The author fixes every "must fix" and asks for a re-review of
those items only.
The session that owns the PR merges it, with a merge commit, when all of these hold:
- The base branch follows the branch table above.
- Every CI check on the head commit passed (
ci-okonce it is required), on a run whose merge ref includes the current base. When the base moved after the last run, update the branch and let CI run again. A run made green by skipping or disabling tests does not count. - The change is inside the agreed scope, or is a bug fix with the two-commit
evidence described under "Writing tests", or changes documentation only.
In every case, a change of behavior that a configuration, script or build
can observe has an entry under "Behavior changes: read before upgrading"
in
docs/releases/<version>.mdwith every line of the template, the same entry in Japanese indocs/releases/<version>.ja.md, and the docs describe the new behavior. - The review above is recorded on the PR and no "must fix" item is open.
- The scrub for secrets and unpublished vulnerability details passed.
- The PR has no conflicts with its base.
- The PR is marked ready for review (GitHub does not merge a draft).
If any condition cannot be met, the session says which one and why, and asks the owner instead of merging.
Other repositories
The owner decided on 2026-10-04 that sessions may change the mrbgem
repositories that the owner maintains (matsumotory/mruby-*), mruby-uname
included.
The rest of this subsection is the sessions' procedure, not a decision of
the owner. It names the branches as they are until v3 is promoted, as the
branch table does; step 4 of "Promoting v3 to master" in
docs/DEVELOPMENT.md rewrites both. master commits no gem lock (its
.gitignore lists build_config.rb.lock), and mruby clones a github:
gem at the head of its master branch when no lock pins it. A merge into
a gem's master branch therefore reaches every later build of master,
CI included, and every source build of a released 2.x tag, without a PR
here. The 2.x policy and the release notes rule above apply to such a
merge as to a PR here.
- Each change is a PR on the gem's repository, from a branch of that repository to its default branch. An agent that did not write it reviews it with the checklist of "Review and merge", as far as it applies, and posts the findings on that PR.
- Before the merge, the author runs ngx_mruby's suite (
sh test.sh) on a checkout ofmasterand on one ofnext, each with the gem at the head commit of the gem's PR, and quotes both results on the gem's PR, with the command, the nginx version and the commits of ngx_mruby and of the gem. To build a gem at a commit, addchecksum_hash: '<commit>'to eachconf.gem github: 'matsumotory/<gem>'line ofbuild_config.rb(a local edit, not committed) and removemruby/buildfirst. Onnext, the build also rewrites the committedbuild_config.rb.lock: restore both files afterwards withgit checkout -- build_config.rb build_config.rb.lock. A gem that the default build leaves out has no such line (its line is commented out, or there is none): enable it in the same local edit for the run and say so with the quoted results, or quote the gem's own tests and name the later PR that enables it, whose suite runs then cover it. The gem's own tests run as well where it has them. Most of these gems have no CI of their own, and mruby-userdata has no tests, so ngx_mruby's suite is the evidence that counts. - The change keeps the behavior that a configuration, script or build of
2.x observes, unless it fixes a defect. For a fix that changes such
behavior, a PR to
masteradds the entry under "Behavior changes: read before upgrading" (seedocs/releases/README.md), naming the gem's PR, and is merged before the gem's PR. A change that is not a defect fix, such as a new method, an API change or a cleanup, is not merged into the gem'smasterbranch, which 2.x builds take: the 2.x policy sends such changes tonext, so the session asks the owner. Other fixes and build changes that the release notes list get their entry through a PR tomasteras well, so that "Before the tag" indocs/releases/README.mdfinds them. - The session that owns the gem's PR (the one that opened it, or one that
took it over in a comment on it) merges it, with a merge commit, when all
of these hold: the review is recorded on the gem's PR and no "must fix"
item is open; both suite results are for the PR's current head commit,
which is up to date with the gem's default branch, and pass; every entry
that the release notes need for the change (a behavior change, a fix or a
build change) is merged through a PR to
master; nothing in the gem's PR discloses an unpublished vulnerability or a secret; and the PR has no conflicts. If any of these cannot be met, the session asks the owner instead of merging. - Afterwards, a PR to
nextmoves the gem's pin inbuild_config.rb.lockto the merge commit on the gem's default branch. Theperfjob measures it like any change of the lock. - A possible vulnerability in a gem goes to the security process of
SECURITY.md, as "Security" below says, not into a PR on the gem. For other defects, do not rewrite a patch from private material into a gem: derive the fix from the symptom (the failing build, test or output).
Session handoff
The owner decided on 2026-10-04 that the information about the work that may be public is kept in the repository, so that another session can continue it.
How sessions do it (their procedure, not a decision of the owner):
docs/HANDOFF.mdonnextrecords the state of the work: the heads of the branches, the open PRs and their stage, the owner's decisions with their dates and the place where each will be written, the work in progress, and the queue. The heads and stages are a snapshot as of the date in its heading; check them withgitandghbefore you act on them.- A PR with base
nextupdates the file in the same PR when it changes what the file records, its own row and the item of the queue that it finishes included. A PR with basemastercannot, because the file is only onnext: after the merge, the session that merged it updates the file in a small PR tonext, or in the merge ofmasterintonextif that comes first. A release is recorded in the same way by the first session that finds its tag missing from the file. - It records state. The rules live in this file and in the plan
(
docs/proposals/v3-plan.mdonnext). A decision of the owner that is not written there yet is recorded indocs/HANDOFF.mdwith its date. It holds over an older text here until a PR writes it here, but only when its row links the owner's comment or quotes the owner's words; a row without either is a proposal of the sessions and changes no rule. - It never holds unpublished vulnerabilities, advisory ids, names of private branches, files from outside the repository, or which release or work carries a security fix.
- There is no copy on
master. From a checkout ofmaster, read it withgit fetch origin nextandgit show origin/next:docs/HANDOFF.md.
Build and test
Requirements are listed in docs/install/ (C compiler, make, bison, git,
Ruby with rake, OpenSSL). nginx also needs the PCRE and zlib development
headers, and test.sh uses wget and the openssl command. mruby is built with
rake: if it is not on PATH because Ruby is managed by a version manager
(rbenv, asdf, ...), put that Ruby's bin directory on PATH first.
sh test.sh # full run: fetch nginx (version in ./nginx_version), configure, build, test
ONLY_BUILD_NGX_MRUBY=1 sh test.sh # fast loop: skip fetch/configure, rebuild changed sources, re-run tests
BUILD_DYNAMIC_MODULE=1 sh test.sh # build as a dynamic module (uses build_dynamic/ instead of build/)
NGINX_RUNNER=valgrind NGINX_HEATTIME=10 sh test.sh # run nginx under valgrind
sh test/perf/compare.sh BASE_DIR # callgrind Ir per request, BASE_DIR vs this checkout (Linux, valgrind; builds in build_perf/, ports 12370-12372)
- The first full run takes a few minutes (it clones mrbgems from GitHub and builds
mruby and nginx). After that, use
ONLY_BUILD_NGX_MRUBY=1. ONLY_BUILD_NGX_MRUBY=1reuses the last configure. After changingBUILD_DYNAMIC_MODULE, configure options ornginx_version, nginx has to be configured again: "Testing" indocs/test/README.mdsays when a full run does that and what to remove first.- Extra arguments to
test.share passed to./configure(for examplesh test.sh --with-openssl-src=/path/to/openssl). - Headers in
src/are not make dependencies: after editing a.h,touchthe.cfiles that include it. test.shadds-DMRB_GC_STRESStoNGX_MRUBY_CFLAGS. An existing mruby build is not rebuilt when only these flags change; use a separate worktree/checkout for sanitizer builds.- Results: the test runner exits non-zero on a failed assertion. nginx logs are in
build/nginx/logs/(build_dynamic/nginx/logs/for dynamic builds);error.logis at debug level. valgrind errors do not change the exit status oftest.sh: read the valgrind output (ERROR SUMMARY, leak summary) yourself. test.shlistens on fixed ports (18080-18090, 18101-18103, 18110-18131, 18136, 18138, 18143-18144, 18147-18153, 12345-12359; 18116 and 12357 belong to the second nginx thattest/t/cases/_second_instance.rbstarts, 12399 to a test intest/t/ngx_mruby.rb) and kills every runningnginxprocess before it starts. Run only onetest.shper machine at a time, and wait if an nginx you did not start is running.test/perf/compare.shbuilds the base and the head inbuild_perf/(it does not touch thetest.shbuild), listens on 12370 and 12371, and starts the mock LLM upstream (test/soak/mock_llm.rb) on 12372 for the agent proxy scenarios (PERF_PORT_BASEmoves all three). It stops only the processes it started.test.shkills its nginx too, so do not run both at the same time on one machine. It needs valgrind'scallgrind_controlandvgdb: on macOS, run it in a Linux container. See "Performance comparison with callgrind" indocs/test/README.md.
When nginx.org is unreachable
test.sh downloads http://nginx.org/download/nginx-X.Y.Z.tar.gz only if
build/nginx-X.Y.Z does not exist. Pre-populate it from the nginx GitHub tag;
the Makefile handles git checkouts (which have auto/configure instead of configure):
. ./nginx_version
git clone --depth 1 --branch release-${NGINX_SRC_MAJOR}.${NGINX_SRC_MINOR}.${NGINX_SRC_PATCH} \
https://github.com/nginx/nginx build/${NGINX_SRC_VER} # build_dynamic/... for BUILD_DYNAMIC_MODULE
sh test.sh
Writing tests
- Prefer a fragment and a case file of their own: a
server {}intest/conf/conf.d/<name>.conf(stream:test/conf/conf.d/stream/) on a port of its own (HTTP 18110 and up, stream 12353 and up), scripts intest/html/, and assertions intest/t/cases/<name>.rb(seedocs/test/README.md). Edittest/conf/nginx.confandtest/t/ngx_mruby.rbonly for tests that need the main server. - A bug fix comes as two commits: the first adds the regression test and
nothing else, the second adds the fix.
- The author runs the suite at each commit (it fails at the first because of the new test and nothing else, and passes at the second) and quotes both results in the PR body, with the command and the nginx version. The reviewer checks out each commit and repeats the two runs. A test that is only shown failing on the base branch in prose, or that lands in the same commit as the fix, does not count.
- For a bug that only valgrind or a sanitizer reports (a leak, an
undefined-behavior report), "fails" means the report in
build/nginx/logs/error.logor the valgrind output that the new test provokes (quote it; seedocs/test/README.md). Sanitizer builds need a checkout of their own. - When switching between the two commits, touch the
.cfiles that include a changed header before building: headers insrc/are not make dependencies, so an unchanged.cis not rebuilt otherwise. - The merge commit keeps the red first commit in the history, so
git bisectneedsgit bisect skipon it. - When no harness test can express the bug (build, CI or packaging problems), say so in the PR and ask the owner what evidence to use instead.
- Assert on the actual response (body, headers, status), not only on "not 500".
- Do not change existing expectations, or remove or skip tests, without the owner's approval. If an expectation looks wrong, say so in the PR instead.
- Do not shorten the
Nginx::Stream::Async.sleep 3000of the 12352 server intest/conf/nginx.stream.confor thesleep 0.3intest/t/issue-268-test.rb: these delays are what those tests exercise. - The main test server (port 18080) has server-level handlers
(
mruby_set_code,mruby_server_rewrite_handler_code,mruby_post_read_handler_code) that run for all of its locations. Tests of phase or return-code behavior may need their ownserver {}.
Do not
- Edit
mruby/ordependence/ngx_devel_kit/by hand. They are vendored with git subtree and updated only with./update-mruby-subtree [ref]and./update-devkit-subtree [ref](seedocs/DEVELOPMENT.md), in a PR of their own. - Edit generated files:
config,Makefile,config.status,config.log(outputs of./configure; editconfig.in/Makefile.ininstead).configureis autoconf output: editconfigure.in, regenerate withautoconfand commit both. - Run
killall nginxorpkill nginxyourself (outsidetest.sh): other sessions may own those processes. Stop only processes you started. - Run
make clobber: it deletes the build directory (including the nginx source tree) and the mruby build. Only do it when the owner asks. - Reformat code you did not change. Format only your changed lines with the
repository
.clang-format: stage your changes, then rungit clang-format origin/<base>(it refuses files with unstaged changes).
Security
Report vulnerabilities privately as described in SECURITY.md. Never put
vulnerability details or reproducers in public issues, PRs, commit messages,
test names or files. If you find a possible vulnerability while working, stop
and report it to the person you are working for instead of fixing it in public.
