Skip to content

Commit 448d341

Browse files
authored
🔀 Merge pull request #93 from ruby/response-handlers
💥 Changes to responses handling, motivated by thread-safety
2 parents 14ad314 + 800edb8 commit 448d341

File tree

2 files changed

+231
-52
lines changed

2 files changed

+231
-52
lines changed

lib/net/imap.rb

Lines changed: 121 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -511,10 +511,12 @@ module Net
511511
#
512512
# - #greeting: The server's initial untagged response, which can indicate a
513513
# pre-authenticated connection.
514-
# - #responses: A hash with arrays of unhandled <em>non-+nil+</em>
515-
# UntaggedResponse and ResponseCode +#data+, keyed by +#name+.
514+
# - #responses: Yields unhandled UntaggedResponse#data and <em>non-+nil+</em>
515+
# ResponseCode#data.
516+
# - #clear_responses: Deletes unhandled data from #responses and returns it.
516517
# - #add_response_handler: Add a block to be called inside the receiver thread
517518
# with every server response.
519+
# - #response_handlers: Returns the list of response handlers.
518520
# - #remove_response_handler: Remove a previously added response handler.
519521
#
520522
#
@@ -710,22 +712,6 @@ class IMAP < Protocol
710712
# Returns the initial greeting the server, an UntaggedResponse.
711713
attr_reader :greeting
712714

713-
# Returns a hash with arrays of unhandled <em>non-+nil+</em>
714-
# UntaggedResponse#data keyed by UntaggedResponse#name, and
715-
# ResponseCode#data keyed by ResponseCode#name.
716-
#
717-
# For example:
718-
#
719-
# imap.select("inbox")
720-
# p imap.responses["EXISTS"][-1]
721-
# #=> 2
722-
# p imap.responses["UIDVALIDITY"][-1]
723-
# #=> 968263756
724-
attr_reader :responses
725-
726-
# Returns all response handlers.
727-
attr_reader :response_handlers
728-
729715
# Seconds to wait until a connection is opened.
730716
# If the IMAP object cannot open a connection within this time,
731717
# it raises a Net::OpenTimeout exception. The default value is 30 seconds.
@@ -734,8 +720,6 @@ class IMAP < Protocol
734720
# Seconds to wait until an IDLE response is received.
735721
attr_reader :idle_response_timeout
736722

737-
attr_accessor :client_thread # :nodoc:
738-
739723
# The hostname this client connected to
740724
attr_reader :host
741725

@@ -768,6 +752,11 @@ class << self
768752
alias default_ssl_port default_tls_port
769753
end
770754

755+
def client_thread # :nodoc:
756+
warn "Net::IMAP#client_thread is deprecated and will be removed soon."
757+
@client_thread
758+
end
759+
771760
# Disconnects from the server.
772761
#
773762
# Related: #logout
@@ -1084,11 +1073,11 @@ def login(user, password)
10841073
# to select a +mailbox+ so that messages in the +mailbox+ can be accessed.
10851074
#
10861075
# After you have selected a mailbox, you may retrieve the number of items in
1087-
# that mailbox from <tt>imap.responses["EXISTS"][-1]</tt>, and the number of
1088-
# recent messages from <tt>imap.responses["RECENT"][-1]</tt>. Note that
1089-
# these values can change if new messages arrive during a session or when
1090-
# existing messages are expunged; see #add_response_handler for a way to
1091-
# detect these events.
1076+
# that mailbox from <tt>imap.responses("EXISTS", &:last)</tt>, and the
1077+
# number of recent messages from <tt>imap.responses("RECENT", &:last)</tt>.
1078+
# Note that these values can change if new messages arrive during a session
1079+
# or when existing messages are expunged; see #add_response_handler for a
1080+
# way to detect these events.
10921081
#
10931082
# A Net::IMAP::NoResponseError is raised if the mailbox does not
10941083
# exist or is for some reason non-selectable.
@@ -1954,6 +1943,104 @@ def idle_done
19541943
end
19551944
end
19561945

1946+
# :call-seq:
1947+
# responses {|hash| ...} -> block result
1948+
# responses(type) {|array| ...} -> block result
1949+
#
1950+
# Yields unhandled responses and returns the result of the block.
1951+
#
1952+
# Unhandled responses are stored in a hash, with arrays of
1953+
# <em>non-+nil+</em> UntaggedResponse#data keyed by UntaggedResponse#name
1954+
# and ResponseCode#data keyed by ResponseCode#name. Call without +type+ to
1955+
# yield the entire responses hash. Call with +type+ to yield only the array
1956+
# of responses for that type.
1957+
#
1958+
# For example:
1959+
#
1960+
# imap.select("inbox")
1961+
# p imap.responses("EXISTS", &:last)
1962+
# #=> 2
1963+
# p imap.responses("UIDVALIDITY", &:last)
1964+
# #=> 968263756
1965+
#
1966+
# >>>
1967+
# *Note:* Access to the responses hash is synchronized for thread-safety.
1968+
# The receiver thread and response_handlers cannot process new responses
1969+
# until the block completes. Accessing either the response hash or its
1970+
# response type arrays outside of the block is unsafe.
1971+
#
1972+
# Calling without a block is unsafe and deprecated. Future releases will
1973+
# raise ArgumentError unless a block is given.
1974+
#
1975+
# Previously unhandled responses are automatically cleared before entering a
1976+
# mailbox with #select or #examine. Long-lived connections can receive many
1977+
# unhandled server responses, which must be pruned or they will continually
1978+
# consume more memory. Update or clear the responses hash or arrays inside
1979+
# the block, or use #clear_responses.
1980+
#
1981+
# Only non-+nil+ data is stored. Many important response codes have no data
1982+
# of their own, but are used as "tags" on the ResponseText object they are
1983+
# attached to. ResponseText will be accessible by its response types:
1984+
# "+OK+", "+NO+", "+BAD+", "+BYE+", or "+PREAUTH+".
1985+
#
1986+
# TaggedResponse#data is not saved to #responses, nor is any
1987+
# ResponseCode#data on tagged responses. Although some command methods do
1988+
# return the TaggedResponse directly, #add_response_handler must be used to
1989+
# handle all response codes.
1990+
#
1991+
# Related: #clear_responses, #response_handlers, #greeting
1992+
def responses(type = nil)
1993+
if block_given?
1994+
synchronize { yield(type ? @responses[type.to_s.upcase] : @responses) }
1995+
elsif type
1996+
raise ArgumentError, "Pass a block or use #clear_responses"
1997+
else
1998+
# warn("DEPRECATED: pass a block or use #clear_responses", uplevel: 1)
1999+
@responses
2000+
end
2001+
end
2002+
2003+
# :call-seq:
2004+
# clear_responses -> hash
2005+
# clear_responses(type) -> array
2006+
#
2007+
# Clears and returns the unhandled #responses hash or the unhandled
2008+
# responses array for a single response +type+.
2009+
#
2010+
# Clearing responses is synchronized with other threads. The lock is
2011+
# released before returning.
2012+
#
2013+
# Related: #responses, #response_handlers
2014+
def clear_responses(type = nil)
2015+
synchronize {
2016+
if type
2017+
@responses.delete(type) || []
2018+
else
2019+
@responses.dup.transform_values(&:freeze)
2020+
.tap { _1.default = [].freeze }
2021+
.tap { @responses.clear }
2022+
end
2023+
}
2024+
.freeze
2025+
end
2026+
2027+
# Returns all response handlers, including those that are added internally
2028+
# by commands. Each response handler will be called with every new
2029+
# UntaggedResponse, TaggedResponse, and ContinuationRequest.
2030+
#
2031+
# Response handlers are called with a mutex inside the receiver thread. New
2032+
# responses cannot be processed and commands from other threads must wait
2033+
# until all response_handlers return. An exception will shut-down the
2034+
# receiver thread and close the connection.
2035+
#
2036+
# For thread-safety, the returned array is a frozen copy of the internal
2037+
# array.
2038+
#
2039+
# Related: #add_response_handler, #remove_response_handler
2040+
def response_handlers
2041+
synchronize { @response_handlers.clone.freeze }
2042+
end
2043+
19572044
# Adds a response handler. For example, to detect when
19582045
# the server sends a new EXISTS response (which normally
19592046
# indicates new messages being added to the mailbox),
@@ -1966,14 +2053,21 @@ def idle_done
19662053
# end
19672054
# }
19682055
#
2056+
# Related: #remove_response_handler, #response_handlers
19692057
def add_response_handler(handler = nil, &block)
19702058
raise ArgumentError, "two Procs are passed" if handler && block
1971-
@response_handlers.push(block || handler)
2059+
synchronize do
2060+
@response_handlers.push(block || handler)
2061+
end
19722062
end
19732063

19742064
# Removes the response handler.
2065+
#
2066+
# Related: #add_response_handler, #response_handlers
19752067
def remove_response_handler(handler)
1976-
@response_handlers.delete(handler)
2068+
synchronize do
2069+
@response_handlers.delete(handler)
2070+
end
19772071
end
19782072

19792073
private

test/net/imap/test_imap.rb

Lines changed: 110 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -860,7 +860,7 @@ def test_uidplus_responses
860860
assert_equal([38505, [3955], [3967]], resp.data.code.data.to_a)
861861
imap.select('trash')
862862
assert_equal(
863-
imap.responses["NO"].last.code,
863+
imap.responses("NO", &:last).code,
864864
Net::IMAP::ResponseCode.new('UIDNOTSTICKY', nil)
865865
)
866866
imap.logout
@@ -870,37 +870,123 @@ def test_uidplus_responses
870870
end
871871

872872
def yields_in_test_server_thread(
873-
greeting = "* OK [CAPABILITY IMAP4rev1 AUTH=PLAIN STARTTLS] test server\r\n"
873+
read_timeout: 2, # requires ruby 3.2+
874+
timeout: 10,
875+
greeting: "* OK [CAPABILITY IMAP4rev1 AUTH=PLAIN STARTTLS] test server\r\n"
874876
)
875877
server = create_tcp_server
876878
port = server.addr[1]
879+
last_tag, last_cmd, last_args = nil
877880
@threads << Thread.start do
878-
sock = server.accept
879-
gets = ->{
880-
buf = "".b
881-
buf << sock.gets until /\A([^ ]+) ([^ ]+) ?(.*)\r\n\z/mn =~ buf
882-
[$1, $2, $3]
883-
}
884-
begin
885-
sock.print(greeting)
886-
last_tag = yield sock, gets
887-
sock.print("* BYE terminating connection\r\n")
888-
sock.print("#{last_tag} OK LOGOUT completed\r\n") if last_tag
889-
ensure
890-
sock.close
891-
server.close
881+
Timeout.timeout(timeout) do
882+
sock = server.accept
883+
sock.timeout = read_timeout if sock.respond_to? :timeout # ruby 3.2+
884+
sock.singleton_class.define_method(:getcmd) do
885+
buf = "".b
886+
buf << (sock.gets || "") until /\A([^ ]+) ([^ ]+) ?(.*)\r\n\z/mn =~ buf
887+
[last_tag = $1, last_cmd = $2, last_args = $3]
888+
end
889+
begin
890+
sock.print(greeting)
891+
yield sock
892+
ensure
893+
begin
894+
sock.print("* BYE terminating connection\r\n")
895+
last_cmd =~ /LOGOUT/i and
896+
sock.print("#{last_tag} OK LOGOUT completed\r\n")
897+
ensure
898+
sock.close
899+
server.close
900+
end
901+
end
892902
end
893903
end
894904
port
895905
end
896906

907+
# SELECT returns many different untagged results, so this is useful for
908+
# several different tests.
909+
RFC3501_6_3_1_SELECT_EXAMPLE_DATA = <<~RESPONSES
910+
* 172 EXISTS
911+
* 1 RECENT
912+
* OK [UNSEEN 12] Message 12 is first unseen
913+
* OK [UIDVALIDITY 3857529045] UIDs valid
914+
* OK [UIDNEXT 4392] Predicted next UID
915+
* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)
916+
* OK [PERMANENTFLAGS (\\Deleted \\Seen \\*)] Limited
917+
%{tag} OK [READ-WRITE] SELECT completed
918+
RESPONSES
919+
.split("\n").join("\r\n").concat("\r\n").freeze
920+
921+
def test_responses
922+
port = yields_in_test_server_thread do |sock|
923+
tag, name, = sock.getcmd
924+
if name == "SELECT"
925+
sock.print RFC3501_6_3_1_SELECT_EXAMPLE_DATA % {tag: tag}
926+
end
927+
sock.getcmd # waits for logout command
928+
end
929+
begin
930+
imap = Net::IMAP.new(server_addr, port: port)
931+
resp = imap.select "INBOX"
932+
assert_equal([Net::IMAP::TaggedResponse, "RUBY0001", "OK"],
933+
[resp.class, resp.tag, resp.name])
934+
assert_equal([172], imap.responses { _1["EXISTS"] })
935+
assert_equal([3857529045], imap.responses("UIDVALIDITY") { _1 })
936+
assert_equal(1, imap.responses("RECENT", &:last))
937+
assert_raise(ArgumentError) do imap.responses("UIDNEXT") end
938+
# Deprecated style, without a block:
939+
# assert_warn(/Pass a block.*or.*clear_responses/i) do
940+
# assert_equal(%i[Answered Flagged Deleted Seen Draft],
941+
# imap.responses["FLAGS"]&.last)
942+
# end
943+
imap.logout
944+
ensure
945+
imap.disconnect if imap
946+
end
947+
end
948+
949+
def test_clear_responses
950+
port = yields_in_test_server_thread do |sock|
951+
tag, name, = sock.getcmd
952+
if name == "SELECT"
953+
sock.print RFC3501_6_3_1_SELECT_EXAMPLE_DATA % {tag: tag}
954+
end
955+
sock.getcmd # waits for logout command
956+
end
957+
begin
958+
imap = Net::IMAP.new(server_addr, port: port)
959+
resp = imap.select "INBOX"
960+
assert_equal([Net::IMAP::TaggedResponse, "RUBY0001", "OK"],
961+
[resp.class, resp.tag, resp.name])
962+
# called with "type", clears and returns only that type
963+
assert_equal([172], imap.clear_responses("EXISTS"))
964+
assert_equal([], imap.clear_responses("EXISTS"))
965+
assert_equal([1], imap.clear_responses("RECENT"))
966+
assert_equal([3857529045], imap.clear_responses("UIDVALIDITY"))
967+
# called without "type", clears and returns all responses
968+
responses = imap.clear_responses
969+
assert_equal([], responses["EXISTS"])
970+
assert_equal([], responses["RECENT"])
971+
assert_equal([], responses["UIDVALIDITY"])
972+
assert_equal([12], responses["UNSEEN"])
973+
assert_equal([4392], responses["UIDNEXT"])
974+
assert_equal(5, responses["FLAGS"].last&.size)
975+
assert_equal(3, responses["PERMANENTFLAGS"].last&.size)
976+
assert_equal({}, imap.responses(&:itself))
977+
assert_equal({}, imap.clear_responses)
978+
imap.logout
979+
ensure
980+
imap.disconnect if imap
981+
end
982+
end
983+
897984
def test_close
898985
requests = Queue.new
899-
port = yields_in_test_server_thread do |sock, gets|
900-
requests.push(gets[])
986+
port = yields_in_test_server_thread do |sock|
987+
requests << sock.getcmd
901988
sock.print("RUBY0001 OK CLOSE completed\r\n")
902-
requests.push(gets[])
903-
"RUBY0002"
989+
requests << sock.getcmd
904990
end
905991
begin
906992
imap = Net::IMAP.new(server_addr, :port => port)
@@ -917,14 +1003,13 @@ def test_close
9171003

9181004
def test_unselect
9191005
requests = Queue.new
920-
port = yields_in_test_server_thread do |sock, gets|
921-
requests.push(gets[])
1006+
port = yields_in_test_server_thread do |sock|
1007+
requests << sock.getcmd
9221008
sock.print("RUBY0001 OK UNSELECT completed\r\n")
923-
requests.push(gets[])
924-
"RUBY0002"
1009+
requests << sock.getcmd
9251010
end
9261011
begin
927-
imap = Net::IMAP.new(server_addr, :port => port)
1012+
imap = Net::IMAP.new(server_addr, port: port)
9281013
resp = imap.unselect
9291014
assert_equal(["RUBY0001", "UNSELECT", ""], requests.pop)
9301015
assert_equal([Net::IMAP::TaggedResponse, "RUBY0001", "OK"],

0 commit comments

Comments
 (0)