Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
94 commits
Select commit Hold shift + click to select a range
f063d21
test: Zeitwerk-load the spec/support harness and gate :e2e specs
gillisd Jul 11, 2026
bce6d58
test: add Ready::PtyShell interactive-zsh driver for e2e specs
gillisd Jul 11, 2026
f868b7a
test: add Ready::Sandbox fixture (isolated build + attach + teardown)
gillisd Jul 11, 2026
9154894
test: e2e — stub aliases and dispatches through by-server in a live s…
gillisd Jul 11, 2026
b2a2bfd
ci: add spec:e2e task and a dedicated zsh-enabled e2e job
gillisd Jul 11, 2026
14f8234
bench: Ready::Bench::Marks — parse mark log into per-span ms
gillisd Jul 11, 2026
afac677
bench: Ready::Bench::Stats.median
gillisd Jul 11, 2026
99adf1c
bench: Ready::Bench::Report — render cold/hot waterfall + paired deltas
gillisd Jul 11, 2026
70050b0
lint: freeze PROMPT string constant in Ready::PtyShell
gillisd Jul 11, 2026
c69e952
docs: Phase C design — layered cold-vs-hot startup benchmark
gillisd Jul 12, 2026
9751453
docs: Phase C implementation plan (layered startup benchmark)
gillisd Jul 12, 2026
c23b7e9
bench: ready layer span model + min/median aggregator
gillisd Jul 12, 2026
aaad7db
bench: RUBYOPT ruby_up prelude + prof.zsh envelope/mark helpers
gillisd Jul 12, 2026
c5a1fcb
bench: ColdArm — mark-instrumented rbenv-shim + rubygems-stub copies
gillisd Jul 12, 2026
c59060d
bench: HotArm — render-based instrumented stub over the warm server
gillisd Jul 12, 2026
24fd6e9
bench: Runner + rake bench (interleaved cold/hot layer waterfall)
gillisd Jul 12, 2026
e03cc3c
test: e2e — benchmark guard (hot eliminates the boot layers, beats cold)
gillisd Jul 12, 2026
9eb6659
bench: HotArm — render by name (works for bin/ and exe/ binstubs alike)
gillisd Jul 12, 2026
60406f9
bench: ColdArm — --disable-gems so the rubygems layer is measured, no…
gillisd Jul 12, 2026
b0112cf
bench: ColdArm — correct the --disable-gems rationale comment
gillisd Jul 12, 2026
ccfd07f
fix: wire command_kit version so `ready --version`/`-V` print the ver…
gillisd Jul 12, 2026
1b31928
fix: don't load dev-only rake tooling when ready runs as an installed…
gillisd Jul 12, 2026
64ccaac
fix: export BY_SOCKET in readyinit so bare `by`/`ready_by` reaches th…
gillisd Jul 12, 2026
aea3a9c
fix: compile ready stubs atomically so a failed build can't poison a …
gillisd Jul 12, 2026
70eac25
simplify: rely on `ready clobber` for corrupt builds instead of atomi…
gillisd Jul 12, 2026
d4df01d
chore: ignore local bundle path, IDE config, and stray archive
gillisd Jul 12, 2026
25b7fdb
refactor: rebuild the bench harness on domain entities (PR #2 review)
gillisd Jul 12, 2026
e0e59e4
refactor: route lib path math through Pathname
gillisd Jul 12, 2026
bd29950
refactor: return value objects instead of arrays; drop endless methods
gillisd Jul 12, 2026
35fd3ed
fix: fail fast when rbenv is absent (system exception: true)
gillisd Jul 12, 2026
6f55fae
feat: readable bench report -- one statistic per column, preamble, le…
gillisd Jul 12, 2026
13a6361
feat: bench a real workload (ri TCPServer) and define the harness voc…
gillisd Jul 12, 2026
08f7ac7
feat: measure the hot path's shell layer (stub_call span)
gillisd Jul 12, 2026
a64dbb5
docs: note where a mark's own write cost lands (the span it opens)
gillisd Jul 12, 2026
7e5dbb8
refactor: one shell row for both arms (command_start mark)
gillisd Jul 12, 2026
1ec40f2
docs: restore HotArm comment line eaten by autocorrect
gillisd Jul 12, 2026
f4bb85c
docs: codify the owner's design rulings in CLAUDE.md
gillisd Jul 12, 2026
a83b9f6
docs: talk prep -- bootsnap analysis and layer attribution notes
gillisd Jul 12, 2026
a84733a
chore: track working docs and dev bootstrap; ship none of it in the gem
gillisd Jul 12, 2026
59b5e30
feat: bin/bench -- ergonomic CLI over the proven bench rake tasks
gillisd Jul 12, 2026
160a78c
fix: stop advertising the BENCH_* env path; explain --library properly
gillisd Jul 12, 2026
7c7624f
feat: bench takes a readyfile, runs every CLI given, and plots with y…
gillisd Jul 12, 2026
e49594c
refactor: type commands verbatim, separated by -- (drop --args)
gillisd Jul 12, 2026
104705e
fix: PtyShell force-kills a shell whose interactive child swallows exit
gillisd Jul 12, 2026
1f47fa3
feat: bin/bench --plot with stacked or youplot styles
gillisd Jul 12, 2026
8e701c2
feat: narrate bench progress to stderr; --plot prints only the plot
gillisd Jul 12, 2026
0e73e5c
ci: add a macOS e2e job to reproduce the process hang/leak
gillisd Jul 13, 2026
8e05e0a
ci: fix YAML -- block-scalar the bench step so its colon isn't a mapping
gillisd Jul 13, 2026
7da12be
ci: run CI on pushes to e2e-rspec while iterating on the macOS repro
gillisd Jul 13, 2026
1588970
ci(macos): fast, per-spec diagnostic probes to pin down the hang
gillisd Jul 13, 2026
0ba0556
ci(macos): test the pager hypothesis -- ri paging vs RI_PAGER=cat
gillisd Jul 13, 2026
68b23ac
ci(macos): set +e so all probes run; test ri(pager) vs irb vs ri(no-p…
gillisd Jul 13, 2026
10583ac
fix: drop </dev/null from the harness command (hangs the by client on…
gillisd Jul 13, 2026
f049043
fix: make the harness structurally unable to hang, and reap process g…
gillisd Jul 13, 2026
e00b241
ci(macos): trace breadcrumbs through build/run_hot to pinpoint the hang
gillisd Jul 13, 2026
c9725e3
ci(macos): trace cold_invocation too -- the hang is in the cold arm
gillisd Jul 13, 2026
ef3ec69
fix: PtyShell#close SIGKILLs the process group instead of graceful exit
gillisd Jul 13, 2026
0ac9825
fix: cold shim marks command_start via zsh (macOS bash 3.2 has no $EP…
gillisd Jul 13, 2026
16ab05b
ci(macos): make it a permanent regression guard; drop diagnostic scaf…
gillisd Jul 13, 2026
fa7254e
bench: run benched tools with no pager (PAGER/RI_PAGER/GIT_PAGER=cat)
gillisd Jul 13, 2026
bf51f4d
report/plot: lead with the verdict, delta as a column, label the fast…
gillisd Jul 13, 2026
053d60f
fix: TERM (not KILL) the process group on teardown/close; use the -SI…
gillisd Jul 13, 2026
4d02449
refactor: use IO#expect's timeout and Process.detach, not a bespoke w…
gillisd Jul 13, 2026
0ef5aa2
model: introduce Round; show warmup and measured rounds as distinct p…
gillisd Jul 13, 2026
8dd1225
refactor: simplify Executable resolution and anchor the bin-dir check
gillisd Jul 13, 2026
623d29a
fix: treat an empty readyfile as empty instead of crashing
gillisd Jul 13, 2026
20811fe
style: clear rubocop across lib; lint at the 3.4 gemspec floor
gillisd Jul 13, 2026
09fc0e8
test: satisfy rubocop-rspec cops without weakening assertions
gillisd Jul 13, 2026
dae835a
ci: put by-server on the base PATH for the ubuntu e2e job
gillisd Jul 13, 2026
fd37004
fix: let the benchmark's cold arm resolve its stub without rbenv
gillisd Jul 13, 2026
5883fa8
ci: also install zeitwerk on the base PATH for the ubuntu e2e job
gillisd Jul 13, 2026
2f7ed2b
bench: add a condensed slide summary of the cold-startup layers
gillisd Jul 13, 2026
cf82da8
bench: sum the slide summary from span floors, not medians
gillisd Jul 13, 2026
be539d7
bench: fail loudly on a tool that errors, and log all harness output
gillisd Jul 14, 2026
e20e4df
fix: disable the test/unit auto-runner in dispatched CLI stubs
gillisd Jul 14, 2026
18bd5e6
Revert "fix: disable the test/unit auto-runner in dispatched CLI stubs"
gillisd Jul 14, 2026
4c00b6d
bench: split the ruby vm boot out of the rubygems layer
gillisd Jul 14, 2026
ad67a28
bench: fix ExampleLength in the split spec; exclude log/ from rubocop
gillisd Jul 14, 2026
55d7537
bench: add the CLI startup benchmark report and a ronin readyfile
gillisd Jul 14, 2026
bb0c59f
bench: report + push-button script for real-command benchmarks
gillisd Jul 14, 2026
d577193
cli: 'ready compile' defaults to 'all', like make
gillisd Jul 14, 2026
521306f
Switch to Ansi for now
gillisd Jul 14, 2026
6d2d0eb
This is causing some bizarre bugs when no env present
gillisd Jul 14, 2026
fa7dd02
Do a more thorough clean
gillisd Jul 14, 2026
4cbc0f4
Add issues and demo lifecycle functions
gillisd Jul 14, 2026
f9c87de
Add another issue
gillisd Jul 14, 2026
a72f397
Bump version to 0.0.2
gillisd Jul 12, 2026
fd9dfc6
Fix conditional in logger
gillisd Jul 12, 2026
e35f780
Tidy up and comment
gillisd Jul 12, 2026
0b24c94
Touch doesn't work with Pathname
gillisd Jul 12, 2026
6176f5b
Add switch for eager vs non-eager loading of zwc file
gillisd Jul 14, 2026
574b118
Do everything possible to install offline
gillisd Jul 14, 2026
f388dc5
Add quirks note
gillisd Jul 14, 2026
e026432
Merge pull request #3 from gillisd/rubyconf-final
gillisd Jul 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 71 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ name: CI

on:
push:
branches: [main, master]
branches: [main, master, e2e-rspec]
pull_request:
branches: [main, master]
workflow_dispatch:

permissions:
contents: read
Expand All @@ -26,3 +27,72 @@ jobs:
run: bundle exec rake spec
- name: Run RuboCop
run: bundle exec rake rubocop

e2e:
runs-on: ubuntu-latest
strategy:
matrix:
ruby-version: ['3.4.7', '4.0.1']
steps:
- uses: actions/checkout@v4
- name: Install zsh
run: sudo apt-get update && sudo apt-get install -y zsh
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby-version }}
bundler-cache: true
# The benchmark deliberately runs two scrubbed, unbundled subprocesses that
# resolve gems the way a real (non-bundler) install does: `ready up` spawns
# by-server, and render_production_source does `require "ready"` (which
# needs zeitwerk). With bundler-cache's path install those gems live only in
# the bundle, so the scrubbed processes can't see them. Put ready's runtime
# deps on the base PATH at their locked versions -- where rbenv has them
# locally and on the macos-e2e job.
- name: Install ready's runtime deps on the base PATH
run: gem install by:1.1.0 zeitwerk:2.8.2
- name: Run E2E specs
run: bundle exec rake spec:e2e

# macOS is where the harness actually runs for users (zsh is the default
# shell, executables resolve through rbenv). This job reproduces the
# process-hang/leak reports on a real Mac and fails loudly if the harness
# leaves any by-server or sandbox process behind.
macos-e2e:
runs-on: macos-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Cache the rbenv-built Ruby (first run compiles it, later runs restore)
uses: actions/cache@v4
with:
path: ~/.rbenv/versions
key: rbenv-versions-4.0.1-${{ runner.os }}
- name: Install rbenv + Ruby 4.0.1 (matches a real user's setup)
run: |
brew install rbenv ruby-build coreutils
rbenv versions --bare | grep -qx 4.0.1 || rbenv install 4.0.1
rbenv global 4.0.1
echo "$HOME/.rbenv/shims" >> "$GITHUB_PATH"
- name: Bundle
run: |
gem install bundler
bundle install
# A hard gtimeout wraps each step so a regression hangs the job for
# seconds, not the 6h GitHub ceiling. The whole point of this job is that
# the harness must run to completion on macOS (default zsh, rbenv).
- name: Run the e2e suite
run: gtimeout -s KILL 300 bundle exec rake spec:e2e
- name: Run the real benchmark (what bin/bench runs)
run: gtimeout -s KILL 200 bundle exec rake bench BENCH_EXE=ri BENCH_ARGS=TCPServer BENCH_RUNS=2 BENCH_WARMUPS=1
- name: Fail if the harness leaked any process
if: always()
run: |
pgrep -fl by-server || echo "no by-server"
pgrep -fl ready-e2e || echo "no ready-e2e"
leaked=$(pgrep -f 'by-server|ready-e2e' | wc -l | tr -d ' ')
echo "leaked process count: $leaked"
if [ "$leaked" != "0" ]; then
echo "::error::harness leaked $leaked process(es)"
exit 1
fi
11 changes: 10 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
/.bundle/
/vendor/
/.idea/
/talk.tar.gz
# Local skill caches dropped by the assistant tooling; regenerated on reload.
/_claude/poodr/
/_claude/rspec3/
/_claude/zsh/
/.yardoc
/_yardoc/
/coverage/
Expand All @@ -9,4 +16,6 @@
Gemfile.lock
*.gem
.rspec_status
references
references
# benchmark run logs
/log/
52 changes: 50 additions & 2 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,23 @@ plugins:

AllCops:
NewCops: enable
TargetRubyVersion: 4.0
# Lint at the gem's supported floor (ready.gemspec's required_ruby_version),
# which CI also exercises -- not the 4.0.1 dev pin -- so accidental
# 4.0-only syntax is caught rather than shipped to 3.4 users.
TargetRubyVersion: 3.4
Exclude:
- bin/*
# Stock bundler scripts; bin/bench is ours and stays linted.
- bin/console
- bin/setup
- vendor/**/*
# Vendored read-only reference copy of an external gem; not this project's
# code (it is also gitignored, so CI never sees it).
- references/**/*
# Standalone runtime helper scripts -- a doc formatter and the rdoc `ri`
# patch -- with their own conventions, not the gem's authored library code.
- extra/**/*
# Gitignored scratch: benchmark run logs and throwaway report-parsing scripts.
- log/**/*
- lib/core_ext/**/*
- rakelib/project.rb
- rakelib/project_version.rb
Expand Down Expand Up @@ -128,6 +141,12 @@ Claude/MysteryRegex:
Style/MutableConstant:
EnforcedStyle: literals

# Data.define subclassing keeps the class body a real class body, so constants
# holding instances of the class (e.g. Span::TABLE) live where they belong;
# the block form would bind them lexically to the enclosing namespace.
Style/DataInheritance:
Enabled: false

# Shared test contexts legitimately define many helpers.
RSpec/MultipleMemoizedHelpers:
Max: 10
Expand All @@ -151,6 +170,35 @@ Style/Documentation:
Exclude:
- "spec/**/*"

# E2E specs share one expensive by-server across examples via before(:all) and
# hold it in an instance variable; that is intentional here.
RSpec/BeforeAfterAll:
Exclude:
- "spec/e2e/**/*"

RSpec/InstanceVariable:
Exclude:
- "spec/e2e/**/*"

# E2E examples are longer by nature (shell/server setup + several aggregated
# structural assertions per example).
RSpec/ExampleLength:
Exclude:
- "spec/e2e/**/*"

# The e2e and benchmark suites are grouped by test type, not class namespace,
# so their paths deliberately do not mirror the described constant.
RSpec/SpecFilePathFormat:
Exclude:
- "spec/e2e/**/*"
- "spec/bench/**/*"

# Ready::Bench::Report is a waterfall renderer whose job is to write to stdout;
# it lives under spec/support only so Zeitwerk autoloads it.
RSpec/Output:
Exclude:
- "spec/support/bench/**/*"

# Trailing commas in multiline literals and arguments.
Style/TrailingCommaInArrayLiteral:
EnforcedStyleForMultiline: comma
Expand Down
12 changes: 11 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ The project has two halves that meet at generated code:
`rakelib/ready.rake` is the orchestrator that drives half 1 to produce artifacts
that half 2 consumes.

It uses zeitwerk
## Commands

```bash
Expand Down Expand Up @@ -111,13 +112,22 @@ socket. Top-level `rake ready` = restart server + compile + clean.
(`lib/ready/foo_bar.rb` → `Ready::FooBar`). A spec eager-loads everything and
`rake zeitwerk:validate` checks naming — run it after adding/moving files.
- **Ruby 4.0.1.** The code leans on modern syntax (`case/in` pattern matching,
the `it` block param, endless methods). CI pins 4.0.1.
the `it` block param). **No endless methods** (`def x = y`) — always regular
`def`/`end` blocks. CI pins 4.0.1.
- **RuboCop is heavily customized** (see `.rubocop.yml`) via `rubocop-claude`
(AI guardrails). Match the house style: double quotes, **no** frozen-string
comment, trailing commas in multiline literals/args, dot-aligned multiline
method chains, pipeline/`.then`-chaining style, short blocks
(`Metrics/BlockLength` max 8), and every class carries an rdoc `##` comment
(`Style/Documentation` is on).
- **Design rulings (owner review, binding):** name the domain — real objects
over primitive hashes; never return an array/tuple (use a `Data.define`
value object whose readers carry type and unit, e.g. `PtyShell::Result`);
no abbreviations in names; polymorphism over boolean/type flags; symbols
over string keys; `Pathname` everywhere (never `File.expand_path` or
`File.join`); `system` calls pass `exception: true` unless the result is
explicitly checked; intermediate variables over nested work-doing calls;
multi-variant docs as tables/bullets, never paragraphs.
- **`references/command_kit.rb/` is a vendored, read-only reference copy** of an
external gem (its own git repo). It is not part of this project — don't edit it
or count it when reasoning about the codebase.
Expand Down
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ gem "rubocop-performance"
gem "rubocop-rake"
gem "rubocop-rspec"
gem "ruby-lsp", "~> 0.26.10"
gem "youplot"
91 changes: 91 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
@@ -1,9 +1,100 @@
# Everything below develops the ready gem itself (gem build/release, specs,
# rubocop, benchmarks) and depends on dev-only gems. None of it is needed to RUN
# ready: the runtime `ready`/`compile`/`clobber`/`ready:*` tasks live in
# rakelib/ready.rake, which rake auto-loads independently of this file. When
# ready runs as an installed gem the gemspec and dev gems are absent, so loading
# this file would crash `ready up|compile|clobber` (e.g. bundler/gem_tasks
# raising "Unable to determine name from existing gemspec"). Load the dev tasks
# only in a source checkout, detected by the gemspec's presence.
return unless (Pathname(__dir__) / "ready.gemspec").exist?

require "bundler/gem_tasks"

require "rspec/core/rake_task"

RSpec::Core::RakeTask.new(:spec)

namespace :spec do
desc "Run the end-to-end (:e2e) specs (needs zsh + by/by-server)"
task :e2e do
ok = system({ "READY_E2E" => "1" }, RbConfig.ruby, "-S", "rspec", "--tag", "e2e")
abort("e2e specs failed") unless ok
end
end

def bench_protocol
executable = ENV.fetch("BENCH_EXE", "ri")
# TCPServer is the default workload for the default tool only; any other
# executable runs bare unless BENCH_ARGS says otherwise.
default_arguments = executable == "ri" ? "TCPServer" : ""
Ready::Bench::Protocol.new(
executable_name: executable,
arguments: ENV.fetch("BENCH_ARGS", default_arguments).split,
preload_gems: bench_preload_gems,
rounds: Integer(ENV.fetch("BENCH_RUNS", "15")),
warmups: Integer(ENV.fetch("BENCH_WARMUPS", "3")),
)
end

# The hot server preloads the gems a readyfile declares (BENCH_READYFILE),
# falling back to rdoc, which ships the default tool. The build_dir is
# irrelevant here -- only gem names are read -- but Readyfile requires an
# existing directory, so the readyfile's own parent satisfies it.
def bench_preload_gems
readyfile_path = ENV.fetch("BENCH_READYFILE", nil)
return ["rdoc"] if readyfile_path.nil?

readyfile_path = Pathname(readyfile_path)
Ready::Readyfile.open(readyfile_path, build_dir: readyfile_path.expand_path.parent).gem_names
end

def bench_runner
require "ready"
require "zeitwerk"
Zeitwerk::Loader.new.tap do |loader|
loader.inflector.inflect("cli" => "CLI")
loader.push_dir(Pathname(__dir__) / "spec/support", namespace: Ready)
loader.setup
end
Ready::Bench::Runner.new(protocol: bench_protocol)
end

# Appends this run's headline numbers so a caller sequencing several
# benchmarks (bin/bench --plot) can chart them afterwards.
def export_bench_results(runner, results_path)
results = Ready::Bench::ResultsLog.new(results_path)
results.append(command: runner.invocation, arm: :cold,
full_milliseconds: runner.cold_summary.duration_of(:full))
results.append(command: runner.invocation, arm: :hot,
full_milliseconds: runner.hot_summary.duration_of(:full))
end

# Plot mode (BENCH_RESULTS set by bin/bench --plot) exports the headline
# numbers for the CLI to chart and stays silent on stdout, so the plot is the
# only output; otherwise the full waterfall report is the output. Either way
# the run narrates its progress to stderr.
def run_bench(verbose:)
runner = bench_runner.call
results_path = ENV.fetch("BENCH_RESULTS", nil)
if results_path
export_bench_results(runner, results_path)
else
runner.render(verbose:)
end
end

desc "Print the cold-vs-hot startup waterfall (needs zsh + by-server + rbenv); bin/bench is the front door"
task :bench do
run_bench(verbose: false)
end

namespace :bench do
desc "rake bench plus a legend table explaining every span row"
task :verbose do
run_bench(verbose: true)
end
end

require "rubocop/rake_task"
RuboCop::RakeTask.new

Expand Down
Loading
Loading