Skip to content

fix(sinatra): persist handler @ivars across before/route/after + MRI-identity oracle vs sinatra gem 4.2.1 - #157

Merged
tannevaled merged 1 commit into
mainfrom
sinatra-ivar-persistence-mri-oracle
Jul 5, 2026
Merged

tannevaled merged 1 commit into
mainfrom
sinatra-ivar-persistence-mri-oracle

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

Summary

The go-ruby-sinatra routing/dispatch core is already bound into rbgo (#104): the Sinatra::Base class-DSL (get/post/…), before/after, halt/redirect/status/content_type/headers/body/pass, not_found/error(code), set/enable/disable, params (query+captures+splat), inheritance and the Rack #call(env) adapter, with the Ruby do…end block re-entering the evaluator as a sinatra.Action — all at 100% coverage.

Driving a real Sinatra app through the rbgo binary and diffing byte-for-byte against the real sinatra gem 4.2.1 (MRI ruby 4.0.5) surfaced one binding bug and produced a lasting MRI-identity proof. This PR lands both.

1. Fix: instance variables lost across the filter chain

Real Sinatra runs a request's before filter(s), the route body and the after filter(s) against one instance, so an @ivar set in before (e.g. @user) is visible in the route and after blocks. The binding:

  • created a fresh SinatraCtx per action, and
  • SinatraCtx could not hold ivars at all — ivarTable returned nil for it, so @x = was silently dropped.

Result: before { @uid = "u42" } then get("/x"){ "uid=#{@uid}" } produced uid= in rbgo vs uid=u42 in the gem.

Fix: give SinatraCtx an ivar table (with a load-bearing lazy alloc — writing a nil map panics) and reuse one SinatraCtx per request, keyed by the library's per-request *sinatra.Context (one per dispatch) and reset around each #call so ivars persist within a request and do not leak across requests.

2. Proof: MRI-identity oracle through rbgo

TestSinatraGemOracle (scripts/conformance/webapp/) runs apps/sinatra_oracle.rb through rbgo and asserts the [status, headers, body] dump is byte-for-byte the response the sinatra gem 4.2.1 produced from the same app + request envs. Scenarios: :name capture, query param, before-filter ivar persistence, splat, optional param, redirect + Location, halt, custom error(500), custom not_found, after-filter header mutation. The golden vector is captured from the gem run and embedded, so the suite stays hermetic (no ruby/gem at CI time; runs under qemu on every 64-bit target).

webapp/doc.go refreshed — stage 3 now runs (was stale "gap: no sinatra binding").

Scoped to follow-up (go-ruby-sinatra LIBRARY, not the binding)

Excluded from the oracle diff and flagged upstream — the binding is a faithful pass-through and cannot invent these:

  • Content-Length — the gem sets it; go-ruby-rack does not finalize it (matches for non-empty bodies, absent on empty/redirect).
  • X-Cascade: pass on 404s — the gem adds it to every 404; go-ruby-sinatra does not.
  • Default 404 page — the gem returns its styled HTML page; go-ruby-sinatra's default not_found body is empty (a custom not_found block is byte-identical).

Session store and template rendering (→ go-ruby-erb) remain documented seams, unchanged here.

Verification

  • GOWORK=off go build ./... && go vet ./... && go test ./... green.
  • 100% coverage on the sinatra binding funcs (incl. new sinatraCtxFor) and the ivarTable *SinatraCtx arm.

🤖 Generated with Claude Code

…oracle

The routing/dispatch core of go-ruby-sinatra is already bound (#104). This
lands two things a byte-for-byte diff against the real sinatra gem 4.2.1
surfaced:

1. Fix instance-variable persistence in the block-action bridge. Real Sinatra
   runs a request's before filter(s), route body and after filter(s) against a
   single instance, so an @ivar set in `before` (e.g. @user) is visible in the
   route and after blocks. The binding created a fresh SinatraCtx per action and
   SinatraCtx could not hold ivars at all (ivarTable returned nil for it), so
   `before { @uid = ... }` was silently dropped by the time the route ran. Give
   SinatraCtx an ivar table and reuse one SinatraCtx per request (keyed by the
   library's per-request *sinatra.Context, reset around each dispatch) so ivars
   persist within a request and do not leak across requests.

2. Add TestSinatraGemOracle: a run-conformance proof THROUGH rbgo that a real
   Sinatra app (apps/sinatra_oracle.rb: :name capture, query param, before-ivar,
   splat, optional param, redirect+Location, halt, custom error(code), custom
   not_found, after-filter) answers byte-for-byte identically to the sinatra gem
   4.2.1. The golden vector is captured from that gem run and embedded so the
   suite stays hermetic (no ruby/gem at CI time).

Refresh the stale webapp/doc.go (stage 3 now runs, not a gap).

Two response headers are excluded from the oracle diff and flagged as
go-ruby-sinatra LIBRARY follow-ups, not binding concerns: Content-Length
(go-ruby-rack does not finalize it) and X-Cascade: pass on 404s. The default
404 HTML page is likewise a library default (empty body) vs the gem's styled
page. 100% coverage on the binding.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit e9a9c67 into main Jul 5, 2026
9 checks passed
@tannevaled
tannevaled deleted the sinatra-ivar-persistence-mri-oracle branch July 5, 2026 19:14
tannevaled added a commit that referenced this pull request Jul 6, 2026
Close the templating half of the Sinatra binding (PR #157 landed
routing/dispatch and deferred template rendering). A route/filter action
runs against a SinatraCtx (self); the new `erb` helper compiles a template
through the already-bound go-ruby-erb compiler and evaluates the resulting
Ruby source in the handler's binding — self = the SinatraCtx, so the
action's @Ivars and the request helpers (params, request, …) resolve, plus
any `locals:` bound as local variables — capturing the _erbout buffer and
returning the rendered String, which becomes the Rack response body.

Render paths:
  * inline-String template (needs no filesystem)
  * :symbol template naming views/<sym>.erb, read via the File binding with
    the views dir taken from settings :views (default "./views")

Trim mode "<>" and eoutvar "_erbout" match Tilt::ERBTemplate's defaults.
<%= %> interpolates unescaped, <% %> is control flow. The eval-in-binding
uses the front-end (parser + CompileWithLocals), so it is split
open/closed: a closed-world binary raises NotImplementedError for `erb`,
exactly as Binding#eval / Kernel#eval-on-source already do.

Proof (through the rbgo binary, byte-for-byte vs sinatra gem 4.2.1):
apps/sinatra_erb_oracle.rb + TestSinatraErbGemOracle exercise inline and
:symbol/file templates, param + @ivar interpolation, control flow,
positional and options[:locals] locals, and trim behaviour — all rendered
identically to the reference gem. 100% coverage on the new binding code.

Flagged follow-ups (not binding gaps):
  * Sinatra 4.x renders :erb via Tilt::ErubiTemplate (erubi); go-ruby-erb
    mirrors MRI's ERB::Compiler. With trim "<>" the two agree byte-for-byte
    for single-line and idiomatic multiline templates (a code-only <% %>
    alone on its line); they diverge only for the unusual line that both
    opens a code block and ends with an expression tag with trailing
    content. Full erubi trim-parity is a go-ruby-erb library follow-up.
  * layouts / partials
  * :erb escape_html
  * the Sinatra session store (also deferred by PR #157)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
tannevaled added a commit that referenced this pull request Jul 6, 2026
…r) (#169)

* feat(sinatra): cookie session store (enable :sessions / session helper)

Implement the Sinatra session seam PR #157 deferred: `enable :sessions`
plus a working `session` helper backed by a signed cookie, matching MRI
Sinatra's Rack::Session::Cookie semantics.

On dispatch the session is loaded from the request's "rack.session" cookie
(base64(Marshal.dump)--HMAC-SHA1(session_secret)); a missing, malformed or
tampered cookie yields a fresh empty session. The `session` helper returns
the live Hash so session[:k]=v and session.clear persist, and after the
handler runs the (possibly mutated) session is committed back into a signed,
HttpOnly, path=/ Set-Cookie. `set :session_secret, "…"` sets the key; without
one a per-VM random default is used (like MRI's warning path).

Serialization reuses the bound go-ruby-marshal engine, so the payload is
byte-identical to MRI's default Base64::Marshal coder; base64 + HMAC-SHA1
match Rack's, making the cookie byte-exact to Rack::Session::Cookie given the
same secret (proven by TestSinatraSessionCookieByteExact). The through-binary
proof is the functional round-trip + tamper rejection (webapp stage 5 and the
internal round-trip/tamper/clear tests), which an MRI Sinatra app reproduces
observably.

100% coverage on the new binding code; go build/vet/test + conformance green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(conformance): add stage 5 (Sinatra cookie session) to the webapp map

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant