Skip to content

Commit 8449fea

Browse files
sisshiki1969claude
andauthored
Exception API compliance: full_message, errno dispatch, jump/throw metadata (#893)
* Exception API: full_message, LocalJumpError, SystemCallError(errno), signals core/exception (ruby/spec, minus the Process.kill-to-self files): 90 failures+errors -> 37. - Exception#full_message rewritten to CRuby semantics: :highlight escape sequences (per-line bolding of multi-line messages), :order (:top / :bottom with the Traceback header), the cause chain, keyword forwarding to #detailed_message (with resolved :highlight, without :order), #to_str coercion of its return value, class-name fallback (nil / no #detailed_message), and caller() fallback when the exception has no backtrace. Exception.to_tty? added. - Exception#inspect goes through #to_s; empty -> class name. - Exception.new(nil) treats nil like no message (CRuby). - MonorubyErr gains a kind-specific payload surfaced as hidden ivars at materialization: LocalJumpError#exit_value / #reason, StopIteration#result (wired from the enumerator terminal value), SyntaxError#path (from the first trace frame). - NameError.new accepts the receiver: keyword; NoMethodError.new accepts (msg, name, args, receiver:) and NoMethodError#args added. - SystemCallError.new(errno) constructs the matching Errno::* class (unknown errno -> plain SystemCallError, 'Unknown error N'); (msg, errno, location) form and '@ location' message decoration; SystemCallError#errno; Errno::E*.new(msg, location) generalized from the hand-written per-class initializers. - SignalException#signo/#signm with symbol/string/integer constructors; Interrupt.new defaults to message 'Interrupt' with signo INT; ClosedQueueError added (< StopIteration). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpzdoFu46d2ChJpSzeWsNK * UncaughtThrowError#tag, lazy exception message, SystemCallError edge cases core/exception (minus Process.kill-to-self files): 37 -> 28. - UncaughtThrowError#tag returns the thrown tag (carried through the generic MonorubyErr payload -> hidden-ivar path). - Exception.new stringifies a non-String message through its #to_s (CRuby defers to_s; the mock-based to_s spec accepts this) instead of requiring #to_str. - NoMethodError receiver descriptions resolve names assigned by constant assignment (MyClass = Class.new) via get_class_name. - SystemCallError.new: nil message / nil errno treated as absent ('unknown error' base), single String argument is a message, TypeError for non-String message and non-Integer errno, Complex-with-zero-imaginary truncates, RangeError otherwise; Errno::E* subclasses inherit the parent's strerror text via an ancestor walk. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpzdoFu46d2ChJpSzeWsNK * Differential tests for the exception APIs; fix 3 conformance bugs they caught tests/exception_api.rs runs 12 test groups of exception-API snippets under both monoruby and the reference CRuby 4.0.2 and compares results — directly covering the new code paths in builtins/exception.rs / startup.rb / error.rb under nextest (the codecov patch gap on #893), and immediately catching three real conformance bugs: - full_message(order: :bottom) numbers the reversed frames ('\tN: from <frame>' counting down), matching CRuby. - NoMethodError#args defaults to nil (not []) when the exception was constructed without args. - SystemCallError#errno reports the errno as given ('foo', 2.9 keeps 2.9) — only the Errno-class lookup and the message use the Integer coercion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpzdoFu46d2ChJpSzeWsNK * Fix darwin CI: platform-agnostic unknown-errno assertion in exception_api macOS strerror renders an unknown errno as "Unknown error: N" (with a colon) while Linux (and monoruby) use "Unknown error N". The differential test hard-compared the full message, so it passed on the Linux runner but failed on the aarch64-darwin runner. Assert only the "Unknown error" prefix so the comparison holds on both hosts, and drop the top-level SubEnoent constant (re-declared on each of run_tests's ~25 warm-up passes, spamming 'already initialized constant') in favor of an anonymous subclass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpzdoFu46d2ChJpSzeWsNK --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent afc9f20 commit 8449fea

8 files changed

Lines changed: 698 additions & 153 deletions

File tree

monoruby/builtins/error.rb

Lines changed: 178 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -1,149 +1,204 @@
11
# `Errno::E*` exception classes and their `Errno` constants are populated
22
# by Rust-side `errno::init` (see monoruby/src/builtins/errno.rs), which
33
# walks the host's `libc::E*` table at startup so any errno present on the
4-
# system (`Errno::ENETDOWN`, `Errno::EPROTOTYPE`, …) is reachable.
5-
# We only attach a per-class `initialize` here so `Errno::E*.new(arg)`
6-
# yields CRuby's "<strerror> - <arg>" message format.
7-
class Errno
8-
class ENOENT < SystemCallError
9-
def initialize(msg = nil)
10-
super(msg ? "No such file or directory - #{msg}" : "No such file or directory")
11-
end
12-
end
13-
14-
class EEXIST < SystemCallError
15-
def initialize(msg = nil)
16-
super(msg ? "File exists - #{msg}" : "File exists")
17-
end
18-
end
19-
20-
class EACCES < SystemCallError
21-
def initialize(msg = nil)
22-
super(msg ? "Permission denied - #{msg}" : "Permission denied")
23-
end
24-
end
25-
26-
class ENOTEMPTY < SystemCallError
27-
def initialize(msg = nil)
28-
super(msg ? "Directory not empty - #{msg}" : "Directory not empty")
29-
end
30-
end
31-
32-
class ECONNRESET < SystemCallError
33-
def initialize(msg = nil)
34-
super(msg ? "Connection reset by peer - #{msg}" : "Connection reset by peer")
35-
end
36-
end
37-
38-
class ETIMEDOUT < SystemCallError
39-
def initialize(msg = nil)
40-
super(msg ? "Connection timed out - #{msg}" : "Connection timed out")
41-
end
42-
end
43-
44-
class ECONNREFUSED < SystemCallError
45-
def initialize(msg = nil)
46-
super(msg ? "Connection refused - #{msg}" : "Connection refused")
47-
end
48-
end
49-
50-
class EISDIR < SystemCallError
51-
def initialize(msg = nil)
52-
super(msg ? "Is a directory - #{msg}" : "Is a directory")
53-
end
54-
end
55-
56-
class ENOTDIR < SystemCallError
57-
def initialize(msg = nil)
58-
super(msg ? "Not a directory - #{msg}" : "Not a directory")
59-
end
60-
end
61-
62-
class EPERM < SystemCallError
63-
def initialize(msg = nil)
64-
super(msg ? "Operation not permitted - #{msg}" : "Operation not permitted")
65-
end
66-
end
67-
68-
class EINVAL < SystemCallError
69-
def initialize(msg = nil)
70-
super(msg ? "Invalid argument - #{msg}" : "Invalid argument")
71-
end
72-
end
73-
74-
class EIO < SystemCallError
75-
def initialize(msg = nil)
76-
super(msg ? "Input/output error - #{msg}" : "Input/output error")
77-
end
78-
end
79-
80-
class EAGAIN < SystemCallError
81-
def initialize(msg = nil)
82-
super(msg ? "Resource temporarily unavailable - #{msg}" : "Resource temporarily unavailable")
4+
# system (`Errno::ENETDOWN`, `Errno::EPROTOTYPE`, ...) is reachable.
5+
# Here we attach CRuby-compatible construction on top:
6+
# SystemCallError.new(errno) -> Errno::X instance (by errno)
7+
# SystemCallError.new(msg, errno[, loc])-> Errno::X instance
8+
# Errno::X.new([msg[, location]]) -> "strerror [@ loc] - msg"
9+
# SystemCallError#errno -> the errno Integer
10+
class SystemCallError
11+
# strerror(3) text for the errnos whose message CRuby tests exercise;
12+
# anything else falls back to "Unknown error N".
13+
STRERROR = {
14+
"ENOENT" => "No such file or directory",
15+
"EEXIST" => "File exists",
16+
"EACCES" => "Permission denied",
17+
"ENOTEMPTY" => "Directory not empty",
18+
"ECONNRESET" => "Connection reset by peer",
19+
"ETIMEDOUT" => "Connection timed out",
20+
"ECONNREFUSED" => "Connection refused",
21+
"EISDIR" => "Is a directory",
22+
"ENOTDIR" => "Not a directory",
23+
"EPERM" => "Operation not permitted",
24+
"EINVAL" => "Invalid argument",
25+
"EIO" => "Input/output error",
26+
"EAGAIN" => "Resource temporarily unavailable",
27+
"EPIPE" => "Broken pipe",
28+
"EBADF" => "Bad file descriptor",
29+
"ENOMEM" => "Cannot allocate memory",
30+
"EBUSY" => "Device or resource busy",
31+
"EROFS" => "Read-only file system",
32+
"ENOSPC" => "No space left on device",
33+
"EADDRINUSE" => "Address already in use",
34+
"ECHILD" => "No child processes",
35+
"EINTR" => "Interrupted system call",
36+
"EDOM" => "Numerical argument out of domain",
37+
"ERANGE" => "Numerical result out of range",
38+
"ESRCH" => "No such process",
39+
"ENXIO" => "No such device or address",
40+
"ESPIPE" => "Illegal seek",
41+
"EXDEV" => "Invalid cross-device link",
42+
"ENODEV" => "No such device",
43+
"EMFILE" => "Too many open files",
44+
"ELOOP" => "Too many levels of symbolic links",
45+
"ENAMETOOLONG" => "File name too long",
46+
}
47+
48+
def self.__errno_class(n)
49+
@__errno_map ||= begin
50+
map = {}
51+
Errno.constants.each do |c|
52+
k = Errno.const_get(c)
53+
next unless k.is_a?(Class) && k < SystemCallError
54+
e = k.const_defined?(:Errno) ? k.const_get(:Errno) : nil
55+
map[e] = k if e.is_a?(Integer) && !map.key?(e)
56+
end
57+
map
58+
end
59+
@__errno_map[n]
60+
end
61+
62+
# `SystemCallError.new(errno)` / `.new(msg, errno[, location])`
63+
# constructs the matching `Errno::*` subclass; an unknown errno stays
64+
# a plain SystemCallError. Subclasses (including user-defined ones)
65+
# construct normally through their own #initialize.
66+
# CRuby coercion for the errno argument: Integers pass through,
67+
# other Numerics truncate (a Complex must have zero imaginary part),
68+
# anything else needs #to_int.
69+
def self.__coerce_errno(errno)
70+
return errno if errno.is_a?(Integer)
71+
if errno.is_a?(Complex)
72+
unless errno.imaginary == 0
73+
raise RangeError, "can't convert #{errno} into Integer"
74+
end
75+
return errno.real.to_i
76+
end
77+
return errno.to_i if errno.is_a?(Numeric)
78+
unless errno.respond_to?(:to_int)
79+
raise TypeError, "no implicit conversion of #{errno.class} into Integer"
80+
end
81+
errno.to_int
82+
end
83+
84+
def self.new(*args)
85+
return super unless self.equal?(SystemCallError)
86+
if args.empty?
87+
raise ArgumentError, "wrong number of arguments (given 0, expected 1..3)"
88+
end
89+
if args.size >= 2
90+
msg, orig, loc = args[0], args[1], args[2]
91+
elsif args[0].is_a?(String) || args[0].nil?
92+
msg, orig, loc = args[0], nil, nil
93+
else
94+
msg, orig, loc = nil, args[0], nil
95+
end
96+
errno = orig.nil? ? nil : __coerce_errno(orig)
97+
klass = errno.nil? ? nil : __errno_class(errno)
98+
obj = (klass || self).allocate
99+
# #errno reports the errno as given (2.9 stays 2.9); only the
100+
# class lookup and the message use the Integer coercion.
101+
obj.__send__(:__syserr_init, msg, errno, loc, orig)
102+
obj
103+
end
104+
105+
# CRuby reports arity -1 for SystemCallError#initialize.
106+
def initialize(*args)
107+
if args.size >= 2
108+
msg, orig, loc = args[0], args[1], args[2]
109+
elsif args[0].is_a?(String) || args[0].nil?
110+
msg, orig, loc = args[0], nil, nil
111+
else
112+
msg, orig, loc = nil, args[0], nil
83113
end
84-
end
85-
86-
class EPIPE < SystemCallError
87-
def initialize(msg = nil)
88-
super(msg ? "Broken pipe - #{msg}" : "Broken pipe")
114+
errno = orig.nil? ? nil : SystemCallError.__coerce_errno(orig)
115+
if errno.nil? && self.class.const_defined?(:Errno)
116+
e = self.class.const_get(:Errno)
117+
errno = e if e.is_a?(Integer)
118+
orig = errno
89119
end
120+
__syserr_init(msg, errno, loc, orig)
90121
end
91122

92-
class EBADF < SystemCallError
93-
def initialize(msg = nil)
94-
super(msg ? "Bad file descriptor - #{msg}" : "Bad file descriptor")
95-
end
123+
def errno
124+
defined?(@__errno) ? @__errno : nil
96125
end
97126

98-
class ENOMEM < SystemCallError
99-
def initialize(msg = nil)
100-
super(msg ? "Cannot allocate memory - #{msg}" : "Cannot allocate memory")
127+
private def __syserr_init(msg, errno, loc, reported_errno = nil)
128+
unless msg.nil? || msg.is_a?(String)
129+
if msg.respond_to?(:to_str)
130+
msg = msg.to_str
131+
else
132+
raise TypeError, "no implicit conversion of #{msg.class} into String"
133+
end
101134
end
102-
end
103-
104-
class EBUSY < SystemCallError
105-
def initialize(msg = nil)
106-
super(msg ? "Device or resource busy - #{msg}" : "Device or resource busy")
107-
end
108-
end
109-
110-
class EROFS < SystemCallError
111-
def initialize(msg = nil)
112-
super(msg ? "Read-only file system - #{msg}" : "Read-only file system")
113-
end
114-
end
115-
116-
class ENOSPC < SystemCallError
117-
def initialize(msg = nil)
118-
super(msg ? "No space left on device - #{msg}" : "No space left on device")
135+
@__errno = reported_errno.nil? ? errno : reported_errno
136+
# Walk the ancestors so a user subclass of Errno::ENOENT inherits
137+
# "No such file or directory" rather than the unknown-errno text.
138+
base = nil
139+
self.class.ancestors.each do |a|
140+
next unless a.is_a?(Class) && a.name
141+
base = STRERROR[a.name.split("::").last]
142+
break if base
143+
break if a.equal?(SystemCallError)
119144
end
145+
base ||= errno ? "Unknown error #{errno}" : "unknown error"
146+
base = "#{base} @ #{loc}" unless loc.nil?
147+
base = "#{base} - #{msg}" unless msg.nil?
148+
::Exception.instance_method(:initialize).bind(self).call(base)
120149
end
150+
end
121151

122-
class EADDRINUSE < SystemCallError
123-
def initialize(msg = nil)
124-
super(msg ? "Address already in use - #{msg}" : "Address already in use")
152+
class Errno
153+
# Give every generated Errno::E* class the CRuby constructor
154+
# signature `new(msg = nil, location = nil)`.
155+
constants.each do |c|
156+
k = const_get(c)
157+
next unless k.is_a?(Class) && k < SystemCallError
158+
k.class_eval do
159+
def initialize(msg = nil, loc = nil)
160+
e = self.class.const_defined?(:Errno) ? self.class.const_get(:Errno) : nil
161+
e = nil unless e.is_a?(Integer)
162+
__syserr_init(msg, e, loc, e)
163+
end
125164
end
126165
end
127166
end
128167

129-
class SignalException
130-
def initialize(sig = nil)
168+
class SignalException < Exception
169+
# SignalException.new(signo), .new(:TERM), .new("SIGTERM", msg), ...
170+
def initialize(sig = nil, message = nil)
131171
if sig.is_a?(Integer)
132-
super("Signal #{sig}")
133-
elsif sig
134-
super(sig.to_s)
172+
name = Signal.signame(sig)
173+
raise ArgumentError, "invalid signal number (#{sig})" unless name
174+
@__signo = sig
175+
super(message || "SIG#{name}")
176+
elsif !sig.nil?
177+
name = sig.to_s.sub(/\ASIG/, "")
178+
signo = Signal.list[name]
179+
raise ArgumentError, "unsupported signal `SIG#{name}'" unless signo
180+
@__signo = signo
181+
super(message || "SIG#{name}")
135182
else
136183
super("SignalException")
137184
end
138185
end
186+
187+
def signo
188+
defined?(@__signo) ? @__signo : nil
189+
end
190+
191+
# The signal description is the exception message.
192+
def signm
193+
message
194+
end
139195
end
140196

141197
class Interrupt < SignalException
142-
def initialize(msg = nil)
143-
if msg
144-
super(msg)
145-
else
146-
super("Interrupt")
147-
end
198+
def initialize(message = nil)
199+
@__signo = Signal.list["INT"]
200+
# Interrupt's default message is its class name, not "SIGINT";
201+
# bypass SignalException#initialize.
202+
::Exception.instance_method(:initialize).bind(self).call(message || "Interrupt")
148203
end
149-
end
204+
end

0 commit comments

Comments
 (0)