wreq 1.2.12 → 1.2.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Cargo.lock +1 -1
- data/Cargo.toml +1 -1
- data/docs/fork-safety.md +49 -32
- data/docs/interrupt-handling.md +4 -3
- data/lib/wreq.rb +36 -12
- data/lib/wreq_ruby/body.rb +9 -6
- data/lib/wreq_ruby/client.rb +18 -14
- data/lib/wreq_ruby/cookie.rb +9 -0
- data/lib/wreq_ruby/error.rb +7 -5
- data/lib/wreq_ruby/response.rb +23 -7
- data/src/arch.rs +83 -37
- data/src/client/body/stream.rs +15 -24
- data/src/client/req.rs +119 -122
- data/src/client/resp.rs +101 -55
- data/src/client.rs +78 -36
- data/src/cookie.rs +47 -11
- data/src/error.rs +1 -1
- data/src/lib.rs +0 -2
- data/src/macros.rs +0 -1
- data/src/rt.rs +20 -36
- data/test/fork_test.rb +30 -6
- data/test/scripts/fork_safety.rb +66 -43
- data/test/scripts/prefork_runtime.rb +95 -0
- metadata +2 -1
data/src/rt.rs
CHANGED
|
@@ -17,29 +17,13 @@ use crate::{
|
|
|
17
17
|
/// Initialize the global runtime lazily and preserve failures for Ruby.
|
|
18
18
|
static RUNTIME: OnceLock<Result<TokioRuntime, io::Error>> = OnceLock::new();
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
Interrupted,
|
|
22
|
-
Future(E),
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/// Register fork tracking while the native extension is being loaded.
|
|
20
|
+
/// Reject a child process that inherited an initialized native runtime.
|
|
26
21
|
///
|
|
27
22
|
/// # Errors
|
|
28
23
|
///
|
|
29
|
-
/// Returns `Wreq::ForkError`
|
|
30
|
-
///
|
|
31
|
-
|
|
32
|
-
pub fn initialize(ruby: &Ruby) -> Result<(), magnus::Error> {
|
|
33
|
-
arch::initialize_fork_tracking().map_err(|err| fork_handler_error(ruby, &err))
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/// Reject a child process that inherited the loaded native extension.
|
|
37
|
-
///
|
|
38
|
-
/// # Errors
|
|
39
|
-
///
|
|
40
|
-
/// Returns `Wreq::ForkError` when the extension was loaded before the current
|
|
41
|
-
/// process was forked.
|
|
42
|
-
pub fn ensure_current(ruby: &Ruby) -> Result<(), magnus::Error> {
|
|
24
|
+
/// Returns `Wreq::ForkError` when the global runtime belongs to the parent
|
|
25
|
+
/// process.
|
|
26
|
+
fn ensure_runtime_owner(ruby: &Ruby) -> Result<(), magnus::Error> {
|
|
43
27
|
#[cfg(unix)]
|
|
44
28
|
if let Some((owner_pid, current_pid)) = arch::forked_process_ids() {
|
|
45
29
|
return Err(fork_error(ruby, owner_pid, current_pid));
|
|
@@ -54,21 +38,25 @@ pub fn ensure_current(ruby: &Ruby) -> Result<(), magnus::Error> {
|
|
|
54
38
|
/// Block on a future to completion on the current process's global Tokio runtime.
|
|
55
39
|
///
|
|
56
40
|
/// The future runs without Ruby's GVL, so it must not construct Ruby objects or
|
|
57
|
-
/// Ruby exceptions.
|
|
58
|
-
///
|
|
41
|
+
/// Ruby exceptions. Its output is returned unchanged. If that output is a
|
|
42
|
+
/// `Result`, convert its error after this function returns and reacquires the
|
|
43
|
+
/// GVL.
|
|
59
44
|
///
|
|
60
45
|
/// # Errors
|
|
61
46
|
///
|
|
62
|
-
/// Returns `Wreq::ForkError` if the
|
|
47
|
+
/// Returns `Wreq::ForkError` if the runtime belongs to a parent process,
|
|
63
48
|
/// `Wreq::BuilderError` if the Tokio runtime cannot be initialized,
|
|
64
|
-
/// `Wreq::InterruptError` if Ruby interrupts the
|
|
65
|
-
|
|
66
|
-
pub fn try_block_on<F, T, E, M>(ruby: &Ruby, future: F, map_err: M) -> Result<T, magnus::Error>
|
|
49
|
+
/// or `Wreq::InterruptError` if Ruby interrupts the operation.
|
|
50
|
+
pub(crate) fn block_on<F>(ruby: &Ruby, future: F) -> Result<F::Output, magnus::Error>
|
|
67
51
|
where
|
|
68
|
-
F: Future
|
|
69
|
-
M: FnOnce(&Ruby, E) -> magnus::Error,
|
|
52
|
+
F: Future,
|
|
70
53
|
{
|
|
71
|
-
|
|
54
|
+
// Install fork tracking at the same point as the lazy runtime. Loading the
|
|
55
|
+
// extension alone must not claim the runtime for the parent process.
|
|
56
|
+
#[cfg(unix)]
|
|
57
|
+
arch::initialize_fork_tracking().map_err(|err| fork_handler_error(ruby, &err))?;
|
|
58
|
+
|
|
59
|
+
ensure_runtime_owner(ruby)?;
|
|
72
60
|
let runtime = RUNTIME
|
|
73
61
|
.get_or_init(|| {
|
|
74
62
|
let mut builder = Builder::new_multi_thread();
|
|
@@ -80,15 +68,11 @@ where
|
|
|
80
68
|
runtime.block_on(async move {
|
|
81
69
|
tokio::select! {
|
|
82
70
|
biased;
|
|
83
|
-
_ = flag.cancelled() =>
|
|
84
|
-
result = future => result
|
|
71
|
+
_ = flag.cancelled() => None,
|
|
72
|
+
result = future => Some(result),
|
|
85
73
|
}
|
|
86
74
|
})
|
|
87
75
|
});
|
|
88
76
|
|
|
89
|
-
|
|
90
|
-
Ok(value) => Ok(value),
|
|
91
|
-
Err(BlockOnError::Interrupted) => Err(interrupt_error(ruby)),
|
|
92
|
-
Err(BlockOnError::Future(err)) => Err(map_err(ruby, err)),
|
|
93
|
-
}
|
|
77
|
+
result.ok_or_else(|| interrupt_error(ruby))
|
|
94
78
|
}
|
data/test/fork_test.rb
CHANGED
|
@@ -7,15 +7,16 @@ require "timeout"
|
|
|
7
7
|
|
|
8
8
|
class ForkTest < Minitest::Test
|
|
9
9
|
FORK_ERROR_LABELS = %w[
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
fresh_body_sender
|
|
10
|
+
module_request
|
|
11
|
+
fresh_client_request
|
|
12
|
+
fresh_body_sender_push
|
|
14
13
|
inherited_body_sender_push
|
|
15
14
|
inherited_body_sender_close
|
|
16
15
|
inherited_body_sender_closed
|
|
17
|
-
fresh_client
|
|
18
16
|
inherited_client
|
|
17
|
+
inherited_jar
|
|
18
|
+
inherited_cookie_provider
|
|
19
|
+
inherited_response_metadata
|
|
19
20
|
inherited_response
|
|
20
21
|
inherited_response_text
|
|
21
22
|
inherited_response_chunks
|
|
@@ -26,13 +27,36 @@ class ForkTest < Minitest::Test
|
|
|
26
27
|
assert_operator Wreq::ForkError, :<, RuntimeError
|
|
27
28
|
end
|
|
28
29
|
|
|
29
|
-
def
|
|
30
|
+
def test_loaded_extension_can_initialize_runtime_after_fork
|
|
31
|
+
skip "fork is not supported on this platform" unless Process.respond_to?(:fork)
|
|
32
|
+
|
|
33
|
+
stdout, stderr, status = run_fork_script("prefork_runtime.rb")
|
|
34
|
+
|
|
35
|
+
assert status.success?, "subprocess failed with #{status.inspect}: #{stderr}"
|
|
36
|
+
assert_equal "ok\n", stdout
|
|
37
|
+
assert_match(/loaded_only=ok/, stderr)
|
|
38
|
+
assert_match(/before_runtime=ok/, stderr)
|
|
39
|
+
assert_match(/parent_after_children=ok/, stderr)
|
|
40
|
+
%w[
|
|
41
|
+
inherited_client_before_runtime
|
|
42
|
+
inherited_sender_before_runtime
|
|
43
|
+
inherited_jar_before_runtime
|
|
44
|
+
inherited_cookie_provider_before_runtime
|
|
45
|
+
].each do |label|
|
|
46
|
+
assert_match(/#{label}=Wreq::ForkError:.*cannot be used after fork/, stderr)
|
|
47
|
+
end
|
|
48
|
+
refute_match(/\[BUG\]|segmentation fault|panicked/i, stderr)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def test_initialized_runtime_is_rejected_after_fork
|
|
30
52
|
skip "fork is not supported on this platform" unless Process.respond_to?(:fork)
|
|
31
53
|
|
|
32
54
|
stdout, stderr, status = run_fork_script("fork_safety.rb")
|
|
33
55
|
|
|
34
56
|
assert status.success?, "subprocess failed with #{status.inspect}: #{stderr}"
|
|
35
57
|
assert_equal "ok\n", stdout
|
|
58
|
+
assert_match(/non_runtime_construction=ok/, stderr)
|
|
59
|
+
assert_match(/inherited_snapshots=ok/, stderr)
|
|
36
60
|
FORK_ERROR_LABELS.each do |label|
|
|
37
61
|
assert_match(/#{label}=Wreq::ForkError:.*cannot be used after fork/, stderr)
|
|
38
62
|
assert_match(/#{label}_retry=Wreq::ForkError:.*cannot be used after fork/, stderr)
|
data/test/scripts/fork_safety.rb
CHANGED
|
@@ -9,36 +9,22 @@ $stdout.sync = true
|
|
|
9
9
|
$stderr.sync = true
|
|
10
10
|
|
|
11
11
|
def expect_fork_error(label)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
attempt_label = attempt.zero? ? label : "#{label}_retry"
|
|
15
|
-
|
|
16
|
-
begin
|
|
17
|
-
Timeout.timeout(5) { yield }
|
|
18
|
-
rescue Wreq::ForkError => error
|
|
19
|
-
warn "#{attempt_label}=#{error.class}: #{error.message}"
|
|
20
|
-
next
|
|
21
|
-
rescue => error
|
|
22
|
-
warn "#{attempt_label}=unexpected #{error.class}: #{error.message}"
|
|
23
|
-
exit! 2
|
|
24
|
-
end
|
|
12
|
+
2.times do |attempt|
|
|
13
|
+
attempt_label = attempt.zero? ? label : "#{label}_retry"
|
|
25
14
|
|
|
26
|
-
|
|
27
|
-
|
|
15
|
+
begin
|
|
16
|
+
yield
|
|
17
|
+
rescue Wreq::ForkError => error
|
|
18
|
+
warn "#{attempt_label}=#{error.class}: #{error.message}"
|
|
19
|
+
next
|
|
20
|
+
rescue => error
|
|
21
|
+
abort "#{attempt_label}=unexpected #{error.class}: #{error.message}"
|
|
28
22
|
end
|
|
29
23
|
|
|
30
|
-
|
|
24
|
+
abort "#{attempt_label}=missing Wreq::ForkError"
|
|
31
25
|
end
|
|
32
|
-
|
|
33
|
-
_, status = Process.wait2(child_pid)
|
|
34
|
-
abort "#{label} child failed with #{status.inspect}" unless status.success?
|
|
35
26
|
end
|
|
36
27
|
|
|
37
|
-
expect_fork_error("before_runtime") { Wreq::Client.new }
|
|
38
|
-
expect_fork_error("invalid_client") { Wreq::Client.new(unknown: true) }
|
|
39
|
-
expect_fork_error("invalid_request") { Wreq.get(1) }
|
|
40
|
-
expect_fork_error("fresh_body_sender") { Wreq::BodySender.new(0) }
|
|
41
|
-
|
|
42
28
|
server = TCPServer.new("127.0.0.1", 0)
|
|
43
29
|
port = server.addr[1]
|
|
44
30
|
server_pid = fork do
|
|
@@ -51,7 +37,7 @@ server_pid = fork do
|
|
|
51
37
|
while (line = socket.gets)
|
|
52
38
|
break if line == "\r\n"
|
|
53
39
|
end
|
|
54
|
-
socket.write("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")
|
|
40
|
+
socket.write("HTTP/1.1 200 OK\r\nX-Fork-Test: ok\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")
|
|
55
41
|
ensure
|
|
56
42
|
socket.close
|
|
57
43
|
end
|
|
@@ -63,29 +49,66 @@ end
|
|
|
63
49
|
server.close
|
|
64
50
|
|
|
65
51
|
url = "http://127.0.0.1:#{port}/"
|
|
52
|
+
|
|
53
|
+
# Start the server process before Tokio creates worker threads in the parent.
|
|
54
|
+
runtime_probe = Wreq::BodySender.new(1)
|
|
55
|
+
runtime_probe.push("warmup")
|
|
56
|
+
|
|
66
57
|
client = Wreq::Client.new
|
|
67
58
|
abort "parent warm-up failed" unless client.get(url).bytes == "ok"
|
|
68
59
|
|
|
69
|
-
|
|
70
|
-
|
|
60
|
+
inherited_objects = {
|
|
61
|
+
client: Wreq::Client.new,
|
|
62
|
+
sender: Wreq::BodySender.new,
|
|
63
|
+
response: client.get(url),
|
|
64
|
+
jar: Wreq::Jar.new
|
|
65
|
+
}
|
|
66
|
+
inherited_weak_refs = inherited_objects.values.map { |object| WeakRef.new(object) }
|
|
67
|
+
status_snapshot = inherited_objects[:response].status
|
|
68
|
+
headers_snapshot = inherited_objects[:response].headers
|
|
69
|
+
|
|
70
|
+
guard_pid = fork do
|
|
71
|
+
Timeout.timeout(10) do
|
|
72
|
+
jar = Wreq::Jar.new
|
|
73
|
+
jar.add("child=1; Path=/", url)
|
|
74
|
+
abort "fresh child jar failed" unless jar.get_all.one?
|
|
75
|
+
|
|
76
|
+
Wreq::Client.new(cookie_provider: jar)
|
|
77
|
+
Wreq::BodySender.new
|
|
78
|
+
warn "non_runtime_construction=ok"
|
|
79
|
+
|
|
80
|
+
abort "status snapshot changed" unless status_snapshot.to_i == 200
|
|
81
|
+
abort "headers snapshot changed" unless headers_snapshot["X-Fork-Test"] == "ok"
|
|
82
|
+
warn "inherited_snapshots=ok"
|
|
83
|
+
|
|
84
|
+
expect_fork_error("module_request") { Wreq.get(url) }
|
|
85
|
+
expect_fork_error("fresh_client_request") { Wreq::Client.new.get(url) }
|
|
86
|
+
expect_fork_error("fresh_body_sender_push") { Wreq::BodySender.new.push("chunk") }
|
|
87
|
+
expect_fork_error("inherited_body_sender_push") do
|
|
88
|
+
inherited_objects[:sender].push("chunk")
|
|
89
|
+
end
|
|
90
|
+
expect_fork_error("inherited_body_sender_close") { inherited_objects[:sender].close }
|
|
91
|
+
expect_fork_error("inherited_body_sender_closed") { inherited_objects[:sender].closed? }
|
|
92
|
+
expect_fork_error("inherited_client") { inherited_objects[:client].get(url) }
|
|
93
|
+
expect_fork_error("inherited_jar") { inherited_objects[:jar].get_all }
|
|
94
|
+
expect_fork_error("inherited_cookie_provider") do
|
|
95
|
+
Wreq::Client.new(cookie_provider: inherited_objects[:jar])
|
|
96
|
+
end
|
|
97
|
+
expect_fork_error("inherited_response_metadata") { inherited_objects[:response].status }
|
|
98
|
+
expect_fork_error("inherited_response") { inherited_objects[:response].bytes }
|
|
99
|
+
expect_fork_error("inherited_response_text") { inherited_objects[:response].text }
|
|
100
|
+
expect_fork_error("inherited_response_chunks") { inherited_objects[:response].chunks { nil } }
|
|
101
|
+
expect_fork_error("inherited_response_close") { inherited_objects[:response].close }
|
|
102
|
+
end
|
|
103
|
+
exit! 0
|
|
104
|
+
rescue => error
|
|
105
|
+
warn "guard_checks=unexpected #{error.class}: #{error.message}"
|
|
106
|
+
exit! 2
|
|
71
107
|
end
|
|
108
|
+
_, guard_status = Process.wait2(guard_pid)
|
|
109
|
+
abort "guard checks child failed with #{guard_status.inspect}" unless guard_status.success?
|
|
72
110
|
|
|
73
|
-
|
|
74
|
-
inherited_weak_refs = inherited_objects.map { |object| WeakRef.new(object) }
|
|
75
|
-
|
|
76
|
-
expect_fork_error("inherited_body_sender_push") do
|
|
77
|
-
inherited_objects[1].push("chunk")
|
|
78
|
-
end
|
|
79
|
-
expect_fork_error("inherited_body_sender_close") { inherited_objects[1].close }
|
|
80
|
-
expect_fork_error("inherited_body_sender_closed") { inherited_objects[1].closed? }
|
|
81
|
-
expect_fork_error("fresh_client") { Wreq::Client.new }
|
|
82
|
-
expect_fork_error("inherited_client") { client.get(url) }
|
|
83
|
-
expect_fork_error("inherited_response") { inherited_objects[2].bytes }
|
|
84
|
-
expect_fork_error("inherited_response_text") { inherited_objects[2].text(1) }
|
|
85
|
-
expect_fork_error("inherited_response_chunks") { inherited_objects[2].chunks }
|
|
86
|
-
expect_fork_error("inherited_response_close") { inherited_objects[2].close }
|
|
87
|
-
|
|
88
|
-
# Release the earlier test blocks so this array is the only strong reference.
|
|
111
|
+
# The hash is now the only strong reference to the inherited native objects.
|
|
89
112
|
GC.start(full_mark: true, immediate_sweep: true)
|
|
90
113
|
gc_pid = fork do
|
|
91
114
|
inherited_objects = nil
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "timeout"
|
|
5
|
+
require "wreq"
|
|
6
|
+
|
|
7
|
+
$stdout.sync = true
|
|
8
|
+
$stderr.sync = true
|
|
9
|
+
|
|
10
|
+
def expect_fork_error(label)
|
|
11
|
+
yield
|
|
12
|
+
abort "#{label}=missing Wreq::ForkError"
|
|
13
|
+
rescue Wreq::ForkError => error
|
|
14
|
+
warn "#{label}=#{error.class}: #{error.message}"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def run_child(label)
|
|
18
|
+
child_pid = fork do
|
|
19
|
+
Timeout.timeout(10) { yield }
|
|
20
|
+
warn "#{label}=ok"
|
|
21
|
+
exit! 0
|
|
22
|
+
rescue => error
|
|
23
|
+
warn "#{label}=unexpected #{error.class}: #{error.message}"
|
|
24
|
+
exit! 2
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
_, status = Process.wait2(child_pid)
|
|
28
|
+
abort "#{label} child failed with #{status.inspect}" unless status.success?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
server = TCPServer.new("127.0.0.1", 0)
|
|
32
|
+
url = "http://127.0.0.1:#{server.addr[1]}/"
|
|
33
|
+
server_pid = fork do
|
|
34
|
+
3.times do
|
|
35
|
+
ready = IO.select([server], nil, nil, 10)
|
|
36
|
+
exit! 4 unless ready
|
|
37
|
+
|
|
38
|
+
socket = server.accept
|
|
39
|
+
begin
|
|
40
|
+
while (line = socket.gets)
|
|
41
|
+
break if line == "\r\n"
|
|
42
|
+
end
|
|
43
|
+
socket.write("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")
|
|
44
|
+
ensure
|
|
45
|
+
socket.close
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
exit! 0
|
|
49
|
+
ensure
|
|
50
|
+
server.close
|
|
51
|
+
end
|
|
52
|
+
server.close
|
|
53
|
+
|
|
54
|
+
# Requiring the extension is the only parent-side Wreq operation before this fork.
|
|
55
|
+
run_child("loaded_only") do
|
|
56
|
+
abort "module request failed" unless Wreq.get(url).bytes == "ok"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
inherited_client = Wreq::Client.new
|
|
60
|
+
inherited_sender = Wreq::BodySender.new
|
|
61
|
+
inherited_jar = Wreq::Jar.new
|
|
62
|
+
|
|
63
|
+
run_child("before_runtime") do
|
|
64
|
+
expect_fork_error("inherited_client_before_runtime") do
|
|
65
|
+
inherited_client.get(url)
|
|
66
|
+
end
|
|
67
|
+
expect_fork_error("inherited_sender_before_runtime") { inherited_sender.closed? }
|
|
68
|
+
expect_fork_error("inherited_jar_before_runtime") { inherited_jar.get_all }
|
|
69
|
+
expect_fork_error("inherited_cookie_provider_before_runtime") do
|
|
70
|
+
Wreq::Client.new(cookie_provider: inherited_jar)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
sender = Wreq::BodySender.new
|
|
74
|
+
sender.push("child")
|
|
75
|
+
|
|
76
|
+
jar = Wreq::Jar.new
|
|
77
|
+
jar.add("child=1; Path=/", url)
|
|
78
|
+
abort "child jar failed" unless jar.get_all.one?
|
|
79
|
+
|
|
80
|
+
client = Wreq::Client.new(cookie_provider: jar)
|
|
81
|
+
abort "client request failed" unless client.get(url).bytes == "ok"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
Timeout.timeout(10) do
|
|
85
|
+
abort "parent client failed" unless inherited_client.get(url).bytes == "ok"
|
|
86
|
+
inherited_sender.push("parent")
|
|
87
|
+
inherited_jar.add("parent=1; Path=/", url)
|
|
88
|
+
abort "parent jar failed" unless inherited_jar.get_all.one?
|
|
89
|
+
end
|
|
90
|
+
warn "parent_after_children=ok"
|
|
91
|
+
|
|
92
|
+
_, server_status = Process.wait2(server_pid)
|
|
93
|
+
abort "server failed with #{server_status.inspect}" unless server_status.success?
|
|
94
|
+
|
|
95
|
+
puts "ok"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: wreq
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.2.
|
|
4
|
+
version: 1.2.13
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- SearchApi
|
|
@@ -164,6 +164,7 @@ files:
|
|
|
164
164
|
- test/request_test.rb
|
|
165
165
|
- test/response_test.rb
|
|
166
166
|
- test/scripts/fork_safety.rb
|
|
167
|
+
- test/scripts/prefork_runtime.rb
|
|
167
168
|
- test/stream_test.rb
|
|
168
169
|
- test/support/tls_server.rb
|
|
169
170
|
- test/test_helper.rb
|