-
Notifications
You must be signed in to change notification settings - Fork 0
research test and ci mechanics
Terrain map for issue #57 ("stop Duckling.parse from blocking sibling
Fibers in an async reactor"). This document covers how the hill-first failing
test (test/falcon_fiber_blocking_test.rb) and its new dev-dependency
(async ~> 2.41) interact with the rest of the repo's build/CI/dev-workflow
tooling, so an implementation PR fixing the GVL-blocking behavior doesn't get
surprised by unrelated CI failures. This is a documentation-only research
artifact — no application code was changed to produce it.
test/falcon_fiber_blocking_test.rb is a single Minitest test,
FalconFiberBlockingTest#test_duckling_parse_does_not_stall_other_fibers_in_async_reactor,
that empirically tests the claim (first raised as "PLAUSIBLE" in
the FFI risk analysis)
that Duckling.parse — a synchronous Rust FFI call that does not release the
GVL — stalls every other Fiber sharing the same Falcon/async-gem reactor
thread for the duration of the call. This differs from Puma's OS-thread
model, where the GVL is contended but the scheduler can still preempt
between bytecode instructions; an async reactor cooperatively schedules
Fibers on a single OS thread, so a native call that never yields to the
reactor freezes every sibling Fiber on that thread for its full duration.
-
Constants:
TICK_INTERVAL = 0.001(1ms) — how often the "ticker" Fiber is asked to wake up.NON_BLOCKING_TOLERANCE = 0.010(10ms) — how much a tick gap is allowed to exceedTICK_INTERVALbefore it's considered "blocked"; the pass condition ismax_gap < TICK_INTERVAL + NON_BLOCKING_TOLERANCE, i.e.max_gap < 0.011s.TICKS_BEFORE_PARSE = 20andTICKS_AFTER_PARSE = 20— the ticker records 20 gaps before the parser Fiber starts its call and 20 after, so there's a "normal" baseline on both sides of the parse window. -
LONG_PARAGRAPHfixture: a few hundred words of travel-itinerary prose containing a mix of dates, times, durations, numbers, and money amounts, chosen (per the test's own comments) to giveDuckling.parse"realistic entity-extraction work to do (not just a cheap no-match scan)" and to match the "long LLM-generated paragraph" shape referenced in the FFI risk analysis (~3ms estimated parse time — the risk doc's Criterion benchmark for "long prose" measured ~2.93ms in a release build). -
Warm-up call:
Duckling.parse("tomorrow", ...)is called once, outside the timedSyncblock, specifically to absorbDuckling.parse's first-call-per-process cost (lazy-static/regex compilation) so the timed run only measures steady-state request-handling latency. -
Measurement: uses
Process.clock_gettime(Process::CLOCK_MONOTONIC)throughout (notTime.now), which is the correct choice for interval measurement since it's immune to wall-clock adjustments (NTP slew, system clock changes) that would otherwise corrupt gap measurements. -
Mechanics: inside a
Sync do ... endblock, anAsync"ticker" task loopsTICKS_BEFORE_PARSE + TICKS_AFTER_PARSEtimes, each iteration doingtask.sleep(TICK_INTERVAL)and recording the gap since the last tick. A secondAsync"parser" task sleeps until the ticker has established its baseline (TICK_INTERVAL * TICKS_BEFORE_PARSE), then makes a singleDuckling.parse(LONG_PARAGRAPH, ...)call and records its wall-clock duration.dims:is intentionally omitted from the call — the test's comment notes only the"time"dimension is implemented as of this gem version, so omittingdims:exercises the same code pathdims: ["time"]would. After both tasks finish, the test assertsmax_gap < 0.011s; on failure, the assertion message reports both the max observed gap and the measured parse duration side by side, since a max gap approximately equal to the parse duration is exactly the failure signature the blocking-FFI-call hypothesis predicts.
Ran bundle exec ruby -I test -I lib test/falcon_fiber_blocking_test.rb
against this branch (after bundle install and bundle exec rake compile,
neither of which had been run yet in this checkout). Result:
1) Failure:
FalconFiberBlockingTest#test_duckling_parse_does_not_stall_other_fibers_in_async_reactor
Expected 0.11061999999219552 to be < 0.011.
Max observed ticker gap 0.1106s, measured Duckling.parse duration
0.1098s — the two numbers track each other closely, as predicted. This
is the same failure signature already reported in the task background (max
gap 0.278s vs. parse duration 0.277s from an earlier run) — the absolute
numbers differ run to run (plausibly due to machine load, thermal
throttling, or a debug- vs release-profile Cargo build — this checkout had
no prior lib/duckling/duckling.bundle, so rake compile built fresh), but
the order of magnitude and the tight coupling between "max gap" and "parse
duration" match. This is the currently-failing hill-first test issue #57
exists to turn green.
duckling.gemspec declares:
spec.add_development_dependency "async", "~> 2.41"with a comment clarifying it's "used only by
test/falcon_fiber_blocking_test.rb ... not a runtime dependency of the gem
itself." This is confirmed as correctly reflected in Gemfile.lock: bundle check (before this research's bundle install) reported only io-event (1.19.1) as missing — a resolution mismatch between the locked version and
what was already gem-installed locally in this dev environment, not a
lockfile/gemspec inconsistency. After bundle install, bundle check
reports the bundle satisfied, and Gemfile.lock's DEPENDENCIES section
lists async (~> 2.41), resolved to async (2.41.0).
async (2.41.0) pulls in, per the lockfile:
-
console (~> 1.29)→ resolvedconsole (1.36.0), itself depending onfiber-annotation,fiber-local (~> 1.1),json -
fiber-annotation→ resolvedfiber-annotation (0.2.0) -
io-event (~> 1.11)→ resolvedio-event (1.19.1) -
metrics (~> 0.12)→ resolved (present in lockfile as ametricsdependency ofasync) -
traces (~> 0.18)→ resolved (present in lockfile as atracesdependency ofasync) -
fiber-local (1.1.0)→ depends onfiber-storage fiber-storage (1.0.1)
All of these are published under the socketry
GitHub organization (same org as async itself:
socketry/async, tag
v2.41.0).
Checked every transitive gem's installed Gem::Specification#extensions
and #platform directly (ruby -e "Gem::Specification.find_by_name(...)")
rather than guessing:
| Gem | Platform | Extensions |
|---|---|---|
async |
ruby |
none |
console |
ruby |
none |
fiber-annotation |
ruby |
none |
fiber-local |
ruby |
none |
fiber-storage |
ruby |
none |
metrics |
ruby |
none |
traces |
ruby |
none |
io-event |
ruby |
["ext/extconf.rb"] |
io-event is the only gem in this dependency chain with a native C
extension. It is shipped as a source gem (platform: ruby, not a
precompiled per-platform binary gem), so it compiles via mkmf at install
time on every platform. Its
ext/extconf.rb
(pinned to the locked version's tag,
v1.19.1) builds a
selector backend appropriate to the host:
-
sys/epoll.hpresent → compiles the epoll selector (Linux) -
sys/event.hpresent → compiles the kqueue selector (macOS/BSD) -
liburing.handliburingpresent → additionally compiles theio_uringselector, but this check is wrapped inif have_library("uring") and have_header("liburing.h")— it silently degrades to epoll/kqueue when absent, rather than failing the build.
So io-event needs a standard C compiler + make at gem-install time (no
liburing, no other special headers), which is already a baseline
assumption for this repo — Ruby MRI's own gem ecosystem (and this gem's own
native extension, wired via rb_sys/Cargo) already requires a C toolchain
on any machine that builds it. This introduces no requirement beyond what
ubuntu-latest GitHub Actions runners and the Claude Code Web sandbox
already provide.
.github/workflows/main.yml runs a single-entry Ruby matrix (3.3.6) on
ubuntu-latest. Relevant steps, in order: actions/checkout, ruby/setup-ruby
(with bundler-cache: true, ruby-version: 3.3.6), dtolnay/rust-toolchain
(pinned 1.94.1, with clippy/rustfmt), cargo fmt --check, cargo clippy -- -D warnings (both against ext/duckling/), then bundle exec rake (which runs standard + compile + test — the full Minitest suite,
now including falcon_fiber_blocking_test.rb).
bundler-cache: true already covers async and its transitive deps with
no extra CI steps. ruby/setup-ruby's bundler-cache: true option runs
bundle install (and caches the result keyed on the lockfile hash), which
resolves and installs every gem in Gemfile.lock, including async and
io-event. Since io-event's native extension only needs a C
compiler/make — both present on the standard ubuntu-latest runner image
(used for many other gems' native extensions across the Ruby ecosystem,
same as this gem's own Rust extension needs a Rust toolchain) — no
additional apt-get install or runner-configuration step is required.
Cross-referenced this against how duckling's upstream dependency, socketry/async itself, tests things in its own CI, to check for any hidden runner requirement this repo's CI doesn't already satisfy:
-
async's main test workflow (pinned to thev2.41.0tag, matching the version this repo locks) runs onubuntu-latestandmacos-latestacross several Ruby versions using nothing butactions/checkout+ruby/setup-rubywithbundler-cache: true+bundle exec bake test. No extra system packages, noio_uring/epoll-specific runner configuration. -
async's repo does have a separatetest-uring.yamlworkflow that explicitly setsIO_EVENT_SELECTOR=URingand runssudo apt-get install -y liburing-devbefore testing — but this only exists to specifically exercise the optionalio_uringselector path. This repo's CI does not setIO_EVENT_SELECTORand does not installliburing-dev, soio-eventwill silently fall back to the epoll selector on theubuntu-latestrunner (matchingextconf.rb's graceful degradation described in section 2) — which is exactly whatfalcon_fiber_blocking_test.rbneeds, since the test doesn't care which selector backendAsyncuses under the hood.
Conclusion: no CI matrix or workflow change is needed for the async
dependency to work in .github/workflows/main.yml as currently written.
One unrelated, pre-existing, non-blocking wrinkle: Gemfile.lock's
PLATFORMS section currently lists only ruby and x86_64-darwin-24 (this
developer's local platform) — not x86_64-linux (the ubuntu-latest
runner's platform). Since every gem this dependency chain touches resolves
to the platform-independent ruby variant (see the table above — no gem
needs a platform-specific binary), and neither Gemfile nor CI sets
BUNDLE_FROZEN/BUNDLE_DEPLOYMENT (checked: no .bundle/config file and no
such env var set in main.yml), a plain bundle install on the runner
transparently resolves for its own platform without erroring — this is a
pre-existing lockfile characteristic unrelated to the async addition and
was already reproduced as passing after bundle install in this research.
bin/claude-web-deps.sh (sourced by both bin/test's no-arg path and
bin/claude-code-web-setup's Edit/Write-gated hook) defines two functions:
-
install_gems: hashesGemfile.lock, checks for a receipt file attmp/claude-web-receipts/gems-<hash>, and if absent, runs a plainbundle installbefore touching the receipt. -
compile_extension: hashes the Rust extension's source files (*.rs/*.toml/*.lock/extconf.rbunderext/duckling), and if the corresponding receipt is absent, runsbundle exec rake compile.
This script is fully dependency-agnostic and needs no change for
async. install_gems doesn't special-case which gems are in
Gemfile.lock — it just runs bundle install generically, and its
cache-invalidation key is the lockfile's own content hash. Since adding
async ~> 2.41 to duckling.gemspec changed Gemfile.lock's contents
(hence its hash), any prior receipt for an older lockfile hash is
automatically invalidated on the next bin/test/hook invocation in a Claude
Code Web session, forcing a fresh bundle install that picks up async and
its transitive deps with zero special-casing required. compile_extension
is scoped only to the Rust extension's own source files and is unaffected
by a Ruby-only dependency change.
Ran bundle exec standardrb test/falcon_fiber_blocking_test.rb duckling.gemspec directly (the same tool bin/lint invokes via
bundle exec standardrb --fix on .rb files, and the same tool CI's
bundle exec rake runs as the standard task). No output — meaning zero
offenses. Both test/falcon_fiber_blocking_test.rb (as already committed
via issue #38's branch) and the gemspec's new
spec.add_development_dependency "async", "~> 2.41" line pass StandardRB
cleanly as currently written. bin/lint's Rust branch is irrelevant here
since neither of these files is a .rs file. A future implementation PR
touching only ext/duckling/src/*.rs (the likely site of the actual GVL
fix — e.g. wrapping the duckling::parse FFI call with Magnus's
without_gvl) would instead trip bin/lint's rustfmt +
cargo clippy --fix path, not StandardRB — worth knowing, but out of scope
for this Ruby-side test-and-CI-mechanics research.
Nothing. The test file and its async dev-dependency are already fully
CI-compatible as committed on this branch:
-
Gemfile.lockalready reflectsasync ~> 2.41correctly;bundle checkis clean afterbundle install. - The only native extension in the new dependency chain (
io-event) needs nothing beyond a C compiler +make, both already present onubuntu-latestand assumed by this repo's existing Rust-extension build. Its optionalio_uringbackend gracefully no-ops withoutliburing-dev, which this repo's CI correctly doesn't install (nor does it need to). -
ruby/setup-ruby's existingbundler-cache: truestep in.github/workflows/main.ymlhandles installingasyncand its transitive deps automatically — no new CI step, no newapt-get install, no matrix change required. -
bin/claude-web-deps.sh'sinstall_gemsis lockfile-hash-keyed and already picks up the new dependency with no code change. -
test/falcon_fiber_blocking_test.rband the gemspec's new dependency line both passstandardrb(bin/lint's Ruby-side tool) cleanly today.
The only thing standing between this branch and a green CI run on this test
is the actual GVL fix itself (issue #57's real scope) — once
Duckling.parse stops holding the GVL for the duration of its native call
(e.g. via Magnus's GVL-release mechanism around the FFI boundary), this test
should pass in CI exactly as it does — or doesn't — locally, with no
additional test-and-CI-mechanics surprises from the async dependency or
the CI matrix.
-
Gemfile.lock'sPLATFORMSlist doesn't includex86_64-linux(onlyrubyand this developer'sx86_64-darwin-24). This is pre-existing and currently harmless (see section 3), but if a future dependency ever does ship platform-specific binary variants, an explicitbundle lock --add-platform x86_64-linux(mirroring what CI actually runs on) would be the more robust fix rather than continuing to rely on bundler's implicit non-frozen auto-resolution. - The measured wall-clock numbers in this test (both the task's cited 0.278s/0.277s run and this research's own 0.1106s/0.1098s run) vary by roughly 2-3x between runs on presumably similar hardware. Section 1 notes a plausible cause (dev vs. release Cargo profile / machine load) but this wasn't independently isolated — not blocking for issue #57 (the test's pass/fail threshold is what matters, not the absolute duration), but worth knowing if a future PR wants to add stricter latency budgets.