sup

A curses threads-with-tags style email client

sup.git

git clone https://supmua.dev/git/sup/
commit 9689d77e242351c6a8a4448172c192e33c238352
parent 05a863b71c4ea43f3af8156f0fe1045c7229e438
Author: Rich Lane <rlane@club.cc.cmu.edu>
Date:   Wed,  9 Jun 2010 18:13:23 -0700

Merge branch 'sup-server-work'

Diffstat:
M bin/sup | 4 ++--
M bin/sup-cmd | 11 +++++++++--
M bin/sup-server | 3 ++-
M lib/sup.rb | 157 +++++++++++++++++++++++++++++++++++++++++++------------------------------------
M lib/sup/client.rb | 6 +++++-
M lib/sup/hook.rb | 13 +++++++++----
M lib/sup/poll.rb | 7 +++----
M lib/sup/protocol.rb | 20 +++++++++++++++++---
M lib/sup/server.rb | 33 +++++++++++++++++++++------------
M lib/sup/util.rb | 2 +-
A test/test_server.rb | 106 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
11 files changed, 260 insertions(+), 102 deletions(-)
diff --git a/bin/sup b/bin/sup
@@ -58,6 +58,7 @@ No return value.
 EOS
 
 if $opts[:list_hooks]
+  Redwood.start
   Redwood::HookManager.print_hooks
   exit
 end
@@ -95,8 +96,6 @@ global_keymap = Keymap.new do |k|
     kk.add :run_keybindings_hook, "Rerun keybindings hook", 'k'
   end
 end
-  
-Redwood::Keymap.run_hook global_keymap
 
 ## the following magic enables wide characters when used with a ruby
 ## ncurses.so that's been compiled against libncursesw. (note the w.) why
@@ -173,6 +172,7 @@ begin
   end
 
   HookManager.run "startup"
+  Redwood::Keymap.run_hook global_keymap
 
   debug "starting curses"
   Redwood::Logger.remove_sink $stderr
diff --git a/bin/sup-cmd b/bin/sup-cmd
@@ -23,8 +23,12 @@ EOS
 
   opt :host, "server address", :type => :string, :default => 'localhost', :short => 'o'
   opt :port, "server port", :type => :int, :default => 4300
+  opt :socket, "unix domain socket path", :type => :string, :default => nil
   opt :verbose
 
+  conflicts :host, :socket
+  conflicts :port, :socket
+
   stop_on SUB_COMMANDS
 end
 
@@ -123,8 +127,11 @@ end
 
 
 EM.run do
-  EM.connect global_opts[:host], global_opts[:port],
-             SupCmd, cmd, ARGV, cmd_opts.merge(global_opts)
+  if global_opts[:socket]
+    EM.connect global_opts[:socket], SupCmd, cmd, ARGV, cmd_opts.merge(global_opts)
+  else
+    EM.connect global_opts[:host], global_opts[:port], SupCmd, cmd, ARGV, cmd_opts.merge(global_opts)
+  end
 end
 
 exit 0
diff --git a/bin/sup-server b/bin/sup-server
@@ -34,7 +34,8 @@ begin
   Index.load
 
   EM.run do
-    EM.start_server global_opts[:host], global_opts[:port], Redwood::Server
+    EM.start_server global_opts[:host], global_opts[:port],
+                    Redwood::Server, Index.instance
     EM.next_tick { puts "ready" }
   end
 
diff --git a/lib/sup.rb b/lib/sup.rb
@@ -120,26 +120,41 @@ module Redwood
     o
   end
 
+  def managers
+    %w(HookManager SentManager ContactManager LabelManager AccountManager
+    DraftManager UpdateManager PollManager CryptoManager UndoManager
+    SourceManager SearchManager IdleManager).map { |x| Redwood.const_get x.to_sym }
+  end
+
   def start
+    managers.each { |x| fail "#{x} already instantiated" if x.instantiated? }
+
+    FileUtils.mkdir_p Redwood::BASE_DIR
+    $config = load_config Redwood::CONFIG_FN
+    @log_io = File.open(Redwood::LOG_FN, 'a')
+    Redwood::Logger.add_sink @log_io
+    Redwood::HookManager.init Redwood::HOOK_DIR
     Redwood::SentManager.init $config[:sent_source] || 'sup://sent'
     Redwood::ContactManager.init Redwood::CONTACT_FN
     Redwood::LabelManager.init Redwood::LABEL_FN
     Redwood::AccountManager.init $config[:accounts]
     Redwood::DraftManager.init Redwood::DRAFT_DIR
-    Redwood::UpdateManager.init
-    Redwood::PollManager.init
-    Redwood::CryptoManager.init
-    Redwood::UndoManager.init
-    Redwood::SourceManager.init
     Redwood::SearchManager.init Redwood::SEARCH_FN
-    Redwood::IdleManager.init
+
+    managers.each { |x| x.init unless x.instantiated? }
   end
 
   def finish
     Redwood::LabelManager.save if Redwood::LabelManager.instantiated?
     Redwood::ContactManager.save if Redwood::ContactManager.instantiated?
-    Redwood::BufferManager.deinstantiate! if Redwood::BufferManager.instantiated?
     Redwood::SearchManager.save if Redwood::SearchManager.instantiated?
+    Redwood::Logger.remove_sink @log_io
+
+    managers.each { |x| x.deinstantiate! if x.instantiated? }
+
+    @log_io.close
+    @log_io = nil
+    $config = nil
   end
 
   ## not really a good place for this, so I'll just dump it here.
@@ -221,85 +236,83 @@ EOS
     end
   end
 
-  module_function :save_yaml_obj, :load_yaml_obj, :start, :finish,
-                  :report_broken_sources, :check_library_version_against
-end
-
-## set up default configuration file
-if File.exists? Redwood::CONFIG_FN
-  $config = Redwood::load_yaml_obj Redwood::CONFIG_FN
-  abort "#{Redwood::CONFIG_FN} is not a valid configuration file (it's a #{$config.class}, not a hash)" unless $config.is_a?(Hash)
-else
-  require 'etc'
-  require 'socket'
-  name = Etc.getpwnam(ENV["USER"]).gecos.split(/,/).first rescue nil
-  name ||= ENV["USER"]
-  email = ENV["USER"] + "@" + 
-    begin
-      Socket.gethostbyname(Socket.gethostname).first
-    rescue SocketError
-      Socket.gethostname
-    end
+  ## set up default configuration file
+  def load_config filename
+    if File.exists? filename
+      config = Redwood::load_yaml_obj filename
+      abort "#{filename} is not a valid configuration file (it's a #{config.class}, not a hash)" unless config.is_a?(Hash)
+      config
+    else
+      require 'etc'
+      require 'socket'
+      name = Etc.getpwnam(ENV["USER"]).gecos.split(/,/).first rescue nil
+      name ||= ENV["USER"]
+      email = ENV["USER"] + "@" + 
+        begin
+          Socket.gethostbyname(Socket.gethostname).first
+        rescue SocketError
+          Socket.gethostname
+        end
 
-  $config = {
-    :accounts => {
-      :default => {
-        :name => name,
-        :email => email,
-        :alternates => [],
-        :sendmail => "/usr/sbin/sendmail -oem -ti",
-        :signature => File.join(ENV["HOME"], ".signature")
+      config = {
+        :accounts => {
+          :default => {
+            :name => name,
+            :email => email,
+            :alternates => [],
+            :sendmail => "/usr/sbin/sendmail -oem -ti",
+            :signature => File.join(ENV["HOME"], ".signature")
+          }
+        },
+        :editor => ENV["EDITOR"] || "/usr/bin/vim -f -c 'setlocal spell spelllang=en_us' -c 'set filetype=mail'",
+        :thread_by_subject => false,
+        :edit_signature => false,
+        :ask_for_from => false,
+        :ask_for_to => true,
+        :ask_for_cc => true,
+        :ask_for_bcc => false,
+        :ask_for_subject => true,
+        :confirm_no_attachments => true,
+        :confirm_top_posting => true,
+        :jump_to_open_message => true,
+        :discard_snippets_from_encrypted_messages => false,
+        :default_attachment_save_dir => "",
+        :sent_source => "sup://sent",
+        :poll_interval => 300,
+        :wrap_width => 0,
+        :slip_rows => 0
       }
-    },
-    :editor => ENV["EDITOR"] || "/usr/bin/vim -f -c 'setlocal spell spelllang=en_us' -c 'set filetype=mail'",
-    :thread_by_subject => false,
-    :edit_signature => false,
-    :ask_for_from => false,
-    :ask_for_to => true,
-    :ask_for_cc => true,
-    :ask_for_bcc => false,
-    :ask_for_subject => true,
-    :confirm_no_attachments => true,
-    :confirm_top_posting => true,
-    :jump_to_open_message => true,
-    :discard_snippets_from_encrypted_messages => false,
-    :default_attachment_save_dir => "",
-    :sent_source => "sup://sent",
-    :poll_interval => 300,
-    :wrap_width => 0,
-    :slip_rows => 0
-  }
-  begin
-    FileUtils.mkdir_p Redwood::BASE_DIR
-    Redwood::save_yaml_obj $config, Redwood::CONFIG_FN
-  rescue StandardError => e
-    $stderr.puts "warning: #{e.message}"
+      begin
+        Redwood::save_yaml_obj config, filename
+      rescue StandardError => e
+        $stderr.puts "warning: #{e.message}"
+      end
+      config
+    end
   end
+
+  module_function :save_yaml_obj, :load_yaml_obj, :start, :finish,
+                  :report_broken_sources, :check_library_version_against,
+                  :load_config, :managers
 end
 
 require "sup/util"
 require "sup/hook"
 
-## we have to initialize this guy first, because other classes must
-## reference it in order to register hooks, and they do that at parse
-## time.
-Redwood::HookManager.init Redwood::HOOK_DIR
-
 ## everything we need to get logging working
 require "sup/logger"
 Redwood::Logger.init.add_sink $stderr
-Redwood::Logger.add_sink File.open(Redwood::LOG_FN, 'a')
 include Redwood::LogsStuff
 
 ## determine encoding and character set
-  $encoding = Locale.current.charset
-  $encoding = "UTF-8" if $encoding == "utf8"
-  if $encoding
-    debug "using character set encoding #{$encoding.inspect}"
-  else
-    warn "can't find character set by using locale, defaulting to utf-8"
-    $encoding = "UTF-8"
-  end
+$encoding = Locale.current.charset
+$encoding = "UTF-8" if $encoding == "utf8"
+if $encoding
+  debug "using character set encoding #{$encoding.inspect}"
+else
+  warn "can't find character set by using locale, defaulting to utf-8"
+  $encoding = "UTF-8"
+end
 
 require "sup/buffer"
 require "sup/keymap"
diff --git a/lib/sup/client.rb b/lib/sup/client.rb
@@ -1,6 +1,8 @@
 require 'sup/protocol'
 
-class Redwood::Client < EM::P::RedwoodClient
+module Redwood
+
+class Client < EM::P::RedwoodClient
   def initialize *a
     @next_tag = 1
     @cbs = {}
@@ -86,3 +88,5 @@ class Redwood::Client < EM::P::RedwoodClient
     cb[type, tag, args]
   end
 end
+
+end
diff --git a/lib/sup/hook.rb b/lib/sup/hook.rb
@@ -61,10 +61,15 @@ class HookManager
 
   include Singleton
 
+  @descs = {}
+
+  class << self
+    attr_reader :descs
+  end
+
   def initialize dir
     @dir = dir
     @hooks = {}
-    @descs = {}
     @contexts = {}
     @tags = {}
 
@@ -90,17 +95,17 @@ class HookManager
     result
   end
 
-  def register name, desc
+  def self.register name, desc
     @descs[name] = desc
   end
 
   def print_hooks f=$stdout
 puts <<EOS
-Have #{@descs.size} registered hooks:
+Have #{HookManager.descs.size} registered hooks:
 
 EOS
 
-    @descs.sort.each do |name, desc|
+    HookManager.descs.sort.each do |name, desc|
       f.puts <<EOS
 #{name}
 #{"-" * name.length}
diff --git a/lib/sup/poll.rb b/lib/sup/poll.rb
@@ -28,9 +28,8 @@ num_inbox_total_unread: the total number of unread messages in the inbox
                         only those messages appearing in the inbox
 EOS
 
-  DELAY = $config[:poll_interval] || 300
-
   def initialize
+    @delay = $config[:poll_interval] || 300
     @mutex = Mutex.new
     @thread = nil
     @last_poll = nil
@@ -83,8 +82,8 @@ EOS
   def start
     @thread = Redwood::reporting_thread("periodic poll") do
       while true
-        sleep DELAY / 2
-        poll if @last_poll.nil? || (Time.now - @last_poll) >= DELAY
+        sleep @delay / 2
+        poll if @last_poll.nil? || (Time.now - @last_poll) >= @delay
       end
     end
   end
diff --git a/lib/sup/protocol.rb b/lib/sup/protocol.rb
@@ -7,9 +7,12 @@ class EM::P::Redwood < EM::Connection
   VERSION = 1
   ENCODINGS = %w(marshal json)
 
+  attr_reader :debug
+
   def initialize *args
     @state = :negotiating
     @version_buf = ""
+    @debug = false
     super
   end
 
@@ -26,12 +29,15 @@ class EM::P::Redwood < EM::Connection
         receive_data x
       end
     else
-      @filter.decode(data).each { |msg| receive_message *msg }
+      @filter.decode(data).each do |msg|
+        puts "#{self.class.name} received: #{msg.inspect}" if @debug
+        validate_message *msg
+        receive_message *msg
+      end
     end
   end
 
   def connection_established
-    puts "client connection established"
   end
 
   def send_version encodings, extensions
@@ -41,6 +47,8 @@ class EM::P::Redwood < EM::Connection
 
   def send_message type, tag, params={}
     fail "attempted to send message during negotiation" unless @state == :established
+    puts "#{self.class.name} sent: #{[type, tag, params].inspect}" if @debug
+    validate_message type, tag, params
     send_data @filter.encode([type,tag,params])
   end
 
@@ -48,12 +56,18 @@ class EM::P::Redwood < EM::Connection
     fail "unimplemented"
   end
 
-  def receive_message type, params
+  def receive_message type, tag, params
     fail "unimplemented"
   end
 
 private
 
+  def validate_message type, tag, params
+    fail unless type.is_a? String or type.is_a? Symbol
+    fail unless tag.is_a? String or tag.is_a? Integer
+    fail unless params.is_a? Hash
+  end
+
   def parse_version l
     l =~ /^Redwood\s+(\d+)\s+([\w,]+)\s+([\w,]+)$/ or fail "unexpected banner #{l.inspect}"
     version, encodings, extensions = $1.to_i, $2, $3
diff --git a/lib/sup/server.rb b/lib/sup/server.rb
@@ -1,16 +1,23 @@
 require 'sup/protocol'
 
-class Redwood::Server < EM::P::RedwoodServer
+module Redwood
+
+class Server < EM::P::RedwoodServer
+  def initialize index
+    super
+    @index = index
+  end
+
   def receive_message type, tag, params
     if respond_to? :"request_#{type}"
       send :"request_#{type}", tag, params
     else
-      fail "bad request type #{type}"
+      send_message 'error', tag, 'description' => "invalid request type #{type.inspect}"
     end
   end
 
   def request_query tag, a
-    q = Redwood::Index.parse_query a['query']
+    q = @index.parse_query a['query']
     query q, a['offset'], a['limit'], a['raw'] do |r|
       send_message 'message', tag, r
     end
@@ -18,13 +25,13 @@ class Redwood::Server < EM::P::RedwoodServer
   end
 
   def request_count tag, a
-    q = Redwood::Index.parse_query a['query']
+    q = @index.parse_query a['query']
     c = count q
     send_message 'count', tag, 'count' => c
   end
 
   def request_label tag, a
-    q = Redwood::Index.parse_query a['query']
+    q = @index.parse_query a['query']
     label q, a['add'], a['remove']
     send_message 'done', tag
   end
@@ -64,7 +71,7 @@ private
 
   def query query, offset, limit, raw
     c = 0
-    Index.each_message query do |m|
+    @index.each_message query do |m|
       next if c < offset
       break if c >= offset + limit if limit
       yield result_from_message(m, raw)
@@ -74,14 +81,14 @@ private
   end
 
   def count query
-    Index.num_results_for query
+    @index.num_results_for query
   end
 
   def label query, remove_labels, add_labels
-    Index.each_message query do |m|
+    @index.each_message query do |m|
       remove_labels.each { |l| m.remove_label l }
       add_labels.each { |l| m.add_label l }
-      Index.update_message_state m
+      @index.update_message_state m
     end
     nil
   end
@@ -96,15 +103,17 @@ private
       m2 = m
     end
     m2.labels = Set.new(labels.map(&:to_sym))
-    Index.update_message_state m2
+    @index.update_message_state m2
     nil
   end
 
   def thread msg_id, raw
-    msg = Index.build_message msg_id
-    Index.each_message_in_thread_for msg do |id, builder|
+    msg = @index.build_message msg_id
+    @index.each_message_in_thread_for msg do |id, builder|
       m = builder.call
       yield result_from_message(m, raw)
     end
   end
 end
+
+end
diff --git a/lib/sup/util.rb b/lib/sup/util.rb
@@ -580,7 +580,7 @@ module Singleton
       @instance.send meth, *a, &b
     end
     def init *args
-      raise "there can be only one! (instance)" if defined? @instance
+      raise "there can be only one! (instance)" if instantiated?
       @instance = new(*args)
     end
   end
diff --git a/test/test_server.rb b/test/test_server.rb
@@ -0,0 +1,106 @@
+#!/usr/bin/ruby
+# encoding: utf-8
+
+require 'test/unit'
+require 'iconv'
+require 'stringio'
+require 'tmpdir'
+require 'fileutils'
+require 'thread'
+require 'eventmachine'
+require 'sup'
+require 'sup/server'
+
+Thread.abort_on_exception = true
+
+module EM
+  # Run the reactor in a new thread. This is useful for using EventMachine
+  # alongside synchronous code. It is recommended to use EM.error_handler to
+  # detect when an exception terminates the reactor thread.
+  def self.spawn_reactor_thread
+    fail "reactor already started" if EM.reactor_running?
+    q = ::Queue.new
+    Thread.new { EM.run { q << nil } }
+    q.pop
+  end
+
+  # Stop the reactor and wait for it to finish. This is the counterpart to #spawn_reactor_thread.
+  def self.kill_reactor_thread
+    fail "reactor is not running" unless EM.reactor_running?
+    fail "current thread is running the reactor" if EM.reactor_thread?
+    EM.stop
+    EM.reactor_thread.join
+  end
+end
+
+class QueueingClient < EM::P::RedwoodClient
+  def initialize
+    super
+    @q = Queue.new
+    @readyq = Queue.new
+  end
+
+  def receive_message type, tag, params
+    @q << [type, tag, params]
+  end
+
+  def connection_established
+    @readyq << nil
+  end
+
+  def wait_until_ready
+    @readyq.pop
+  end
+
+  def read
+    @q.pop
+  end
+
+  alias write send_message
+end
+
+class TestServer < Test::Unit::TestCase
+  def setup
+    port = rand(1000) + 30000
+    EM.spawn_reactor_thread
+    @path = Dir.mktmpdir
+    socket_path = File.join(@path, 'socket')
+    Redwood::HookManager.init File.join(@path, 'hooks')
+    Redwood::SourceManager.init
+    Redwood::SourceManager.load_sources File.join(@path, 'sources.yaml')
+    Redwood::Index.init @path
+    Redwood::SearchManager.init File.join(@path, 'searches')
+    Redwood::Index.load
+    @server = EM.start_server socket_path,
+              Redwood::Server, Redwood::Index.instance
+    @client = EM.connect socket_path, QueueingClient
+    @client.wait_until_ready
+  end
+
+  def teardown
+    FileUtils.rm_r @path if passed?
+    puts "not cleaning up #{@path}" unless passed?
+    %w(Index SearchManager SourceManager HookManager).each do |x|
+      Redwood.const_get(x.to_sym).deinstantiate!
+    end
+    EM.kill_reactor_thread
+  end
+
+  def test_invalid_request
+    @client.write 'foo', '1'
+    check @client.read, 'error', '1'
+  end
+
+  def test_query
+    @client.write 'query', '1', 'query' => 'type:mail'
+    check @client.read, 'done', '1'
+  end
+
+  def check resp, type, tag, args={}
+    assert_equal type.to_s, resp[0]
+    assert_equal tag.to_s, resp[1]
+    args.each do |k,v|
+      assert_equal v, resp[2][k.to_s]
+    end
+  end
+end