From 81a4085e717542f2160415dabaef928d8d0f44a1 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Tue, 7 Jul 2026 00:28:25 +0200 Subject: [PATCH] Bind pure-Go webrick into rbgo (require "webrick") MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bind github.com/go-ruby-webrick/webrick — the pure-Go (CGO=0) port of Ruby's WEBrick HTTP server core — into rbgo so `require "webrick"` works with no .rb file. The library owns the deterministic request parse, response build, status table and longest-prefix mount dispatch and does no socket I/O; this shell maps that surface onto Ruby classes: - WEBrick::HTTPServer.new(config) + #mount_proc { |req, res| … } / #mount (AbstractServlet subclass or instance) / #unmount / #service / #config (#start/#shutdown/#stop are the host accept-loop seam, no-ops here). - WEBrick::HTTPRequest.new + #parse(str) and the parsed accessors (request_method / path / query / headers / keep_alive? / body / each …). - WEBrick::HTTPResponse.new with the status/header/body accessors, #set_redirect, #set_error and #to_s (the wire bytes). - WEBrick::HTTPServlet::AbstractServlet base class (do_ contract). - WEBrick::HTTPStatus: reason_phrase, the range predicates, and the Status exception family (each named leaf carrying its code). The request-handler seam — a mount_proc block or a servlet's do_ method — is the injectable Ruby code the engine leaves open. Because the core does no I/O and starts no goroutines, #service is driven on the VM goroutine under the GVL and the handler runs inline (vm.callBlock / vm.send), so nothing leaks; a handler that raises a WEBrick::HTTPStatus is recovered and rendered as the matching error response, exactly as WEBrick::HTTPServer#run rescues HTTPStatus::Status. Tests drive requests through the server object in-process (build a HTTPRequest, dispatch via #service, assert the HTTPResponse) with no listening socket. 100% coverage of the new files; -race clean; green on all six 64-bit arches. Co-Authored-By: Claude Opus 4.8 --- go.mod | 1 + go.sum | 2 + internal/vm/builtins.go | 1 + internal/vm/object_model.go | 6 + internal/vm/require.go | 1 + internal/vm/webrick.go | 422 +++++++++++++++++++++++++++ internal/vm/webrick_bind.go | 365 +++++++++++++++++++++++ internal/vm/webrick_internal_test.go | 375 ++++++++++++++++++++++++ 8 files changed, 1173 insertions(+) create mode 100644 internal/vm/webrick.go create mode 100644 internal/vm/webrick_bind.go create mode 100644 internal/vm/webrick_internal_test.go diff --git a/go.mod b/go.mod index f56c9fd7..d1a2247d 100644 --- a/go.mod +++ b/go.mod @@ -147,6 +147,7 @@ require ( github.com/go-ruby-uri/uri v0.0.0-20260629113958-59633d1b0deb github.com/go-ruby-warden/warden v0.0.0-20260706120446-d887742539ef github.com/go-ruby-webauthn/webauthn v0.0.0-20260704120708-35595b0ac27b + github.com/go-ruby-webrick/webrick v0.0.0-20260630133907-a1380ee7733b github.com/go-ruby-xslt/xslt v0.0.0-20260702171958-146eaf3f0176 github.com/go-ruby-yaml/yaml v0.0.0-20260629093916-8035038027bd github.com/go-ruby-zeitwerk/zeitwerk v0.0.0-20260706163820-cda238c0e98c diff --git a/go.sum b/go.sum index a357f88a..c8cddb19 100644 --- a/go.sum +++ b/go.sum @@ -456,6 +456,8 @@ github.com/go-ruby-warden/warden v0.0.0-20260706120446-d887742539ef h1:JLPi8PmkO github.com/go-ruby-warden/warden v0.0.0-20260706120446-d887742539ef/go.mod h1:oI3L9dywsrSXJqLDuJGIYuHUHRCxUS7BU8PBZGXpx4A= github.com/go-ruby-webauthn/webauthn v0.0.0-20260704120708-35595b0ac27b h1:ZwN0q9xq9HUGCskWGz8rmL+v6MaHy+AM99oictlPlBs= github.com/go-ruby-webauthn/webauthn v0.0.0-20260704120708-35595b0ac27b/go.mod h1:tLv8PWrPxrOQrJWMFTfRi6wfZpSjZtmfxHKgGwUriR8= +github.com/go-ruby-webrick/webrick v0.0.0-20260630133907-a1380ee7733b h1:T7H6p2VhpU/05o/XSlsCT3YfMznMhcuShu8iDyDVeqU= +github.com/go-ruby-webrick/webrick v0.0.0-20260630133907-a1380ee7733b/go.mod h1:0DF9Jl9o+cvEvPCzCJ95nHIlF7qz8yzuQIyfMm8FUlM= github.com/go-ruby-xslt/xslt v0.0.0-20260702171958-146eaf3f0176 h1:iHTCF/Eu0/lwOEank8pLKqoW5j9MI2ERXErrvqsZZ+4= github.com/go-ruby-xslt/xslt v0.0.0-20260702171958-146eaf3f0176/go.mod h1:1C3Bx0pQIsqLqC2SnqV7pJnGuLJsgYmar2NrD+CmVXw= github.com/go-ruby-yaml/yaml v0.0.0-20260629093916-8035038027bd h1:4HGjgCdTIuIEM+iZXJ9hJJzt0IBMtxPqdJq72mr0Y64= diff --git a/internal/vm/builtins.go b/internal/vm/builtins.go index 6351c32c..2e48ad1d 100644 --- a/internal/vm/builtins.go +++ b/internal/vm/builtins.go @@ -486,6 +486,7 @@ func (vm *VM) bootstrap() { vm.registerRSpec() // RSpec matcher + expect surface (require "rspec"), backed by go-ruby-rspec; needs Exception for ExpectationNotMetError vm.registerRuboCop() // RuboCop::Runner#inspect/autocorrect + Cop::Offense (require "rubocop"), backed by go-ruby-rubocop over go-ruby-parser; needs StandardError for RuboCop::Error vm.registerRack() // Rack::Request/Response/Utils (require "rack" / "rack/utils"), backed by go-ruby-rack; deterministic env/query/escape, no socket + vm.registerWEBrick() // WEBrick::HTTPServer/HTTPRequest/HTTPResponse/HTTPServlet/HTTPStatus (require "webrick"), backed by go-ruby-webrick; deterministic parse/build/mount-dispatch, no socket; the mount_proc block / servlet do_* method is the rbgo seam run inline under the GVL; needs StandardError (WEBrick::HTTPStatus family) vm.registerGrape() // Grape::Router/Validator/Formatter (require "grape"), backed by go-ruby-grape; endpoint-block exec + Rack env are host seams; needs StandardError for Grape::Exceptions vm.registerSinatra() // Sinatra::Base class-DSL + #call Rack adapter (require "sinatra/base"), backed by go-ruby-sinatra over go-ruby-rack; route/filter block eval is the rbgo seam; needs Rack + StandardError (Sinatra::NotFound), so registered after registerRack vm.registerRoda() // Roda class routing-tree DSL (route do |r| … end) + #call Rack adapter (require "roda"), backed by go-ruby-roda over go-ruby-rack; the route/matcher block eval is the rbgo seam, the routing tree is the library; needs Rack + StandardError (Roda::RodaError) diff --git a/internal/vm/object_model.go b/internal/vm/object_model.go index 8b434a8a..917c665e 100644 --- a/internal/vm/object_model.go +++ b/internal/vm/object_model.go @@ -1336,6 +1336,12 @@ func (vm *VM) classOf(v object.Value) *RClass { return vm.consts["Puma::Configuration"].(*RClass) case *PumaDSL: return vm.consts["Puma::DSL"].(*RClass) + case *WEBrickServer: + return vm.consts["WEBrick::HTTPServer"].(*RClass) + case *WEBrickRequest: + return vm.consts["WEBrick::HTTPRequest"].(*RClass) + case *WEBrickResponse: + return vm.consts["WEBrick::HTTPResponse"].(*RClass) case *WardenManager: return x.cls case *WardenProxy: diff --git a/internal/vm/require.go b/internal/vm/require.go index 281b7a1d..a882c874 100644 --- a/internal/vm/require.go +++ b/internal/vm/require.go @@ -72,6 +72,7 @@ var providedFeatures = map[string]bool{ "rubocop": true, "grape": true, "rack": true, "rack/utils": true, + "webrick": true, "sinatra": true, "sinatra/base": true, "active_record": true, "activerecord": true, "active_model": true, "activemodel": true, diff --git a/internal/vm/webrick.go b/internal/vm/webrick.go new file mode 100644 index 00000000..2d51e3f7 --- /dev/null +++ b/internal/vm/webrick.go @@ -0,0 +1,422 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + webrick "github.com/go-ruby-webrick/webrick" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// registerWEBrick installs the WEBrick module (require "webrick"): the pure-Go +// (CGO=0) port of Ruby's WEBrick HTTP server core, reimplemented by +// github.com/go-ruby-webrick/webrick on top of go-ruby-net-http. The library +// owns the deterministic request-parse / response-build / status-table / mount +// dispatch; it does no socket I/O itself (the TCP accept loop is a host seam). +// This file (with webrick_bind.go) is the thin shell mapping that surface onto +// rbgo classes: +// +// WEBrick::HTTPServer.new(Port: 80) — the mount registry + #service dispatch +// #mount_proc("/p") { |req, res| … } — a proc mounted at a path +// #mount("/p", servlet) — an AbstractServlet subclass / instance +// #service(req, res) — route + run the handler, fill res +// WEBrick::HTTPRequest.new; #parse(str) — parse a raw request byte string +// WEBrick::HTTPResponse.new — status / headers / body / #to_s bytes +// WEBrick::HTTPServlet::AbstractServlet — the do_ servlet base class +// WEBrick::HTTPStatus — reason_phrase, the category predicates +// and the Status exception family +// +// The request-handler seam — a mount_proc block or a servlet's do_ +// method — is the injectable Ruby code the deterministic engine leaves open. +// Because rbgo drives #service on the VM goroutine while holding the GVL, the +// handler runs INLINE (vm.callBlock / vm.send) with no extra goroutine and no +// listener, so there is nothing to leak; a Ruby handler that raises a +// WEBrick::HTTPStatus is recovered and turned into the matching error response, +// exactly like WEBrick::HTTPServer#run rescues HTTPStatus::Status (see +// webrick_bind.go). +func (vm *VM) registerWEBrick() { + mod := newClass("WEBrick", nil) + mod.isModule = true + vm.consts["WEBrick"] = mod + + // WEBrick::VERSION — the WEBrick release this port mirrors. + mod.consts["VERSION"] = object.NewString(webrick.VERSION) + + mk := func(name string, super *RClass) *RClass { + full := "WEBrick::" + name + cls := newClass(full, super) + mod.consts[name] = cls + vm.consts[full] = cls + return cls + } + + vm.registerWEBrickServer(mk("HTTPServer", vm.cObject)) + vm.registerWEBrickRequest(mk("HTTPRequest", vm.cObject)) + vm.registerWEBrickResponse(mk("HTTPResponse", vm.cObject)) + vm.registerWEBrickServlet(mod) + vm.registerWEBrickStatus(mod) +} + +// webrickSMethod installs a class ("singleton") method on cls. +func webrickSMethod(cls *RClass, name string, fn NativeFn) { + cls.smethods[name] = &Method{name: name, owner: cls, native: fn} +} + +// registerWEBrickServer installs WEBrick::HTTPServer: HTTPServer.new(config = {}) +// and the mount/dispatch surface. The config Hash honours :Port, :ServerName and +// :ServerSoftware (the deterministic keys the codec reads); the networking keys +// (:BindAddress, SSL, the callbacks) are host seams and are ignored. #start / +// #shutdown / #stop are the host accept-loop seam and are no-ops here — a real +// rbgo host drives #service from its own accept loop. +func (vm *VM) registerWEBrickServer(cls *RClass) { + webrickSMethod(cls, "new", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + cfg := webrickConfig(webrickFirstOpt(args)) + return &WEBrickServer{srv: webrick.NewHTTPServer(cfg), cfg: cfg} + }) + + self := func(v object.Value) *WEBrickServer { return v.(*WEBrickServer) } + d := func(name string, fn NativeFn) { cls.define(name, fn) } + + // #mount_proc(dir) { |req, res| … } — mounts the captured block at dir, run + // inline as the request handler (WEBrick::HTTPServer#mount_proc). + d("mount_proc", func(vm *VM, v object.Value, args []object.Value, blk *Proc) object.Value { + if len(args) == 0 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1)") + } + if blk == nil { + raise("ArgumentError", "HTTPServer#mount_proc requires a block") + } + self(v).srv.MountProc(webrickStr(args[0]), vm.webrickProcHandler(blk)) + return object.NilV + }) + + // #mount(dir, servlet, *options) — mounts an AbstractServlet subclass (a class, + // instantiated per request with the options) or a servlet instance at dir; its + // do_ methods handle requests (WEBrick::HTTPServer#mount). + d("mount", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) < 2 { + raise("ArgumentError", "wrong number of arguments (given %d, expected 2+)", len(args)) + } + self(v).srv.Mount(webrickStr(args[0]), vm.webrickServlet(args[1], args[2:])) + return object.NilV + }) + + unmount := func(_ *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) == 0 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1)") + } + self(v).srv.Unmount(webrickStr(args[0])) + return object.NilV + } + d("unmount", unmount) + d("umount", unmount) + + // #service(req, res) — route req to its mounted handler and fill res, wiring + // the request context first (WEBrick::HTTPServer#service, driven by #run). A + // handler that raises a WEBrick::HTTPStatus is rendered into the error + // response; an unmatched path yields the default 404 page. + d("service", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) < 2 { + raise("ArgumentError", "wrong number of arguments (given %d, expected 2)", len(args)) + } + vm.webrickService(self(v), webrickReq(args[0]), webrickRes(args[1])) + return args[1] + }) + + // #config — the frozen config Hash the server was built with. + d("config", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return webrickConfigHash(self(v).cfg) + }) + + // #start / #shutdown / #stop — the host accept-loop seam; no-ops here (there + // is no listener), returning nil like a clean shutdown. + noop := func(_ *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { return object.NilV } + d("start", noop) + d("run", noop) + d("shutdown", noop) + d("stop", noop) +} + +// registerWEBrickRequest installs WEBrick::HTTPRequest: HTTPRequest.new(config = +// nil) builds an unparsed request, #parse(str) parses a raw request byte string +// into it, and the accessors expose the parsed request the way a WEBrick servlet +// reads it (request_method / path / query / cookies-free subset / keep_alive?). +func (vm *VM) registerWEBrickRequest(cls *RClass) { + webrickSMethod(cls, "new", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + return &WEBrickRequest{cfg: webrickConfig(webrickFirstOpt(args))} + }) + + self := func(v object.Value) *WEBrickRequest { return v.(*WEBrickRequest) } + d := func(name string, fn NativeFn) { cls.define(name, fn) } + + // #parse(str) — parse the complete request byte stream (String) into self, + // returning self; a malformed request raises the matching WEBrick::HTTPStatus. + d("parse", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) == 0 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1)") + } + r := self(v) + req, st := webrick.ParseRequest([]byte(webrickStr(args[0])), r.cfg) + if st != nil { + webrickRaiseStatus(st) + } + r.req = req + return v + }) + + req := func(v object.Value) *webrick.Request { + r := self(v) + if r.req == nil { + raise("RuntimeError", "WEBrick::HTTPRequest has not been parsed yet") + } + return r.req + } + str := func(name string, fn func(*webrick.Request) string) { + d(name, func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(fn(req(v))) + }) + } + str("request_method", func(r *webrick.Request) string { return r.RequestMethod }) + str("unparsed_uri", func(r *webrick.Request) string { return r.UnparsedURI }) + str("path", func(r *webrick.Request) string { return r.Path }) + str("script_name", func(r *webrick.Request) string { return r.ScriptName }) + str("path_info", func(r *webrick.Request) string { return r.PathInfo }) + str("query_string", func(r *webrick.Request) string { return r.QueryString }) + str("http_version", func(r *webrick.Request) string { return r.HTTPVersion.String() }) + str("host", func(r *webrick.Request) string { return r.Host() }) + str("content_type", func(r *webrick.Request) string { return r.ContentType() }) + str("body", func(r *webrick.Request) string { return string(r.Body) }) + + d("port", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.IntValue(int64(req(v).Port())) + }) + d("keep_alive?", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(req(v).KeepAlive()) + }) + d("content_length", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + n, _ := req(v).ContentLength() + return object.IntValue(int64(n)) + }) + // #[](name) — a request header value, nil when absent (HTTPRequest#[]). + d("[]", func(_ *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) == 0 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1)") + } + if s, ok := req(v).Header(webrickStr(args[0])); ok { + return object.NewString(s) + } + return object.NilV + }) + // #query — the parsed query as a Hash of first values (HTTPRequest#query). + d("query", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + q := req(v).Query() + h := object.NewHash() + for _, k := range q.Order { + val, _ := q.Get(k) + h.Set(object.NewString(k), object.NewString(val)) + } + return h + }) + // #each { |name, value| … } — iterate the request headers (HTTPRequest#each). + d("each", func(vm *VM, v object.Value, _ []object.Value, blk *Proc) object.Value { + if blk == nil { + raise("LocalJumpError", "no block given (yield)") + } + req(v).EachHeader(func(name, value string) { + vm.callBlock(blk, []object.Value{object.NewString(name), object.NewString(value)}) + }) + return v + }) +} + +// registerWEBrickResponse installs WEBrick::HTTPResponse: HTTPResponse.new(config +// = nil) plus the status / header / body accessors a servlet fills in, and #to_s +// which runs setup_header + send_header + send_body and returns the wire bytes. +func (vm *VM) registerWEBrickResponse(cls *RClass) { + webrickSMethod(cls, "new", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + cfg := webrickConfig(webrickFirstOpt(args)) + return &WEBrickResponse{res: webrick.NewResponse(cfg)} + }) + + self := func(v object.Value) *webrick.Response { return v.(*WEBrickResponse).res } + d := func(name string, fn NativeFn) { cls.define(name, fn) } + + // #request=(req) — wire the request method / version / host into the response + // (the assignments HTTPServer#run makes before servicing). + d("request=", func(_ *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) == 0 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1)") + } + self(v).SetRequest(webrickReq(args[0]).req) + return args[0] + }) + + d("status", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.IntValue(int64(self(v).Status)) + }) + d("status=", func(_ *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + self(v).SetStatus(webrickInt(webrickArg(args))) + return args[0] + }) + d("reason_phrase", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(self(v).ReasonPhrase) + }) + d("status_line", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(self(v).StatusLine()) + }) + d("[]", func(_ *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) == 0 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1)") + } + if s, ok := self(v).Get(webrickStr(args[0])); ok { + return object.NewString(s) + } + return object.NilV + }) + d("[]=", func(_ *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) < 2 { + raise("ArgumentError", "wrong number of arguments (given %d, expected 2)", len(args)) + } + self(v).Set(webrickStr(args[0]), webrickStr(args[1])) + return args[1] + }) + d("content_type", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + if s, ok := self(v).Get("content-type"); ok { + return object.NewString(s) + } + return object.NilV + }) + d("content_type=", func(_ *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + self(v).SetContentType(webrickStr(webrickArg(args))) + return args[0] + }) + d("content_length=", func(_ *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + self(v).SetContentLength(webrickInt(webrickArg(args))) + return args[0] + }) + d("body", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(string(self(v).Body)) + }) + d("body=", func(_ *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + self(v).Body = []byte(webrickStr(webrickArg(args))) + return args[0] + }) + d("chunked?", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(self(v).Chunked()) + }) + d("chunked=", func(_ *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + self(v).SetChunked(webrickArg(args).Truthy()) + return args[0] + }) + d("keep_alive?", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(self(v).KeepAlive()) + }) + // #set_redirect(status, url) — set the redirect body / Location / status from a + // WEBrick::HTTPStatus redirect class (HTTPResponse#set_redirect; deterministic, + // it does not raise here — the response is left ready to send). + d("set_redirect", func(_ *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) < 2 { + raise("ArgumentError", "wrong number of arguments (given %d, expected 2)", len(args)) + } + code := vm.webrickStatusCode(args[0]) + res := self(v) + res.SetStatus(code) + res.SetRedirect(webrick.NewStatus(code, ""), webrickStr(args[1])) + return object.NilV + }) + // #set_error(err) — render err (a WEBrick::HTTPStatus, else 500) as the default + // error page (HTTPResponse#set_error). + d("set_error", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + self(v).SetError(vm.webrickErrorStatus(webrickArg(args))) + return object.NilV + }) + // #to_s — the response wire bytes (setup_header + send_header + send_body). + d("to_s", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + b, err := self(v).Bytes() + if err != nil { + raise("WEBrick::HTTPResponse::InvalidHeader", "%s", err.Error()) + } + return object.NewString(string(b)) + }) +} + +// registerWEBrickServlet installs WEBrick::HTTPServlet and its AbstractServlet +// base class. A servlet is written as a subclass defining do_ methods and +// mounted with HTTPServer#mount; the do_ dispatch (HEAD -> GET, the +// OPTIONS Allow list, MethodNotAllowed for an unhandled verb) is the library's, +// run over the subclass's methods via the seam in webrick_bind.go. +func (vm *VM) registerWEBrickServlet(mod *RClass) { + hs := newClass("WEBrick::HTTPServlet", nil) + hs.isModule = true + mod.consts["HTTPServlet"] = hs + vm.consts["WEBrick::HTTPServlet"] = hs + + abs := newClass("WEBrick::HTTPServlet::AbstractServlet", vm.cObject) + hs.consts["AbstractServlet"] = abs + vm.consts["WEBrick::HTTPServlet::AbstractServlet"] = abs +} + +// registerWEBrickStatus installs WEBrick::HTTPStatus: the reason_phrase lookup, +// the category predicates (info? / success? / … / error?), and the Status +// exception family (Status < StandardError, the category classes, and the named +// leaf classes servlets raise, each carrying its code). A raised status is +// recovered in webrick_bind.go and rendered as the matching error response. +func (vm *VM) registerWEBrickStatus(mod *RClass) { + hs := newClass("WEBrick::HTTPStatus", nil) + hs.isModule = true + mod.consts["HTTPStatus"] = hs + vm.consts["WEBrick::HTTPStatus"] = hs + + // Module functions: reason_phrase(code) and the range predicates. + sm := func(name string, fn NativeFn) { hs.smethods[name] = &Method{name: name, owner: hs, native: fn} } + sm("reason_phrase", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + return object.NewString(webrick.ReasonPhrase(webrickInt(webrickArg(args)))) + }) + pred := func(name string, fn func(int) bool) { + sm(name, func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + return object.Bool(fn(webrickInt(webrickArg(args)))) + }) + } + pred("info?", webrick.IsInfo) + pred("success?", webrick.IsSuccess) + pred("redirect?", webrick.IsRedirect) + pred("error?", webrick.IsError) + pred("client_error?", webrick.IsClientError) + pred("server_error?", webrick.IsServerError) + + std := vm.consts["StandardError"].(*RClass) + mk := func(name string, super *RClass) *RClass { + cls := newClass("WEBrick::HTTPStatus::"+name, super) + hs.consts[name] = cls + vm.consts["WEBrick::HTTPStatus::"+name] = cls + return cls + } + + // The exception hierarchy: Status < StandardError, the five category classes, + // then the named leaf classes (each < its category) that carry a code. + status := mk("Status", std) + cats := map[string]*RClass{ + "Info": mk("Info", status), + "Success": mk("Success", status), + "Redirect": mk("Redirect", status), + "Error": mk("Error", status), + } + cats["ClientError"] = mk("ClientError", cats["Error"]) + cats["ServerError"] = mk("ServerError", cats["Error"]) + for _, def := range webrickStatusDefs { + mk(def.Name, cats[def.Cat]) + } + + // Status#code / #to_i / #reason_phrase read the code stamped on the raised + // class, resolved through the class hierarchy so a subclass still answers. + code := func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.IntValue(int64(vm.webrickCodeOf(v))) + } + status.define("code", code) + status.define("to_i", code) + status.define("reason_phrase", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(webrick.ReasonPhrase(vm.webrickCodeOf(v))) + }) +} diff --git a/internal/vm/webrick_bind.go b/internal/vm/webrick_bind.go new file mode 100644 index 00000000..429fc290 --- /dev/null +++ b/internal/vm/webrick_bind.go @@ -0,0 +1,365 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "errors" + + webrick "github.com/go-ruby-webrick/webrick" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// This file is the request-handler seam between rbgo's Ruby object graph and the +// interpreter-independent github.com/go-ruby-webrick/webrick core. The library +// owns the deterministic request parse, the response build, the status table and +// the longest-prefix mount dispatch; rbgo supplies the two things the engine +// leaves injectable — a mount_proc block and a servlet's do_ methods — +// and converts values at the boundary (a Ruby request/response wrapper each way). +// +// Threading model: unlike a threaded server (puma), the webrick core does no +// socket I/O and starts no goroutines — HTTPServer#service is driven on the VM +// goroutine while it holds the GVL, so the handler runs INLINE (vm.callBlock / +// vm.send) with no extra goroutine to leak and no GVL juggling. A handler that +// raises a WEBrick::HTTPStatus unwinds as a Go panic through the library's Go +// dispatch back to webrickService, which recovers it and renders the matching +// error response, exactly as WEBrick::HTTPServer#run rescues HTTPStatus::Status. + +// WEBrickServer is the Ruby wrapper around a webrick.HTTPServer (the mount +// registry + #service dispatch) plus the config it was built with. +type WEBrickServer struct { + srv *webrick.HTTPServer + cfg *webrick.Config +} + +func (s *WEBrickServer) ToS() string { return "#" } +func (s *WEBrickServer) Inspect() string { return "#" } +func (s *WEBrickServer) Truthy() bool { return true } + +// WEBrickRequest is the Ruby wrapper around a webrick.Request. req is nil until +// #parse runs (or the request originates from the service seam); cfg is the +// config #parse parses against. +type WEBrickRequest struct { + req *webrick.Request + cfg *webrick.Config +} + +func (r *WEBrickRequest) ToS() string { return "#" } +func (r *WEBrickRequest) Inspect() string { return "#" } +func (r *WEBrickRequest) Truthy() bool { return true } + +// WEBrickResponse is the Ruby wrapper around a webrick.Response — the mutable +// status/headers/body a servlet fills in, whose #to_s yields the wire bytes. +type WEBrickResponse struct { + res *webrick.Response +} + +func (r *WEBrickResponse) ToS() string { return "#" } +func (r *WEBrickResponse) Inspect() string { return "#" } +func (r *WEBrickResponse) Truthy() bool { return true } + +// webrickMethods is the fixed set of HTTP methods a servlet may handle via a +// do_ method, in the order they are probed when wiring a servlet. +var webrickMethods = []string{"GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"} + +// webrickProcHandler adapts a mount_proc Ruby block into a webrick.HandlerFunc: +// the library calls it (on the VM goroutine, inline) with the per-request Go +// Request/Response, which are wrapped as Ruby WEBrick::HTTPRequest / +// HTTPResponse (thin pointer holders, so the block's mutations land on the same +// response) and yielded to the block. The block's return value is ignored — a +// WEBrick handler communicates through res — so the handler reports nil. +func (vm *VM) webrickProcHandler(blk *Proc) webrick.HandlerFunc { + return func(req *webrick.Request, res *webrick.Response) *webrick.Status { + vm.callBlock(blk, []object.Value{&WEBrickRequest{req: req}, &WEBrickResponse{res: res}}) + return nil + } +} + +// webrickRubyServlet is the webrick.Servlet backing HTTPServer#mount: it holds +// either a fixed servlet instance or a servlet class instantiated per request +// (with the mount options), and dispatches by building a library AbstractServlet +// whose handlers call the instance's do_ methods — so the HEAD->GET +// aliasing, the OPTIONS Allow list and MethodNotAllowed all come from the +// library, over the Ruby methods. +type webrickRubyServlet struct { + vm *VM + inst object.Value // a pre-built servlet instance, or nil + cls *RClass // a servlet class instantiated per request, when inst is nil + opts []object.Value // the mount options passed to the class's #new +} + +// Service dispatches req to the Ruby servlet's do_ handler through a +// library AbstractServlet (see the type comment). +func (s *webrickRubyServlet) Service(req *webrick.Request, res *webrick.Response) *webrick.Status { + inst := s.inst + if inst == nil { + inst = s.vm.send(s.cls, "new", s.opts, nil) + } + abs := webrick.NewAbstractServlet() + for _, m := range webrickMethods { + if !s.vm.respondsTo(inst, "do_"+m) { + continue + } + mm := m + abs.Handle(m, func(rq *webrick.Request, rs *webrick.Response) *webrick.Status { + s.vm.send(inst, "do_"+mm, []object.Value{&WEBrickRequest{req: rq}, &WEBrickResponse{res: rs}}, nil) + return nil + }) + } + return abs.Service(req, res) +} + +// webrickServlet resolves HTTPServer#mount's servlet argument into a +// webrick.Servlet: a Class is instantiated per request (WEBrick's get_instance +// model, with the mount options as #new args), and any other value is used as a +// pre-built servlet instance. +func (vm *VM) webrickServlet(v object.Value, opts []object.Value) webrick.Servlet { + if cls, ok := v.(*RClass); ok { + return &webrickRubyServlet{vm: vm, cls: cls, opts: append([]object.Value(nil), opts...)} + } + return &webrickRubyServlet{vm: vm, inst: v} +} + +// webrickService is the Go port of HTTPServer#service driven by #run: it wires +// the request context into the response, routes the request to its servlet and, +// on a raised (returned or panicked) WEBrick::HTTPStatus, renders the matching +// error response. A non-status raise from a handler propagates untouched. +func (vm *VM) webrickService(s *WEBrickServer, req *WEBrickRequest, res *WEBrickResponse) { + defer func() { + if r := recover(); r != nil { + if st, ok := vm.webrickStatusFromPanic(r); ok { + res.res.SetError(st) + return + } + panic(r) + } + }() + if req.req == nil { + raise("RuntimeError", "WEBrick::HTTPRequest has not been parsed yet") + } + res.res.SetRequest(req.req) + if st := s.srv.Service(req.req, res.res); st != nil { + res.res.SetError(st) + } +} + +// webrickStatusDefs is the named WEBrick::HTTPStatus leaf exceptions this binding +// registers, each with its code and its category class (the exception's parent). +var webrickStatusDefs = []struct { + Name string + Code int + Cat string +}{ + {"OK", 200, "Success"}, + {"MovedPermanently", 301, "Redirect"}, + {"Found", 302, "Redirect"}, + {"NotModified", 304, "Redirect"}, + {"TemporaryRedirect", 307, "Redirect"}, + {"BadRequest", 400, "ClientError"}, + {"Forbidden", 403, "ClientError"}, + {"NotFound", 404, "ClientError"}, + {"MethodNotAllowed", 405, "ClientError"}, + {"LengthRequired", 411, "ClientError"}, + {"RequestEntityTooLarge", 413, "ClientError"}, + {"InternalServerError", 500, "ServerError"}, + {"NotImplemented", 501, "ServerError"}, +} + +// webrickCodeByClassName maps a registered status exception's fully-qualified +// class name to its code; webrickClassNameByCode is the inverse. Both are built +// once from webrickStatusDefs. +var ( + webrickCodeByClassName = map[string]int{} + webrickClassNameByCode = map[int]string{} +) + +func init() { + for _, d := range webrickStatusDefs { + name := "WEBrick::HTTPStatus::" + d.Name + webrickCodeByClassName[name] = d.Code + webrickClassNameByCode[d.Code] = name + } +} + +// webrickCodeFromValue resolves the status code carried by a WEBrick::HTTPStatus +// class or instance, walking the ancestor chain so a subclass still resolves. ok +// is false when the value is not (a subclass of) a coded status. +func (vm *VM) webrickCodeFromValue(v object.Value) (int, bool) { + cls, ok := v.(*RClass) + if !ok { + cls = vm.classOf(v) + } + for _, a := range vm.ancestors(cls) { + if code, ok := webrickCodeByClassName[a.name]; ok { + return code, true + } + } + return 0, false +} + +// webrickCodeOf returns the code of a status exception instance (Status#code), +// or 0 for a bare Status/category with no code of its own. +func (vm *VM) webrickCodeOf(v object.Value) int { + code, _ := vm.webrickCodeFromValue(v) + return code +} + +// webrickStatusCode returns the code of the status class/instance passed to +// #set_redirect, raising ArgumentError when it is not a WEBrick::HTTPStatus. +func (vm *VM) webrickStatusCode(v object.Value) int { + code, ok := vm.webrickCodeFromValue(v) + if !ok { + raise("ArgumentError", "expected a WEBrick::HTTPStatus, got %s", v.Inspect()) + } + return code +} + +// webrickErrorStatus maps #set_error's argument onto the error the library's +// SetError renders: a coded WEBrick::HTTPStatus becomes that status, anything +// else a plain error (which SetError turns into a 500 page). +func (vm *VM) webrickErrorStatus(v object.Value) error { + if code, ok := vm.webrickCodeFromValue(v); ok { + if st := webrick.NewStatus(code, ""); st != nil { + return st + } + } + return errors.New(v.ToS()) +} + +// webrickStatusFromPanic maps a recovered panic to the status a handler raised: a +// Ruby exception whose class (or an ancestor) is a coded WEBrick::HTTPStatus +// yields that status carrying the raise's message. Any other panic is not a +// status (ok=false) and is re-raised by the caller. +func (vm *VM) webrickStatusFromPanic(r any) (*webrick.Status, bool) { + re, ok := r.(RubyError) + if !ok { + return nil, false + } + if re.Obj != nil { + if code, ok := vm.webrickCodeFromValue(re.Obj); ok { + return webrick.NewStatus(code, re.Message), true + } + return nil, false + } + if code, ok := webrickCodeByClassName[re.Class]; ok { + return webrick.NewStatus(code, re.Message), true + } + return nil, false +} + +// webrickRaiseStatus raises the Ruby exception matching a *webrick.Status a parse +// returned: the mapped WEBrick::HTTPStatus class when the code is one this +// binding registers, else a plain RuntimeError carrying the status message. +func webrickRaiseStatus(st *webrick.Status) { + if name, ok := webrickClassNameByCode[st.Code]; ok { + raise(name, "%s", st.Error()) + } + raise("RuntimeError", "%s", st.Error()) +} + +// webrickConfig builds a *webrick.Config from an optional Ruby config Hash, +// honouring the deterministic keys the codec reads (:Port, :ServerName, +// :ServerSoftware); the networking keys are host seams and are ignored. +func webrickConfig(v object.Value) *webrick.Config { + cfg := webrick.DefaultConfig() + h, ok := v.(*object.Hash) + if !ok { + return cfg + } + for _, k := range h.Keys { + val, _ := h.Get(k) + switch webrickKey(k) { + case "Port": + cfg.Port = webrickInt(val) + case "ServerName": + cfg.ServerName = webrickStr(val) + case "ServerSoftware": + cfg.ServerSoftware = webrickStr(val) + } + } + return cfg +} + +// webrickConfigHash renders a *webrick.Config as the Hash HTTPServer#config +// returns, keyed by Symbol like a WEBrick config. +func webrickConfigHash(cfg *webrick.Config) *object.Hash { + h := object.NewHash() + h.Set(object.Symbol("Port"), object.IntValue(int64(cfg.Port))) + h.Set(object.Symbol("ServerName"), object.NewString(cfg.ServerName)) + h.Set(object.Symbol("ServerSoftware"), object.NewString(cfg.ServerSoftware)) + return h +} + +// webrickFirstOpt returns the optional first argument (a config Hash) or nil. +func webrickFirstOpt(args []object.Value) object.Value { + if len(args) == 0 { + return object.NilV + } + return args[0] +} + +// webrickArg returns the single required argument of a setter, raising +// ArgumentError when it was omitted. +func webrickArg(args []object.Value) object.Value { + if len(args) == 0 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1)") + } + return args[0] +} + +// webrickReq coerces a Ruby argument to a *WEBrickRequest, raising TypeError for +// anything else. +func webrickReq(v object.Value) *WEBrickRequest { + if r, ok := v.(*WEBrickRequest); ok { + return r + } + raise("TypeError", "expected a WEBrick::HTTPRequest, got %s", v.Inspect()) + panic("unreachable") +} + +// webrickRes coerces a Ruby argument to a *WEBrickResponse, raising TypeError for +// anything else. +func webrickRes(v object.Value) *WEBrickResponse { + if r, ok := v.(*WEBrickResponse); ok { + return r + } + raise("TypeError", "expected a WEBrick::HTTPResponse, got %s", v.Inspect()) + panic("unreachable") +} + +// webrickKey coerces a Ruby config key (Symbol or String) to its Go string name. +func webrickKey(v object.Value) string { + switch k := v.(type) { + case object.Symbol: + return string(k) + case *object.String: + return k.Str() + } + return v.ToS() +} + +// webrickStr coerces a Ruby argument to its String contents (String or Symbol). +func webrickStr(v object.Value) string { + switch n := v.(type) { + case *object.String: + return n.Str() + case object.Symbol: + return string(n) + } + return v.ToS() +} + +// webrickInt coerces a Ruby Integer argument to an int, raising TypeError for a +// non-Integer (a port / status / length must be an Integer). +func webrickInt(v object.Value) int { + if n, ok := v.(object.Integer); ok { + return int(n) + } + if n, ok := object.BigOf(v); ok { + return int(n.Int64()) + } + raise("TypeError", "expected an Integer, got %s", v.Inspect()) + panic("unreachable") +} diff --git a/internal/vm/webrick_internal_test.go b/internal/vm/webrick_internal_test.go new file mode 100644 index 00000000..1bbb9e49 --- /dev/null +++ b/internal/vm/webrick_internal_test.go @@ -0,0 +1,375 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "bytes" + "io" + "math/big" + "strings" + "testing" + + webrick "github.com/go-ruby-webrick/webrick" + + "github.com/go-embedded-ruby/ruby/internal/compiler" + "github.com/go-embedded-ruby/ruby/internal/object" + "github.com/go-ruby-parser/parser" +) + +// webrickRun runs src on a fresh VM and returns its trimmed stdout, failing on a +// parse/compile/run error. +func webrickRun(t *testing.T, src string) string { + t.Helper() + prog, err := parser.Parse(src) + if err != nil { + t.Fatalf("parse: %v", err) + } + iseq, err := compiler.Compile(prog) + if err != nil { + t.Fatalf("compile: %v", err) + } + var buf bytes.Buffer + if _, err := New(&buf).Run(iseq); err != nil { + t.Fatalf("run: %v\n%s", err, buf.String()) + } + return strings.TrimRight(buf.String(), "\n") +} + +// webrickRunErr runs src on a fresh VM and returns the uncaught error's class, or +// "" if it ran clean. +func webrickRunErr(t *testing.T, src string) string { + t.Helper() + prog, err := parser.Parse(src) + if err != nil { + t.Fatalf("parse: %v", err) + } + iseq, err := compiler.Compile(prog) + if err != nil { + t.Fatalf("compile: %v", err) + } + _, rerr := New(io.Discard).Run(iseq) + if rerr == nil { + return "" + } + if i := strings.Index(rerr.Error(), ": "); i > 0 { + return rerr.Error()[:i] + } + return rerr.Error() +} + +// TestWEBrickValueTypes covers the wrappers' string/truthy surface and classOf. +func TestWEBrickValueTypes(t *testing.T) { + vm := New(io.Discard) + srv := &WEBrickServer{} + req := &WEBrickRequest{} + res := &WEBrickResponse{} + for _, c := range []interface { + ToS() string + Inspect() string + Truthy() bool + }{srv, req, res} { + if c.ToS() != c.Inspect() || !c.Truthy() { + t.Errorf("%T: ToS=%q Inspect=%q Truthy=%v", c, c.ToS(), c.Inspect(), c.Truthy()) + } + } + if vm.classOf(srv) != vm.consts["WEBrick::HTTPServer"] || + vm.classOf(req) != vm.consts["WEBrick::HTTPRequest"] || + vm.classOf(res) != vm.consts["WEBrick::HTTPResponse"] { + t.Fatal("classOf mismatch for WEBrick wrappers") + } +} + +// TestWEBrickServing drives the full request/response/mount/servlet/status +// surface through in-process #service dispatch (no listening socket). +func TestWEBrickServing(t *testing.T) { + src := ` +require "webrick" +srv = WEBrick::HTTPServer.new(Port: 8080, ServerName: "example.com", ServerSoftware: "test/1") +srv.mount_proc("/hello") do |req, res| + res.status = 200 + res.content_type = "text/plain" + res.body = "path=#{req.path} m=#{req.request_method} x=#{req.query["x"]} h=#{req["X-Test"]} miss=#{req["X-No"].inspect} v=#{req.http_version} ka=#{req.keep_alive?} host=#{req.host} port=#{req.port} sn=#{req.script_name} pi=#{req.path_info} uri=#{req.unparsed_uri} qs=#{req.query_string}" +end +srv.mount_proc("/echo") do |req, res| + res["X-Len"] = req.content_length.to_s + hs = [] + req.each { |k, v| hs << "#{k}=#{v}" } + res.body = req.body + " ct=" + req.content_type.to_s + " hdrs=" + hs.sort.join(",") +end +srv.mount_proc("/redir") { |req, res| res.set_redirect(WEBrick::HTTPStatus::Found, "/hello") } +srv.mount_proc("/boom") { |req, res| raise WEBrick::HTTPStatus::NotFound, "nope" } +srv.mount_proc("/chunk") { |req, res| res.chunked = true; res.body = "abc" } + +class MyServlet < WEBrick::HTTPServlet::AbstractServlet + def do_GET(req, res); res.body = "sget #{req.path}"; end + def do_POST(req, res); res.body = "spost"; end +end +srv.mount("/s", MyServlet) +srv.mount("/i", MyServlet.new) + +def run(srv, raw) + req = WEBrick::HTTPRequest.new + req.parse(raw) + res = WEBrick::HTTPResponse.new + srv.service(req, res) + res +end + +r = run(srv, "GET /hello?x=42 HTTP/1.1\r\nHost: h.example:9\r\nX-Test: yo\r\n\r\n") +puts "hello: #{r.status} #{r.reason_phrase} | #{r.body}" +puts "hello line: #{r.status_line.start_with?("HTTP/1.1 200 OK")} ka=#{r.keep_alive?} chunked=#{r.chunked?}" +r = run(srv, "POST /echo HTTP/1.1\r\nHost: h\r\nContent-Type: text/plain\r\nContent-Length: 5\r\n\r\nhello") +puts "echo: #{r.status} | #{r.body} | xlen=#{r["X-Len"]} ctmiss=#{r.content_type.inspect}" +r = run(srv, "GET /redir HTTP/1.1\r\nHost: h\r\n\r\n") +puts "redir: #{r.status} loc=#{r["Location"]} body=#{r.body}" +r = run(srv, "GET /boom HTTP/1.1\r\nHost: h\r\n\r\n") +puts "boom: #{r.status} #{r.reason_phrase}" +r = run(srv, "GET /chunk HTTP/1.1\r\nHost: h\r\n\r\n") +puts "chunk: #{r.status} chunked=#{r.chunked?}" +r = run(srv, "GET /s/x HTTP/1.1\r\nHost: h\r\n\r\n") +puts "sget: #{r.status} #{r.body}" +r = run(srv, "POST /s HTTP/1.1\r\nHost: h\r\nContent-Length: 0\r\n\r\n") +puts "spost: #{r.status} #{r.body}" +r = run(srv, "DELETE /s HTTP/1.1\r\nHost: h\r\n\r\n") +puts "sdel: #{r.status} #{r.reason_phrase}" +r = run(srv, "OPTIONS /hello HTTP/1.1\r\nHost: h\r\n\r\n") +puts "opts: #{r.status} allow=#{r["Allow"]}" +r = run(srv, "GET /nowhere HTTP/1.1\r\nHost: h\r\n\r\n") +puts "nowhere: #{r.status} #{r.reason_phrase}" +r = run(srv, "DELETE /hello HTTP/1.1\r\nHost: h\r\n\r\n") +puts "mna: #{r.status} #{r.reason_phrase}" +r = run(srv, "GET /i/y HTTP/1.1\r\nHost: h\r\n\r\n") +puts "inst: #{r.status} #{r.body}" +puts "toS: #{run(srv, "GET /s/x HTTP/1.1\r\nHost: h\r\n\r\n").to_s.start_with?("HTTP/1.1 200 OK")}" +puts "config: #{srv.config.inspect}" +puts "version: #{WEBrick::VERSION}" +puts "status: rp=#{WEBrick::HTTPStatus.reason_phrase(404)} i=#{WEBrick::HTTPStatus.info?(100)} s=#{WEBrick::HTTPStatus.success?(200)} rd=#{WEBrick::HTTPStatus.redirect?(302)} e=#{WEBrick::HTTPStatus.error?(404)} ce=#{WEBrick::HTTPStatus.client_error?(404)} se=#{WEBrick::HTTPStatus.server_error?(500)}" +begin; raise WEBrick::HTTPStatus::Forbidden, "no"; rescue => e; puts "rescue: #{e.class} code=#{e.code} to_i=#{e.to_i} rp=#{e.reason_phrase}"; end +srv.start; srv.run; srv.shutdown; srv.stop +srv.unmount("/i"); srv.umount("/s") + +# set_error with a coded status class and with a plain object. +res = WEBrick::HTTPResponse.new +res.set_error(WEBrick::HTTPStatus::BadRequest) +puts "seterr-coded: #{res.status}" +res2 = WEBrick::HTTPResponse.new +res2.set_error("boom") +puts "seterr-plain: #{res2.status}" + +# content_length= / body= / []= round-trips. +res3 = WEBrick::HTTPResponse.new +res3.content_length = 3 +res3.body = "xyz" +res3["X-K"] = "v" +res3.content_type = "text/html" +puts "resp: cl=#{res3["Content-Length"]} body=#{res3.body} k=#{res3["X-K"]} ct=#{res3.content_type} miss=#{res3["X-No"].inspect}" +` + got := webrickRun(t, src) + want := strings.Join([]string{ + "hello: 200 OK | path=/hello m=GET x=42 h=yo miss=nil v=1.1 ka=true host=h.example port=9 sn=/hello pi= uri=/hello?x=42 qs=x=42", + "hello line: true ka=true chunked=false", + "echo: 200 | hello ct=text/plain hdrs=content-length=5,content-type=text/plain,host=h | xlen=5 ctmiss=nil", + "redir: 302 loc=/hello body=/hello.", + "boom: 404 Not Found", + "chunk: 200 chunked=true", + "sget: 200 sget /s/x", + "spost: 200 spost", + "sdel: 405 Method Not Allowed", + "opts: 200 allow=GET,HEAD,OPTIONS,POST,PUT", + "nowhere: 404 Not Found", + "mna: 405 Method Not Allowed", + "inst: 200 sget /i/y", + "toS: true", + "config: {Port: 8080, ServerName: \"example.com\", ServerSoftware: \"test/1\"}", + "version: 1.9.2", + "status: rp=Not Found i=true s=true rd=true e=true ce=true se=true", + "rescue: WEBrick::HTTPStatus::Forbidden code=403 to_i=403 rp=Forbidden", + "seterr-coded: 400", + "seterr-plain: 500", + "resp: cl=3 body=xyz k=v ct=text/html miss=nil", + }, "\n") + if got != want { + t.Fatalf("webrick serving mismatch:\n got:\n%s\nwant:\n%s", got, want) + } +} + +// TestWEBrickRubyErrors covers the raising branches reachable from Ruby: the +// arity / missing-block / not-parsed / wrong-type / non-status-raise / malformed +// parse / not-a-status-redirect paths. +func TestWEBrickRubyErrors(t *testing.T) { + cases := []struct { + src string + want string + }{ + {`require "webrick"; WEBrick::HTTPServer.new.mount_proc("/x")`, "ArgumentError"}, // no block + {`require "webrick"; WEBrick::HTTPServer.new.mount("/x")`, "ArgumentError"}, // <2 args + {`require "webrick"; WEBrick::HTTPRequest.new.parse`, "ArgumentError"}, // parse no arg + {`require "webrick"; WEBrick::HTTPRequest.new.path`, "RuntimeError"}, // not parsed + {`require "webrick"; WEBrick::HTTPServer.new.service(1, 2)`, "TypeError"}, // bad req type + {`require "webrick" +r = WEBrick::HTTPRequest.new; r.parse("GET / HTTP/1.1\r\nHost: h\r\n\r\n") +WEBrick::HTTPServer.new.service(r, 2)`, "TypeError"}, // bad res type + {`require "webrick"; res = WEBrick::HTTPResponse.new; res.request = 5`, "TypeError"}, // request= wrong type + {`require "webrick" +srv = WEBrick::HTTPServer.new +srv.service(WEBrick::HTTPRequest.new, WEBrick::HTTPResponse.new)`, "RuntimeError"}, // unparsed req in service + {`require "webrick" +srv = WEBrick::HTTPServer.new +srv.mount_proc("/x") { |req, res| raise "generic" } +r = WEBrick::HTTPRequest.new; r.parse("GET /x HTTP/1.1\r\nHost: h\r\n\r\n") +srv.service(r, WEBrick::HTTPResponse.new)`, "RuntimeError"}, // non-status raise propagates + {`require "webrick"; WEBrick::HTTPRequest.new.parse("GET /x HTTP/9\r\n")`, "WEBrick::HTTPStatus::BadRequest"}, // malformed -> mapped status + {`require "webrick"; WEBrick::HTTPRequest.new.parse("")`, "RuntimeError"}, // EOF -> unmapped status + {`require "webrick"; WEBrick::HTTPResponse.new.set_redirect(42, "/x")`, "ArgumentError"}, // not a status + {`require "webrick"; WEBrick::HTTPResponse.new.set_redirect(WEBrick::HTTPStatus::Found)`, "ArgumentError"}, // <2 args + {`require "webrick" +res = WEBrick::HTTPResponse.new; res["X"] = "a\r\nb"; res.to_s`, "WEBrick::HTTPResponse::InvalidHeader"}, // CRLF header + {`require "webrick"; WEBrick::HTTPRequest.new.parse("GET / HTTP/1.1\r\nHost: h\r\n\r\n").each`, "LocalJumpError"}, // each no block + {`require "webrick"; WEBrick::HTTPResponse.new.status = "x"`, "TypeError"}, // non-integer status + } + for _, c := range cases { + if got := webrickRunErr(t, c.src); got != c.want { + t.Errorf("src %q: got %q want %q", c.src, got, c.want) + } + } +} + +// TestWEBrickArityDirect covers the native-level arity guards that Ruby call +// syntax cannot express (a bare setter with zero args, unmount with none, etc.). +func TestWEBrickArityDirect(t *testing.T) { + vm := New(io.Discard) + srv := &WEBrickServer{srv: webrick.NewHTTPServer(webrick.DefaultConfig()), cfg: webrick.DefaultConfig()} + srvCls := vm.consts["WEBrick::HTTPServer"].(*RClass) + reqCls := vm.consts["WEBrick::HTTPRequest"].(*RClass) + resCls := vm.consts["WEBrick::HTTPResponse"].(*RClass) + res := &WEBrickResponse{res: webrick.NewResponse(webrick.DefaultConfig())} + rq := &WEBrickRequest{req: mustParse(t)} + + expectRaise := func(cls, method string, m map[string]*Method, self object.Value, args []object.Value) { + defer func() { + r := recover() + re, ok := r.(RubyError) + if !ok || re.Class != cls { + t.Errorf("%s expected %s, got %#v", method, cls, r) + } + }() + m[method].native(vm, self, args, nil) + } + // Zero-arg guards. + expectRaise("ArgumentError", "mount_proc", srvCls.methods, srv, nil) + expectRaise("ArgumentError", "unmount", srvCls.methods, srv, nil) + expectRaise("ArgumentError", "umount", srvCls.methods, srv, nil) + expectRaise("ArgumentError", "service", srvCls.methods, srv, nil) + expectRaise("ArgumentError", "parse", reqCls.methods, rq, nil) + expectRaise("ArgumentError", "[]", reqCls.methods, rq, nil) + expectRaise("ArgumentError", "status=", resCls.methods, res, nil) + expectRaise("ArgumentError", "request=", resCls.methods, res, nil) + expectRaise("ArgumentError", "[]", resCls.methods, res, nil) + expectRaise("ArgumentError", "[]=", resCls.methods, res, []object.Value{object.NewString("k")}) + expectRaise("ArgumentError", "set_redirect", resCls.methods, res, nil) + expectRaise("ArgumentError", "content_type=", resCls.methods, res, nil) + expectRaise("ArgumentError", "content_length=", resCls.methods, res, nil) + expectRaise("ArgumentError", "body=", resCls.methods, res, nil) + expectRaise("ArgumentError", "chunked=", resCls.methods, res, nil) + expectRaise("ArgumentError", "reason_phrase", vm.consts["WEBrick::HTTPStatus"].(*RClass).smethods, object.NilV, nil) +} + +// mustParse builds a parsed request for the direct tests. +func mustParse(t *testing.T) *webrick.Request { + t.Helper() + req, st := webrick.ParseRequest([]byte("GET /p?a=1 HTTP/1.1\r\nHost: h\r\n\r\n"), webrick.DefaultConfig()) + if st != nil { + t.Fatalf("parse: %v", st) + } + return req +} + +// TestWEBrickHelpers exercises the pure conversion helpers and the panic->status +// mapper directly, including the fall-through branches Ruby cannot reach. +func TestWEBrickHelpers(t *testing.T) { + vm := New(io.Discard) + + // webrickStr: String, Symbol, other. + if webrickStr(object.NewString("s")) != "s" || webrickStr(object.Symbol("y")) != "y" || webrickStr(object.IntValue(3)) != "3" { + t.Fatal("webrickStr") + } + // webrickKey: Symbol, String, other. + if webrickKey(object.Symbol("P")) != "P" || webrickKey(object.NewString("Q")) != "Q" || webrickKey(object.IntValue(5)) != "5" { + t.Fatal("webrickKey") + } + // webrickInt: Integer, Bignum, non-int -> TypeError. + if webrickInt(object.IntValue(7)) != 7 { + t.Fatal("webrickInt int") + } + if webrickInt(&object.Bignum{I: big.NewInt(11)}) != 11 { + t.Fatal("webrickInt bignum") + } + func() { + defer func() { + if re, ok := recover().(RubyError); !ok || re.Class != "TypeError" { + t.Errorf("webrickInt non-int: %#v", recover()) + } + }() + webrickInt(object.NewString("x")) + }() + + // webrickFirstOpt: empty -> nil, non-empty -> first. + if !object.IsNil(webrickFirstOpt(nil)) || webrickFirstOpt([]object.Value{object.IntValue(1)}) != object.IntValue(1) { + t.Fatal("webrickFirstOpt") + } + + // webrickConfig: non-Hash default, and a Hash mixing Symbol/String/other keys. + if c := webrickConfig(object.NilV); c.Port != 80 { + t.Fatal("webrickConfig default") + } + h := object.NewHash() + h.Set(object.Symbol("Port"), object.IntValue(9)) + h.Set(object.NewString("ServerName"), object.NewString("n")) + h.Set(object.Symbol("ServerSoftware"), object.NewString("sw")) + h.Set(object.IntValue(1), object.NewString("ignored")) + c := webrickConfig(h) + if c.Port != 9 || c.ServerName != "n" || c.ServerSoftware != "sw" { + t.Fatalf("webrickConfig hash = %+v", c) + } + + // webrickCodeFromValue: class found, class not found, instance not found. + nf := vm.consts["WEBrick::HTTPStatus::NotFound"].(*RClass) + if code, ok := vm.webrickCodeFromValue(nf); !ok || code != 404 { + t.Fatal("codeFromValue class") + } + if _, ok := vm.webrickCodeFromValue(vm.cObject); ok { + t.Fatal("codeFromValue non-status class") + } + if _, ok := vm.webrickCodeFromValue(object.IntValue(1)); ok { + t.Fatal("codeFromValue non-status value") + } + + // webrickErrorStatus: coded -> *Status, non-coded -> plain error. + if st, ok := vm.webrickErrorStatus(nf).(*webrick.Status); !ok || st.Code != 404 { + t.Fatal("errorStatus coded") + } + if _, ok := vm.webrickErrorStatus(object.NewString("x")).(*webrick.Status); ok { + t.Fatal("errorStatus plain should not be *Status") + } + + // webrickStatusFromPanic: non-RubyError, Obj-status, Obj-non-status, Obj-nil + // class-match, Obj-nil no-match. + if _, ok := vm.webrickStatusFromPanic("not a ruby error"); ok { + t.Fatal("fromPanic non-RubyError") + } + inst := &RObject{class: nf} + if st, ok := vm.webrickStatusFromPanic(RubyError{Class: "X", Message: "m", Obj: inst}); !ok || st.Code != 404 { + t.Fatal("fromPanic Obj status") + } + if _, ok := vm.webrickStatusFromPanic(RubyError{Class: "X", Obj: object.IntValue(1)}); ok { + t.Fatal("fromPanic Obj non-status") + } + if st, ok := vm.webrickStatusFromPanic(RubyError{Class: "WEBrick::HTTPStatus::Forbidden"}); !ok || st.Code != 403 { + t.Fatal("fromPanic class-name match") + } + if _, ok := vm.webrickStatusFromPanic(RubyError{Class: "Foo"}); ok { + t.Fatal("fromPanic no match") + } +}