module Main
where
+-- System imports.
import Control.Concurrent ( threadDelay )
-import Control.Exception.Base ( bracket )
+import Control.Exception ( bracket, throw )
import Control.Monad ( when )
import Data.List ( isPrefixOf )
import Data.Maybe ( isNothing )
import Network (
connectTo,
PortID (PortNumber) )
+import Network.Services.TSN.Logging ( init_logging )
+import Network.Services.TSN.Report (
+ report_debug,
+ report_info,
+ report_warning,
+ report_error )
+import Network.Services.TSN.Terminal ( display_sent )
import System.Console.CmdArgs ( def )
import System.Directory ( doesFileExist )
import System.Exit ( ExitCode(..), exitWith )
import System.IO.Error ( catchIOError )
import System.Timeout ( timeout )
+-- Local imports.
import CommandLine ( get_args )
import Configuration ( Configuration(..), merge_optional )
import ExitCodes (
exit_no_username,
exit_pidfile_exists )
import FeedHosts ( FeedHosts(..) )
-import Network.Services.TSN.Logging ( init_logging )
import qualified OptionalConfiguration as OC (
OptionalConfiguration(..),
from_rc )
-import Network.Services.TSN.Report (
- report_debug,
- report_info,
- report_warning,
- report_error )
-import Network.Services.TSN.Terminal ( display_sent )
import Xml ( parse_xmlfid )
import Unix ( full_daemonize )
--- | Receive a single line of text from a Handle, and send it to the
--- debug log.
+-- | Receive a single line of text from a 'Handle', and record it for
+-- debugging purposes.
--
recv_line :: Handle -> IO String
recv_line h = do
return line
--- | Takes a Configuration, and an XML document (as a String). The XML
--- document is written to the output directory, as specified by the
--- Configuration.
+-- | Takes a 'Configuration', and an XML document (as a 'String'). The
+-- XML document is written to the output directory, as specified by
+-- the 'Configuration'.
--
-- This can fail, but we don't purposefully throw any exceptions. If
-- something goes wrong, we would rather log it and keep going.
--
-save_document :: Configuration -> String -> IO ()
+save_document :: Configuration
+ -> String -- ^ String representation of an XML document
+ -> IO ()
save_document cfg doc =
case either_path of
Left err -> report_error err
either_path = fmap ((output_directory cfg) </>) filename
--- | Loop forever, writing the buffer to file whenever a </message>
--- tag is seen. This is the low-level "loop forever" function that
--- we stay in as long as we are connected to one feed.
+-- | Loop forever, writing the @buffer@ to file whenever a
+-- \</message\> tag is seen. This is the low-level \"loop forever\"
+-- function that we stay in as long as we are connected to one feed.
--
-- The documentation at
-- <http://www.sportsnetworkdata.com/feeds/xml-levels.asp> states
-- for a given document. We therefore rely on this to simplify
-- processing.
--
-loop :: Configuration -> Handle -> [String] -> IO ()
+-- The bang pattern at least on @buffer@ is necessary for
+-- performance reasons.
+--
+-- We specify a timeout of fifteen minutes on the 'recv_line'
+-- function, after which we will return to our caller. This should
+-- cause the connection to be dropped, and a new one initiated. The
+-- timeout is in response to observed behavior where the feed
+-- eventually stops transmitting data entirely without closing the
+-- connection.
+--
+loop :: Configuration
+ -> Handle -- ^ Handle to the feed (network connection)
+ -> [String] -- ^ Current XML document buffer, line-by-line, in reverse
+ -> IO ()
loop !cfg !h !buffer = do
- line <- recv_line h
- let new_buffer = line : buffer
-
- -- Use isPrefixOf to avoid line-ending issues. Hopefully they won't
- -- send invalid junk (on the same line) after closing the root
- -- element.
- if "</message>" `isPrefixOf` line
- then do
- -- The buffer is in reverse (newest first) order, though, so we
- -- have to reverse it first. We then concatenate all of its lines
- -- into one big string.
- let document = concat $ reverse new_buffer
- save_document cfg document
- loop cfg h [] -- Empty the buffer before looping again.
- else
- -- Append line to the head of the buffer and loop.
- loop cfg h new_buffer
+ line' <- timeout fifteen_minutes $ recv_line h
+ case line' of
+ -- If we haven't received anything in fifteen minutes, return back
+ -- to the calling function. This should only happen in the case of
+ -- an error, and our caller should be prepared to handle it.
+ Nothing -> report_warning "No data received for 15 minutes."
+
+ Just line -> do
+ -- If the recv didn't timeout, proceed normally.
+ let new_buffer = line : buffer
+
+ -- Use isPrefixOf to avoid line-ending issues. Hopefully they won't
+ -- send invalid junk (on the same line) after closing the root
+ -- element.
+ if "</message>" `isPrefixOf` line
+ then do
+ -- The buffer is in reverse (newest first) order, though, so we
+ -- have to reverse it first. We then concatenate all of its lines
+ -- into one big string.
+ let document = concat $ reverse new_buffer
+ save_document cfg document
+ loop cfg h [] -- Empty the buffer before looping again.
+ else
+ -- Append line to the head of the buffer and loop.
+ loop cfg h new_buffer
+ where
+ fifteen_minutes :: Int
+ fifteen_minutes = 15 * 60 * 1000000
-- | Once we're connected to a feed, we need to log in. There's no
-- (apparently) successfully guessed it.
--
-- The first thing TSN sends once we've connected is the string
--- "Username: ", containing 10 ASCII characters. We then send a
+-- \"Username: \", containing 10 ASCII characters. We then send a
-- username, followed by a newline. If TSN likes the username, the
--- second they'll send is the string "Password: ", also containing
+-- second they'll send is the string \"Password: \", also containing
-- 10 ASCII characters, to which we reply in kind.
--
-- Assuming the above will always hold, it is implemented as follows:
--
-- 4. Send password if we got the password prompt
--
--- If TSN likes the password as well, they send the string "The
--- Sports Network" before finally beginning to stream the feed.
+-- If TSN likes the password as well, they send the string \"The
+-- Sports Network\" before finally beginning to stream the feed.
--
log_in :: Configuration -> Handle -> IO ()
log_in cfg h = do
--
-- Steps:
--
--- 1. Connect to the host on the XML port
+-- 1. Connect to @host@ on the XML feed port.
--
--- 2. Log in
+-- 2. Log in.
--
-- 3. Go into the eternal read/save loop.
--
-connect_and_parse :: Configuration -> String -> IO ()
-connect_and_parse cfg host = do
- report_info $ "Connecting to " ++ host ++ "."
+connect_and_parse :: Configuration
+ -> String -- ^ Hostname to connect to
+ -> IO ()
+connect_and_parse cfg host =
bracket acquire_handle release_handle action
- return ()
where
five_seconds :: Int
- five_seconds = 5000000
+ five_seconds = 5 * 1000000
+
+ acquire_handle = do
+ report_info $ "Connecting to " ++ host ++ "."
+ connectTo host (PortNumber 4500)
+
+ release_handle h = do
+ report_info $ "Closing connection to " ++ host ++ "."
+ hClose h
- acquire_handle = connectTo host (PortNumber 4500)
- release_handle = hClose
action h = do
-- No buffering anywhere.
hSetBuffering h NoBuffering
case login_worked of
Nothing -> report_info $ "Login timed out (5 seconds). "
++ "Waiting 5 seconds to reconnect."
+
+ -- If loop returns (due to its timeout), it will pop out right
+ -- here and the action will terminate causing 'release_handle'
+ -- to trigger.
Just _ -> loop cfg h []
-- | A wrapper around threadDelay which takes seconds instead of
-- microseconds as its argument.
--
-thread_sleep :: Int -> IO ()
+thread_sleep :: Int -- ^ Number of seconds for which to sleep.
+ -> IO ()
thread_sleep seconds = do
let microseconds = seconds * (10 ^ (6 :: Int))
threadDelay microseconds
-- logging before the missing parameter checks below so that we can
-- log the errors.
let cfg = (def :: Configuration) `merge_optional` opt_config
- init_logging (log_file cfg) (log_level cfg) (syslog cfg)
-
- -- Check the optional config for missing required options. This is
- -- necessary because if the user specifies an empty list of
- -- hostnames in e.g. the config file, we want to bail rather than
- -- fall back on the default list (which was merged from a default
- -- Configuration above).
- when (null $ get_feed_hosts (OC.feed_hosts opt_config)) $ do
- report_error "No feed hosts supplied."
- exitWith (ExitFailure exit_no_feed_hosts)
+ init_logging (log_level cfg) (log_file cfg) (syslog cfg)
+ -- Check the optional config for missing required options.
when (isNothing (OC.password opt_config)) $ do
report_error "No password supplied."
exitWith (ExitFailure exit_no_password)
report_error "No username supplied."
exitWith (ExitFailure exit_no_username)
+ -- This should be impossible. We had a choice to make: since the
+ -- command-line feed_hosts are usually not supplied, we don't want
+ -- to take the empty list supplied on the command-line and use
+ -- that. But that means that we need to do the same thing if the
+ -- user supplies an empty list in the config file. That "same thing"
+ -- is to use the default list. So, this should never be empty,
+ -- because if the optional config has no feed hosts, we use the
+ -- default list.
+ when (null $ get_feed_hosts (feed_hosts cfg)) $ do
+ report_error "No feed hosts supplied."
+ exitWith (ExitFailure exit_no_feed_hosts)
+
when (daemonize cfg) $ do
-- Old PID files can be left around after an unclean shutdown. We
-- only care if we're running as a daemon.
-- If we were asked to daemonize, do that; otherwise just run the thing.
if (daemonize cfg)
- then full_daemonize cfg run_program
+ then try_daemonize cfg run_program
else run_program
where
- -- | This is the top-level "loop forever" function. If an
+ -- | This is the top-level \"loop forever\" function. If an
-- exception is thrown, it will propagate up to this point, where
-- it will be logged and ignored in style.
--
catchIOError (connect_and_parse cfg host) (report_error . show)
thread_sleep 5 -- Wait 5s before attempting to reconnect.
round_robin cfg $ (feed_host_idx + 1) `mod` (length hosts)
+
+
+ -- | A exception handler around full_daemonize. If full_daemonize
+ -- doesn't work, we report the error and crash. This is fine; we
+ -- only need the program to be resilient once it actually starts.
+ --
+ try_daemonize :: Configuration -> IO () -> IO ()
+ try_daemonize cfg program =
+ catchIOError
+ (full_daemonize cfg program)
+ (\e -> do
+ report_error (show e)
+ throw e)