mogilefs-client 2.2.0 → 3.0.0.rc1
Sign up to get free protection for your applications and to get access to all the features.
- data/.document +11 -0
- data/.gemtest +0 -0
- data/.gitignore +4 -0
- data/.wrongdoc.yml +5 -0
- data/GIT-VERSION-GEN +28 -0
- data/GNUmakefile +44 -0
- data/HACKING +33 -0
- data/{History.txt → History} +0 -1
- data/{LICENSE.txt → LICENSE} +0 -1
- data/Manifest.txt +34 -7
- data/README +51 -0
- data/Rakefile +11 -11
- data/TODO +10 -0
- data/bin/mog +109 -68
- data/examples/mogstored_rack.rb +189 -0
- data/lib/mogilefs.rb +56 -17
- data/lib/mogilefs/admin.rb +128 -62
- data/lib/mogilefs/backend.rb +205 -95
- data/lib/mogilefs/bigfile.rb +54 -70
- data/lib/mogilefs/bigfile/filter.rb +58 -0
- data/lib/mogilefs/chunker.rb +30 -0
- data/lib/mogilefs/client.rb +0 -2
- data/lib/mogilefs/copy_stream.rb +30 -0
- data/lib/mogilefs/http_file.rb +175 -0
- data/lib/mogilefs/http_reader.rb +79 -0
- data/lib/mogilefs/mogilefs.rb +242 -148
- data/lib/mogilefs/mysql.rb +3 -4
- data/lib/mogilefs/paths_size.rb +24 -0
- data/lib/mogilefs/pool.rb +0 -1
- data/lib/mogilefs/socket.rb +9 -0
- data/lib/mogilefs/socket/kgio.rb +55 -0
- data/lib/mogilefs/socket/pure_ruby.rb +70 -0
- data/lib/mogilefs/socket_common.rb +58 -0
- data/lib/mogilefs/util.rb +6 -169
- data/test/aggregate.rb +11 -11
- data/test/exec.rb +72 -0
- data/test/fresh.rb +222 -0
- data/test/integration.rb +43 -0
- data/test/setup.rb +1 -0
- data/test/socket_test.rb +98 -0
- data/test/test_admin.rb +14 -37
- data/test/test_backend.rb +50 -107
- data/test/test_bigfile.rb +2 -2
- data/test/test_db_backend.rb +1 -2
- data/test/test_fresh.rb +8 -0
- data/test/test_http_reader.rb +34 -0
- data/test/test_mogilefs.rb +278 -98
- data/test/test_mogilefs_integration.rb +174 -0
- data/test/test_mogilefs_integration_large_pipe.rb +62 -0
- data/test/test_mogilefs_integration_list_keys.rb +40 -0
- data/test/test_mogilefs_socket_kgio.rb +11 -0
- data/test/test_mogilefs_socket_pure.rb +7 -0
- data/test/test_mogstored_rack.rb +89 -0
- data/test/test_mogtool_bigfile.rb +116 -0
- data/test/test_mysql.rb +1 -2
- data/test/test_pool.rb +1 -1
- data/test/test_unit_mogstored_rack.rb +72 -0
- metadata +76 -54
- data/README.txt +0 -80
- data/lib/mogilefs/httpfile.rb +0 -157
- data/lib/mogilefs/network.rb +0 -107
- data/test/test_network.rb +0 -56
- data/test/test_util.rb +0 -121
data/lib/mogilefs/mysql.rb
CHANGED
@@ -1,7 +1,6 @@
|
|
1
1
|
# -*- encoding: binary -*-
|
2
|
-
|
3
|
-
|
4
|
-
|
2
|
+
# Consider this deprecated, to be removed at some point...
|
3
|
+
#
|
5
4
|
# read-only interface that can be a backend for MogileFS::MogileFS
|
6
5
|
#
|
7
6
|
# This provides direct, read-only access to any slave MySQL database to
|
@@ -29,7 +28,7 @@ class MogileFS::Mysql
|
|
29
28
|
##
|
30
29
|
# Lists keys starting with +prefix+ follwing +after+ up to +limit+. If
|
31
30
|
# +after+ is nil the list starts at the beginning.
|
32
|
-
def _list_keys(domain, prefix = '', after = '', limit = 1000
|
31
|
+
def _list_keys(domain, prefix = '', after = '', limit = 1000)
|
33
32
|
# this code is based on server/lib/MogileFS/Worker/Query.pm
|
34
33
|
dmid = get_dmid(domain)
|
35
34
|
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# -*- encoding: binary -*-
|
2
|
+
# This is only a hack for old MogileFS installs that didn't have file_info
|
3
|
+
require "net/http"
|
4
|
+
require "uri"
|
5
|
+
module MogileFS::PathsSize
|
6
|
+
def self.call(paths)
|
7
|
+
errors = {}
|
8
|
+
paths.each do |path|
|
9
|
+
uri = URI.parse(path)
|
10
|
+
begin
|
11
|
+
case r = Net::HTTP.start(uri.host, uri.port) { |x| x.head(uri.path) }
|
12
|
+
when Net::HTTPOK
|
13
|
+
return r["Content-Length"].to_i
|
14
|
+
else
|
15
|
+
errors[path] = r
|
16
|
+
end
|
17
|
+
rescue => err
|
18
|
+
errors[path] = err
|
19
|
+
end
|
20
|
+
end
|
21
|
+
errors = errors.map { |path,err| "#{path} - #{err.message} (#{err.class})" }
|
22
|
+
raise MogileFS::Error, "all paths failed with HEAD: #{errors.join(', ')}"
|
23
|
+
end
|
24
|
+
end
|
data/lib/mogilefs/pool.rb
CHANGED
@@ -0,0 +1,9 @@
|
|
1
|
+
# -*- encoding: binary -*-
|
2
|
+
# internal implementation details here, do not rely on this in your code
|
3
|
+
require "mogilefs/socket_common"
|
4
|
+
begin
|
5
|
+
raise LoadError, "testing pure Ruby version" if ENV["MOGILEFS_CLIENT_PURE"]
|
6
|
+
require "mogilefs/socket/kgio"
|
7
|
+
rescue LoadError
|
8
|
+
require "mogilefs/socket/pure_ruby"
|
9
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# -*- encoding: binary -*-
|
2
|
+
# internal implementation details here, do not rely on them in your code
|
3
|
+
require "kgio"
|
4
|
+
|
5
|
+
class MogileFS::Socket < Kgio::Socket
|
6
|
+
include MogileFS::SocketCommon
|
7
|
+
|
8
|
+
def self.start(host, port)
|
9
|
+
sock = super(Socket.sockaddr_in(port, host))
|
10
|
+
sock.post_init(host, port)
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.tcp(host, port, timeout = 5)
|
14
|
+
sock = start(host, port)
|
15
|
+
unless sock.kgio_wait_writable(timeout)
|
16
|
+
sock.close
|
17
|
+
raise MogileFS::Timeout, 'socket connect timeout'
|
18
|
+
end
|
19
|
+
sock
|
20
|
+
end
|
21
|
+
|
22
|
+
def timed_read(len, dst = "", timeout = 5)
|
23
|
+
case rc = kgio_tryread(len, dst)
|
24
|
+
when :wait_readable
|
25
|
+
kgio_wait_readable(timeout) or unreadable_socket!
|
26
|
+
else
|
27
|
+
return rc
|
28
|
+
end while true
|
29
|
+
end
|
30
|
+
|
31
|
+
def timed_peek(len, dst, timeout = 5)
|
32
|
+
case rc = kgio_trypeek(len, dst)
|
33
|
+
when :wait_readable
|
34
|
+
kgio_wait_readable(timeout) or unreadable_socket!
|
35
|
+
else
|
36
|
+
return rc
|
37
|
+
end while true
|
38
|
+
end
|
39
|
+
|
40
|
+
def timed_write(buf, timeout = 5)
|
41
|
+
written = 0
|
42
|
+
expect = buf.bytesize
|
43
|
+
case rc = kgio_trywrite(buf)
|
44
|
+
when :wait_writable
|
45
|
+
kgio_wait_writable(timeout) or request_truncated!(written, expect)
|
46
|
+
when String
|
47
|
+
written += expect - rc.bytesize
|
48
|
+
buf = rc
|
49
|
+
else
|
50
|
+
return expect
|
51
|
+
end while true
|
52
|
+
end
|
53
|
+
|
54
|
+
alias write timed_write
|
55
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
# -*- encoding: binary -*-
|
2
|
+
# internal implementation details here, do not rely on them in your code
|
3
|
+
|
4
|
+
class MogileFS::Socket < Socket
|
5
|
+
include MogileFS::SocketCommon
|
6
|
+
|
7
|
+
def self.start(host, port)
|
8
|
+
sock = new(Socket::AF_INET, Socket::SOCK_STREAM, 0)
|
9
|
+
begin
|
10
|
+
sock.connect_nonblock(sockaddr_in(port, host))
|
11
|
+
rescue Errno::EINPROGRESS
|
12
|
+
end
|
13
|
+
sock.post_init(host, port)
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.tcp(host, port, timeout = 5)
|
17
|
+
sock = start(host, port)
|
18
|
+
unless IO.select(nil, [ sock ], nil, timeout)
|
19
|
+
sock.close
|
20
|
+
raise MogileFS::Timeout, 'socket connect timeout'
|
21
|
+
end
|
22
|
+
sock
|
23
|
+
end
|
24
|
+
|
25
|
+
def timed_read(len, dst = "", timeout = 5)
|
26
|
+
begin
|
27
|
+
IO.select([self], nil, nil, timeout) or unreadable_socket!
|
28
|
+
return read_nonblock(len, dst)
|
29
|
+
rescue Errno::EAGAIN
|
30
|
+
rescue EOFError
|
31
|
+
return
|
32
|
+
end while true
|
33
|
+
end
|
34
|
+
|
35
|
+
def timed_peek(len, dst, timeout = 5)
|
36
|
+
begin
|
37
|
+
IO.select([self], nil, nil, timeout) or unreadable_socket!
|
38
|
+
rc = recv_nonblock(len, Socket::MSG_PEEK)
|
39
|
+
return rc.empty? ? nil : dst.replace(rc)
|
40
|
+
rescue Errno::EAGAIN
|
41
|
+
rescue EOFError
|
42
|
+
dst.replace("")
|
43
|
+
return
|
44
|
+
end while true
|
45
|
+
end
|
46
|
+
|
47
|
+
def timed_write(buf, timeout = 5)
|
48
|
+
written = 0
|
49
|
+
expect = buf.bytesize
|
50
|
+
begin
|
51
|
+
rc = write_nonblock(buf)
|
52
|
+
return expect if rc == buf.bytesize
|
53
|
+
written += rc
|
54
|
+
|
55
|
+
if buf.respond_to?(:byteslice)
|
56
|
+
buf = buf.byteslice(rc, buf.bytesize)
|
57
|
+
else
|
58
|
+
if buf.respond_to?(:encoding) && buf.encoding != Encoding::BINARY
|
59
|
+
buf = buf.dup.force_encoding(Encoding::BINARY)
|
60
|
+
end
|
61
|
+
buf = buf.slice(rc, buf.bytesize)
|
62
|
+
end
|
63
|
+
rescue Errno::EAGAIN
|
64
|
+
IO.select(nil, [self], nil, timeout) or
|
65
|
+
request_truncated!(written, expect)
|
66
|
+
end while true
|
67
|
+
end
|
68
|
+
|
69
|
+
alias write timed_write
|
70
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
# -*- encoding: binary -*-
|
2
|
+
# internal implementation details here, do not rely on this in your code
|
3
|
+
require "socket"
|
4
|
+
|
5
|
+
module MogileFS::SocketCommon
|
6
|
+
attr_reader :mogilefs_addr
|
7
|
+
|
8
|
+
def post_init(host, port)
|
9
|
+
@mogilefs_addr = "#{host}:#{port}"
|
10
|
+
Socket.const_defined?(:TCP_NODELAY) and
|
11
|
+
setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
|
12
|
+
self
|
13
|
+
end
|
14
|
+
|
15
|
+
def unreadable_socket!
|
16
|
+
raise MogileFS::UnreadableSocketError,
|
17
|
+
"#@mogilefs_addr never became readable"
|
18
|
+
end
|
19
|
+
|
20
|
+
def request_truncated!(written, expect)
|
21
|
+
raise MogileFS::RequestTruncatedError,
|
22
|
+
"request truncated (sent #{written} expected #{expect})"
|
23
|
+
end
|
24
|
+
|
25
|
+
SEP_RE = /\A(.*?#{Regexp.escape("\n")})/
|
26
|
+
def timed_gets(timeout = 5)
|
27
|
+
unless defined?(@rbuf) && @rbuf
|
28
|
+
@rbuf = timed_read(1024, "", timeout) or return # EOF
|
29
|
+
end
|
30
|
+
begin
|
31
|
+
@rbuf.sub!(SEP_RE, "") and return $1
|
32
|
+
tmp ||= ""
|
33
|
+
if timed_read(1024, tmp, timeout)
|
34
|
+
@rbuf << tmp
|
35
|
+
else
|
36
|
+
# EOF, return the last buffered bit even without SEP_RE matching
|
37
|
+
# (not ideal for MogileFS, this is an error)
|
38
|
+
return @rbuf.empty? ? nil : @rbuf.slice!(0, @rbuf.size)
|
39
|
+
end
|
40
|
+
end while true
|
41
|
+
end
|
42
|
+
|
43
|
+
def read(size, buf = "", timeout = 5)
|
44
|
+
timed_read(size, buf, timeout) or return # nil/EOF
|
45
|
+
|
46
|
+
while size > buf.bytesize
|
47
|
+
tmp ||= ""
|
48
|
+
timed_read(size - buf.bytesize, tmp, timeout) or return buf # truncated
|
49
|
+
buf << tmp
|
50
|
+
end
|
51
|
+
|
52
|
+
buf # full read
|
53
|
+
end
|
54
|
+
|
55
|
+
def readpartial(size, buf = "", timeout = 5)
|
56
|
+
timed_read(size, buf, timeout) or raise EOFError, "end of file reached"
|
57
|
+
end
|
58
|
+
end
|
data/lib/mogilefs/util.rb
CHANGED
@@ -1,96 +1,13 @@
|
|
1
1
|
# -*- encoding: binary -*-
|
2
|
-
require 'mogilefs'
|
3
|
-
require 'socket'
|
4
2
|
|
5
3
|
module MogileFS::Util
|
6
4
|
|
7
|
-
|
8
|
-
|
9
|
-
#
|
10
|
-
#
|
11
|
-
#
|
12
|
-
#
|
13
|
-
# string or nil). This can be used to filter I/O through an
|
14
|
-
# Zlib::Inflate or Digest::MD5 object
|
15
|
-
def sysrwloop(io_rd, io_wr, filter = nil)
|
16
|
-
copied = 0
|
17
|
-
# avoid making sysread repeatedly allocate a new String
|
18
|
-
# This is not well-documented, but both read/sysread can take
|
19
|
-
# an optional second argument to use as the buffer to avoid
|
20
|
-
# GC overhead of creating new strings in a loop
|
21
|
-
buf = ' ' * CHUNK_SIZE # preallocate to avoid GC thrashing
|
22
|
-
io_rd.flush rescue nil # flush may be needed for sockets/pipes, be safe
|
23
|
-
io_wr.flush
|
24
|
-
io_rd.sync = io_wr.sync = true
|
25
|
-
loop do
|
26
|
-
b = begin
|
27
|
-
io_rd.sysread(CHUNK_SIZE, buf)
|
28
|
-
rescue Errno::EAGAIN, Errno::EINTR
|
29
|
-
IO.select([io_rd], nil, nil, nil)
|
30
|
-
retry
|
31
|
-
rescue EOFError
|
32
|
-
break
|
33
|
-
end
|
34
|
-
b = filter.call(b) if filter
|
35
|
-
copied += syswrite_full(io_wr, b)
|
36
|
-
end
|
37
|
-
|
38
|
-
# filter must take nil as a possible argument to indicate EOF
|
39
|
-
if filter
|
40
|
-
b = filter.call(nil)
|
41
|
-
copied += syswrite_full(io_wr, b) if b && b.length > 0
|
42
|
-
end
|
43
|
-
copied
|
44
|
-
end # sysrwloop
|
45
|
-
|
46
|
-
# writes the contents of buf to io_wr in full w/o blocking
|
47
|
-
def syswrite_full(io_wr, buf, timeout = nil)
|
48
|
-
written = 0
|
49
|
-
loop do
|
50
|
-
begin
|
51
|
-
w = io_wr.syswrite(buf)
|
52
|
-
written += w
|
53
|
-
return written if w == buf.size
|
54
|
-
buf = buf[w..-1]
|
55
|
-
|
56
|
-
# a short syswrite means the next syswrite will likely block
|
57
|
-
# inside the interpreter. so force an IO.select on it so we can
|
58
|
-
# timeout there if one was specified
|
59
|
-
raise Errno::EAGAIN if timeout
|
60
|
-
rescue Errno::EAGAIN, Errno::EINTR
|
61
|
-
t0 = Time.now if timeout
|
62
|
-
IO.select(nil, [io_wr], nil, timeout)
|
63
|
-
if timeout && ((timeout -= (Time.now - t0)) < 0)
|
64
|
-
raise MogileFS::Timeout, 'syswrite_full timeout'
|
65
|
-
end
|
66
|
-
end
|
67
|
-
end
|
68
|
-
# should never get here
|
69
|
-
end
|
70
|
-
|
71
|
-
def sysread_full(io_rd, size, timeout = nil, full_timeout = false)
|
72
|
-
tmp = [] # avoid expensive string concatenation with every loop iteration
|
73
|
-
reader = io_rd.method(timeout ? :read_nonblock : :sysread)
|
74
|
-
begin
|
75
|
-
while size > 0
|
76
|
-
tmp << reader.call(size)
|
77
|
-
size -= tmp.last.size
|
78
|
-
end
|
79
|
-
rescue Errno::EAGAIN, Errno::EINTR
|
80
|
-
t0 = Time.now
|
81
|
-
r = IO.select([ io_rd ], nil, nil, timeout)
|
82
|
-
if timeout
|
83
|
-
timeout -= (Time.now - t0) if full_timeout
|
84
|
-
if !(r && r[0]) || timeout < 0
|
85
|
-
raise MogileFS::Timeout, 'sysread_full timeout'
|
86
|
-
end
|
87
|
-
end
|
88
|
-
retry
|
89
|
-
rescue EOFError
|
90
|
-
end
|
91
|
-
tmp.join('')
|
92
|
-
end
|
93
|
-
|
5
|
+
# MogileFS::Util::StoreContent allows you to roll your own method
|
6
|
+
# of streaming data on an upload (instead of using a string or file)
|
7
|
+
#
|
8
|
+
# Current versions of this library support streaming a IO or IO-like
|
9
|
+
# object to using MogileFS::MogileFS#store_file, so using StoreContent
|
10
|
+
# may no longer be necessary.
|
94
11
|
class StoreContent < Proc
|
95
12
|
def initialize(total_size, &writer_proc)
|
96
13
|
@total_size = total_size
|
@@ -100,7 +17,6 @@ module MogileFS::Util
|
|
100
17
|
@total_size
|
101
18
|
end
|
102
19
|
end
|
103
|
-
|
104
20
|
end
|
105
21
|
|
106
22
|
require 'timeout'
|
@@ -109,82 +25,3 @@ require 'timeout'
|
|
109
25
|
# reason we require the 'timeout' module, otherwise that module is
|
110
26
|
# broken and worthless to us.
|
111
27
|
class MogileFS::Timeout < Timeout::Error; end
|
112
|
-
|
113
|
-
class Socket
|
114
|
-
attr_accessor :mogilefs_addr, :mogilefs_connected, :mogilefs_size
|
115
|
-
|
116
|
-
# Socket lacks peeraddr method of the IPSocket/TCPSocket classes
|
117
|
-
def mogilefs_peername
|
118
|
-
Socket.unpack_sockaddr_in(getpeername).reverse.map {|x| x.to_s }.join(':')
|
119
|
-
end
|
120
|
-
|
121
|
-
def mogilefs_init(host = nil, port = nil)
|
122
|
-
return true if defined?(@mogilefs_connected)
|
123
|
-
|
124
|
-
@mogilefs_addr = Socket.sockaddr_in(port, host).freeze if port && host
|
125
|
-
|
126
|
-
begin
|
127
|
-
connect_nonblock(@mogilefs_addr)
|
128
|
-
@mogilefs_connected = true
|
129
|
-
rescue Errno::EINPROGRESS
|
130
|
-
nil
|
131
|
-
rescue Errno::EISCONN
|
132
|
-
@mogilefs_connected = true
|
133
|
-
end
|
134
|
-
end
|
135
|
-
|
136
|
-
class << self
|
137
|
-
|
138
|
-
# Creates a new (TCP) Socket and initiates (but does not wait for) the
|
139
|
-
# connection
|
140
|
-
def mogilefs_new_nonblock(host, port)
|
141
|
-
sock = Socket.new(Socket::AF_INET, Socket::SOCK_STREAM, 0)
|
142
|
-
if defined?(Socket::TCP_NODELAY)
|
143
|
-
sock.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
|
144
|
-
end
|
145
|
-
sock.mogilefs_init(host, port)
|
146
|
-
sock
|
147
|
-
end
|
148
|
-
|
149
|
-
# Like TCPSocket.new(host, port), but with an explicit timeout
|
150
|
-
# (and we don't care for local address/port we're binding to).
|
151
|
-
# This raises MogileFS::Timeout if timeout expires
|
152
|
-
def mogilefs_new(host, port, timeout = 5.0)
|
153
|
-
sock = mogilefs_new_nonblock(host, port) or return sock
|
154
|
-
|
155
|
-
while timeout > 0
|
156
|
-
t0 = Time.now
|
157
|
-
r = IO.select(nil, [sock], nil, timeout)
|
158
|
-
return sock if r && r[1] && sock.mogilefs_init
|
159
|
-
timeout -= (Time.now - t0)
|
160
|
-
end
|
161
|
-
|
162
|
-
sock.close rescue nil
|
163
|
-
raise MogileFS::Timeout, 'socket write timeout'
|
164
|
-
end
|
165
|
-
|
166
|
-
include MogileFS::Util
|
167
|
-
|
168
|
-
# Makes a request on a new TCP Socket and returns with a readble socket
|
169
|
-
# within the given timeout.
|
170
|
-
# This raises MogileFS::Timeout if timeout expires
|
171
|
-
def mogilefs_new_request(host, port, request, timeout = 5.0)
|
172
|
-
t0 = Time.now
|
173
|
-
sock = mogilefs_new(host, port, timeout)
|
174
|
-
syswrite_full(sock, request, timeout)
|
175
|
-
timeout -= (Time.now - t0)
|
176
|
-
if timeout < 0
|
177
|
-
sock.close rescue nil
|
178
|
-
raise MogileFS::Timeout, 'socket read timeout'
|
179
|
-
end
|
180
|
-
r = IO.select([sock], nil, nil, timeout)
|
181
|
-
return sock if r && r[0]
|
182
|
-
|
183
|
-
sock.close rescue nil
|
184
|
-
raise MogileFS::Timeout, 'socket read timeout'
|
185
|
-
end
|
186
|
-
|
187
|
-
end
|
188
|
-
|
189
|
-
end
|
190
|
-
|
data/test/aggregate.rb
CHANGED
@@ -1,14 +1,14 @@
|
|
1
|
-
#!/usr/bin/ruby
|
1
|
+
#!/usr/bin/ruby
|
2
2
|
# -*- encoding: binary -*-
|
3
|
-
|
3
|
+
$tests = $assertions = $failures = $errors = 0
|
4
4
|
|
5
|
-
|
6
|
-
|
7
|
-
$
|
8
|
-
$
|
9
|
-
$
|
5
|
+
STDIN.each_line do |l|
|
6
|
+
l =~ /(\d+) tests, (\d+) assertions, (\d+) failures, (\d+) errors/ or next
|
7
|
+
$tests += $1.to_i
|
8
|
+
$assertions += $2.to_i
|
9
|
+
$failures += $3.to_i
|
10
|
+
$errors += $4.to_i
|
11
|
+
end
|
10
12
|
|
11
|
-
|
12
|
-
|
13
|
-
$tests, $assertions, $failures, $errors)
|
14
|
-
}
|
13
|
+
printf("\n%d tests, %d assertions, %d failures, %d errors\n",
|
14
|
+
$tests, $assertions, $failures, $errors)
|