From 2d4676bd6f13411bc01af9325775808e9d1883de Mon Sep 17 00:00:00 2001 From: Rodney Lorrimar Date: Mon, 10 Jun 2019 17:27:01 +1000 Subject: [PATCH 01/12] Add DaedalusIPC module This takes the cardano-sl/cardano-shell NodeIPC code and splits the general nodejs child_process IPC protocol out from the Daedalus/Cardano specific protocol, improves the exception handling, changes the logging, and just makes it simpler. --- lib/core/cardano-wallet-core.cabal | 3 + lib/core/src/Cardano/Wallet/DaedalusIPC.hs | 233 +++++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 lib/core/src/Cardano/Wallet/DaedalusIPC.hs diff --git a/lib/core/cardano-wallet-core.cabal b/lib/core/cardano-wallet-core.cabal index c603f4b3c53..a8aa4aba6be 100644 --- a/lib/core/cardano-wallet-core.cabal +++ b/lib/core/cardano-wallet-core.cabal @@ -32,10 +32,12 @@ library -Werror build-depends: aeson + , async , base , basement , binary , bytestring + , Cabal , cardano-crypto , containers , cryptonite @@ -76,6 +78,7 @@ library Cardano.Wallet.Api Cardano.Wallet.Api.Server Cardano.Wallet.Api.Types + Cardano.Wallet.DaedalusIPC Cardano.Wallet.DB Cardano.Wallet.DB.MVar Cardano.Wallet.DB.Sqlite diff --git a/lib/core/src/Cardano/Wallet/DaedalusIPC.hs b/lib/core/src/Cardano/Wallet/DaedalusIPC.hs new file mode 100644 index 00000000000..7ca510c296d --- /dev/null +++ b/lib/core/src/Cardano/Wallet/DaedalusIPC.hs @@ -0,0 +1,233 @@ +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE RankNTypes #-} +{-# LANGUAGE ScopedTypeVariables #-} + +-- | +-- Copyright: © 2018-2019 IOHK +-- License: MIT +-- +-- Provides a mechanism for Daedalus to discover what port the cardano-wallet +-- server is listening on. +-- +-- See +-- for more information about the message protocol. + +module Cardano.Wallet.DaedalusIPC + ( daedalusIPC + ) where + +import Prelude + +import Control.Concurrent + ( threadDelay ) +import Control.Concurrent.Async + ( race_ ) +import Control.Concurrent.MVar + ( MVar, newEmptyMVar, putMVar, takeMVar ) +import Control.Exception + ( IOException, catch, tryJust ) +import Control.Monad + ( forever, when ) +import Data.Aeson + ( FromJSON (..) + , ToJSON (..) + , defaultOptions + , eitherDecode + , encode + , genericParseJSON + , genericToEncoding + ) +import Data.Aeson.Types + ( Options, SumEncoding (ObjectWithSingleField), sumEncoding ) +import Data.Bifunctor + ( first ) +import Data.Binary.Get + ( getWord32le, getWord64le, runGet ) +import Data.Binary.Put + ( putLazyByteString, putWord32le, putWord64le, runPut ) +import Data.Maybe + ( fromMaybe ) +import Data.Text + ( Text ) +import Data.Word + ( Word32, Word64 ) +import Distribution.System + ( OS (Windows), buildOS ) +import GHC.Generics + ( Generic ) +import GHC.IO.Handle.FD + ( fdToHandle ) +import Say + ( sayErr, sayErrString ) +import System.Environment + ( lookupEnv ) +import System.IO + ( Handle, hFlush, hGetLine, hSetNewlineMode, noNewlineTranslation, stdout ) +import System.IO.Error + ( IOError, isEOFError ) +import Text.Read + ( readEither ) + +import qualified Data.ByteString.Lazy as BL +import qualified Data.ByteString.Lazy.Char8 as L8 +import qualified Data.Text as T + +---------------------------------------------------------------------------- +-- Daedalus <-> Wallet child process port discovery protocol + +data MsgIn = QueryPort | Ping + deriving (Show, Eq, Generic) +data MsgOut = Started | Pong | ReplyPort Int | ParseError Text + deriving (Show, Eq, Generic) + +aesonOpts :: Options +aesonOpts = defaultOptions { sumEncoding = ObjectWithSingleField } + +instance FromJSON MsgIn where + parseJSON = genericParseJSON aesonOpts +instance ToJSON MsgOut where + toEncoding = genericToEncoding aesonOpts + +daedalusIPC :: Int -> IO () +daedalusIPC port = withNodeChannel (pure . msg) action >>= \case + Right act -> do + sayErr "[INFO] Daedalus IPC server starting" + act + Left NodeChannelDisabled -> do + sayErr "[INFO] Daedalus IPC is not enabled" + sleep + Left (NodeChannelBadFD err) -> + sayErr $ "[ERROR] Starting Daedalus IPC: " <> err + where + -- How to respond to an incoming message, or when there is an incoming + -- message that couldn't be parsed. + msg (Right QueryPort) = Just (ReplyPort port) + msg (Right Ping) = Just Pong + msg (Left e) = Just (ParseError e) + + -- What to do in context of withNodeChannel + action :: (MsgOut -> IO ()) -> IO () + action send = send Started >> sleep + + sleep = threadDelay maxBound + +---------------------------------------------------------------------------- +-- NodeJS child_process IPC protocol +-- https://nodejs.org/api/child_process.html#child_process_child_process_spawn_command_args_options + +data NodeChannelError + = NodeChannelDisabled + -- ^ This process has not been started as a nodejs @'ipc'@ child_process. + | NodeChannelBadFD Text + -- ^ The @NODE_CHANNEL_FD@ environment variable has an incorrect value. + deriving (Show, Eq) + +withNodeChannel + :: (FromJSON msgin, ToJSON msgout) + => (Either Text msgin -> IO (Maybe msgout)) + -- ^ Incoming message handler + -> ((msgout -> IO ()) -> IO a) + -- ^ Action to run + -> IO (Either NodeChannelError (IO ())) +withNodeChannel onMsg handleMsg = fmap setup <$> lookupNodeChannel + where + setup handle = do + chan <- newEmptyMVar + let ipc = ipcListener handle onMsg chan + action' = handleMsg (putMVar chan) + race_ action' ipc + +-- | Parse the NODE_CHANNEL_FD variable, if it's set, and convert to a +-- 'System.IO.Handle'. +lookupNodeChannel :: IO (Either NodeChannelError Handle) +lookupNodeChannel = (fromMaybe "" <$> lookupEnv "NODE_CHANNEL_FD") >>= \case + "" -> pure (Left NodeChannelDisabled) + var -> case readEither var of + Left err -> pure . Left . NodeChannelBadFD $ + "unable to parse NODE_CHANNEL_FD: " <> T.pack err + Right fd -> tryJust handleBadFd (fdToHandle fd) + where + handleBadFd :: IOException -> Maybe NodeChannelError + handleBadFd = Just . NodeChannelBadFD . T.pack . show + +ipcListener + :: forall msgin msgout. (FromJSON msgin, ToJSON msgout) + => Handle + -> (Either Text msgin -> IO (Maybe msgout)) + -> MVar msgout + -> IO () +ipcListener handle onMsg chan = do + hSetNewlineMode handle noNewlineTranslation + catch (race_ replyLoop sendLoop) onIOError + where + sendLoop, replyLoop :: IO () + replyLoop = forever (recvMsg >>= onMsg >>= maybeSend) + sendLoop = forever (takeMVar chan >>= sendMsg) + + recvMsg :: IO (Either Text msgin) + recvMsg = first T.pack . eitherDecode <$> readMessage handle + + sendMsg :: msgout -> IO () + sendMsg = sendMessage handle . encode + + maybeSend :: Maybe msgout -> IO () + maybeSend = maybe (pure ()) (putMVar chan) + + onIOError :: IOError -> IO () + onIOError err = do + sayErrString $ "[ERROR] Exception caught in DaedalusIPC: " <> show err + when (isEOFError err) $ sayErr "[DEBUG] it's an eof" + hFlush stdout + +readMessage :: Handle -> IO BL.ByteString +readMessage + | buildOS == Windows = windowsReadMessage + | otherwise = posixReadMessage + +windowsReadMessage :: Handle -> IO BL.ByteString +windowsReadMessage handle = do + _int1 <- readInt32 handle + _int2 <- readInt32 handle + size <- readInt64 handle + -- logInfo $ "int is: " <> (show [_int1, _int2]) <> " and blob is: " <> (show blob) + BL.hGet handle $ fromIntegral size + where + readInt64 :: Handle -> IO Word64 + readInt64 hnd = do + bs <- BL.hGet hnd 8 + pure $ runGet getWord64le bs + + readInt32 :: Handle -> IO Word32 + readInt32 hnd = do + bs <- BL.hGet hnd 4 + pure $ runGet getWord32le bs + +posixReadMessage :: Handle -> IO BL.ByteString +posixReadMessage = fmap L8.pack . hGetLine + +sendMessage :: Handle -> BL.ByteString -> IO () +sendMessage handle msg = send handle msg >> hFlush handle + where + send + | buildOS == Windows = sendWindowsMessage + | otherwise = sendLinuxMessage + +sendWindowsMessage :: Handle -> BL.ByteString -> IO () +sendWindowsMessage = sendWindowsMessage' 1 0 + +sendWindowsMessage' :: Word32 -> Word32 -> Handle -> BL.ByteString -> IO () +sendWindowsMessage' int1 int2 handle blob = + L8.hPut handle $ runPut $ mconcat parts + where + blob' = blob <> "\n" + parts = + [ putWord32le int1 + , putWord32le int2 + , putWord64le $ fromIntegral $ BL.length blob' + , putLazyByteString blob' + ] + +sendLinuxMessage :: Handle -> BL.ByteString -> IO () +sendLinuxMessage = L8.hPutStrLn From 2578b9c5f9aa0fc3bb819cd67a6862cb21ec2791 Mon Sep 17 00:00:00 2001 From: Rodney Lorrimar Date: Tue, 11 Jun 2019 14:37:33 +1000 Subject: [PATCH 02/12] Integrate Daedalus IPC into CLI server start --- cardano-wallet.cabal | 1 + exe/wallet/Main.hs | 10 ++++++- lib/core/src/Cardano/Wallet/Api/Server.hs | 32 ++++++++++++++++------- 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/cardano-wallet.cabal b/cardano-wallet.cabal index ab053770eab..19f24ba12a9 100644 --- a/cardano-wallet.cabal +++ b/cardano-wallet.cabal @@ -33,6 +33,7 @@ executable cardano-wallet base , aeson , aeson-pretty + , async , bytestring , cardano-wallet-cli , cardano-wallet-core diff --git a/exe/wallet/Main.hs b/exe/wallet/Main.hs index ad1d574fbfc..f1304d0a54c 100644 --- a/exe/wallet/Main.hs +++ b/exe/wallet/Main.hs @@ -62,6 +62,8 @@ import Cardano.Wallet.Api.Types , WalletPostData (..) , WalletPutData (..) ) +import Cardano.Wallet.DaedalusIPC + ( daedalusIPC ) import Cardano.Wallet.HttpBridge.Compatibility ( HttpBridge, block0 ) import Cardano.Wallet.HttpBridge.Environment @@ -78,6 +80,8 @@ import Control.Arrow ( second ) import Control.Concurrent ( threadDelay ) +import Control.Concurrent.Async + ( race_ ) import Control.Monad ( when ) import Data.Aeson @@ -102,6 +106,8 @@ import Network.HTTP.Client ( Manager, defaultManagerSettings, newManager ) import Paths_cardano_wallet ( version ) +import Say + ( sayErr ) import Servant ( (:<|>) (..), (:>) ) import Servant.Client @@ -410,7 +416,9 @@ execHttpBridge args _ = do wallet <- newWalletLayer @_ @(HttpBridge n) tracer block0 db nw tl let logStartup port = logInfo tracer $ "Wallet backend server listening on: " <> toText port - Server.start logStartup walletListen wallet + Server.withListeningSocket walletPort $ \(port, socket) -> do + let settings = Server.mkWarpSettings logStartup port + race_ (daedalusIPC port) (Server.startOnSocket settings socket wallet) -- | Generate a random mnemonic of the given size 'n' (n = number of words), -- and print it to stdout. diff --git a/lib/core/src/Cardano/Wallet/Api/Server.hs b/lib/core/src/Cardano/Wallet/Api/Server.hs index 18993d3de3b..34543e98cef 100644 --- a/lib/core/src/Cardano/Wallet/Api/Server.hs +++ b/lib/core/src/Cardano/Wallet/Api/Server.hs @@ -16,8 +16,11 @@ -- endpoints reachable through HTTP. module Cardano.Wallet.Api.Server - ( start - , Listen (..) + ( Listen (..) + , start + , startOnSocket + , mkWarpSettings + , withListeningSocket ) where import Prelude @@ -159,14 +162,11 @@ start -> Listen -> WalletLayer (SeqState t) t -> IO () -start onStartup portOption wl = - void $ withListeningSocket portOption $ \(port, socket) -> do - let settings = Warp.defaultSettings - & Warp.setPort port - & Warp.setBeforeMainLoop (onStartup port) - startOnSocket settings socket wl - pure port +start onStartup portOpt wl = + withListeningSocket portOpt $ \(port, socket) -> + startOnSocket (mkWarpSettings onStartup port) socket wl +-- | Start the application server, using the given settings and a bound socket. startOnSocket :: forall t. (TxId t, KeyToAddress t, EncodeAddress t, DecodeAddress t) => Warp.Settings @@ -184,11 +184,25 @@ startOnSocket settings socket wl = Warp.runSettingsSocket settings socket application :: Application application = serve (Proxy @("v2" :> Api t)) server +-- | Create warp server settings. +mkWarpSettings + :: (Warp.Port -> IO ()) + -- ^ Function to run after the listening socket is bound, just before + -- Warp enters its event loop. + -> Warp.Port + -- ^ Port that socket will be listening on. + -> Warp.Settings +mkWarpSettings onStartup port = Warp.defaultSettings + & Warp.setPort port + & Warp.setBeforeMainLoop (onStartup port) + -- | Run an action with a TCP socket bound to a port specified by the `Listen` -- parameter. withListeningSocket :: Listen + -- ^ Whether to listen on a given port, or random port. -> ((Port, Socket) -> IO Port) + -- ^ Action to run with listening socket. -> IO Port withListeningSocket portOpt = bracket acquire release where From f30460607ebaf8182ad925b68d915cf99c637388 Mon Sep 17 00:00:00 2001 From: Rodney Lorrimar Date: Tue, 11 Jun 2019 16:55:58 +1000 Subject: [PATCH 03/12] tests: Add nodejs child_process spawner --- .../test/integration/js/mock-daedalus.js | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100755 lib/http-bridge/test/integration/js/mock-daedalus.js diff --git a/lib/http-bridge/test/integration/js/mock-daedalus.js b/lib/http-bridge/test/integration/js/mock-daedalus.js new file mode 100755 index 00000000000..a4aa4d36d78 --- /dev/null +++ b/lib/http-bridge/test/integration/js/mock-daedalus.js @@ -0,0 +1,57 @@ +#!/usr/bin/env node + +// This runs cardano-wallet-launcher in the same way that Daedalus would. +// It needs node, cardano-wallet, and cardano-wallet-launcher on the PATH to run. + +const child_process = require("child_process"); +const http = require('http'); + +function main() { + // const proc = child_process.spawn("cardano-wallet", ["server"], { + const proc = child_process.spawn("cardano-wallet-launcher", [], { + stdio: ["ignore", "inherit", "inherit", "ipc"] + }); + + proc.on("close", function(code, signal) { + console.log("JS: child_process stdio streams closed"); + process.exit(1); + }); + + proc.on("disconnect", function() { + console.log("JS: child_process disconnected"); + process.exit(2); + }); + + proc.on("error", function(err) { + console.log("JS: error child_process: " + err); + process.exit(3); + }); + + proc.on("exit", function(code, signal) { + console.log("JS: child_process exited with status " + code + " or signal " + signal); + process.exit(4); + }); + + proc.on("message", function(msg) { + console.log("JS: message received", msg); + if (msg.Started) { + proc.send("QueryPort"); + } else if (msg.ReplyPort) { + http.get({ + hostname: "localhost", + port: msg.ReplyPort, + path: "/v2/wallets", + agent: false + }, (res) => { + console.log("JS: response from wallet: " + res.statusCode); + res.resume(); + res.on("end", () => { + console.log("JS: request response from wallet finished, exiting."); + process.exit(0); + }); + }); + } + }); +} + +main(); From 8fcef2f49c90c372991f74f8828f63ad3b4364bf Mon Sep 17 00:00:00 2001 From: Rodney Lorrimar Date: Wed, 12 Jun 2019 13:37:49 +1000 Subject: [PATCH 04/12] tests: Add launcher --state-dir integration test --- .../Test/Integration/Scenario/CLI/Server.hs | 59 +++++++++++++++++++ .../cardano-wallet-http-bridge.cabal | 2 + lib/http-bridge/test/integration/Main.hs | 6 +- 3 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 lib/core/test/integration/Test/Integration/Scenario/CLI/Server.hs diff --git a/lib/core/test/integration/Test/Integration/Scenario/CLI/Server.hs b/lib/core/test/integration/Test/Integration/Scenario/CLI/Server.hs new file mode 100644 index 00000000000..f631a3d080a --- /dev/null +++ b/lib/core/test/integration/Test/Integration/Scenario/CLI/Server.hs @@ -0,0 +1,59 @@ +module Test.Integration.Scenario.CLI.Server + ( spec + ) where + +import Prelude + +import Control.Concurrent + ( threadDelay ) +import System.Directory + ( listDirectory, removeDirectory ) +import System.Exit + ( ExitCode (..) ) +import System.IO.Temp + ( withSystemTempDirectory ) +import System.Process + ( CreateProcess, proc, withCreateProcess ) +import Test.Hspec + ( Spec, describe, it, shouldContain ) + +spec :: Spec +spec = do + describe "Launcher should start the server with a database" $ do + it "should create the database file" $ withTempDir $ \d -> do + removeDirectory d + launcher d + ls <- listDirectory d + ls `shouldContain` ["wallet.db"] + + it "should work with empty state directory" $ withTempDir $ \d -> do + launcher d + ls <- listDirectory d + ls `shouldContain` ["wallet.db"] + +withTempDir :: (FilePath -> IO a) -> IO a +withTempDir = withSystemTempDirectory "integration-state" + +waitForStartup :: IO () +waitForStartup = threadDelay (2 * 1000 * 1000) + +launcher :: FilePath -> IO () +launcher stateDir = withCreateProcess cmd $ \_ _ _ ph -> do + waitForStartup + terminateProcess ph + where + cmd = proc' "cardano-wallet-launcher" ["--state-dir", stateDir] + +-- There is a dependency cycle in the packages. +-- +-- cardano-wallet-launcher depends on cardano-wallet-http-bridge so that it can +-- import the HttpBridge module. +-- +-- This package (cardano-wallet-http-bridge) should have +-- build-tool-depends: cardano-wallet:cardano-wallet-launcher so that it can +-- run launcher in the tests. But that dependency can't be expressed in the +-- cabal file, because otherwise there would be a cycle. +-- +-- So one hacky way to work around it is by running programs under "stack exec". +proc' :: FilePath -> [String] -> CreateProcess +proc' cmd args = proc "stack" (["exec", cmd, "--"] ++ args) diff --git a/lib/http-bridge/cardano-wallet-http-bridge.cabal b/lib/http-bridge/cardano-wallet-http-bridge.cabal index 74a3338995b..6a05e109e34 100644 --- a/lib/http-bridge/cardano-wallet-http-bridge.cabal +++ b/lib/http-bridge/cardano-wallet-http-bridge.cabal @@ -157,6 +157,7 @@ test-suite integration , process , retry , template-haskell + , temporary , text , text-class , time @@ -182,6 +183,7 @@ test-suite integration Test.Integration.Scenario.API.Wallets Test.Integration.Scenario.CLI.Addresses Test.Integration.Scenario.CLI.Mnemonics + Test.Integration.Scenario.CLI.Server Test.Integration.Scenario.CLI.Transactions Test.Integration.Scenario.CLI.Wallets Test.Integration.Scenario.CLI.Port diff --git a/lib/http-bridge/test/integration/Main.hs b/lib/http-bridge/test/integration/Main.hs index f90d9e84073..fb303cbbd3b 100644 --- a/lib/http-bridge/test/integration/Main.hs +++ b/lib/http-bridge/test/integration/Main.hs @@ -82,6 +82,7 @@ import qualified Test.Integration.Scenario.API.Wallets as Wallets import qualified Test.Integration.Scenario.CLI.Addresses as AddressesCLI import qualified Test.Integration.Scenario.CLI.Mnemonics as MnemonicsCLI import qualified Test.Integration.Scenario.CLI.Port as PortCLI +import qualified Test.Integration.Scenario.CLI.Server as ServerCLI import qualified Test.Integration.Scenario.CLI.Transactions as TransactionsCLI import qualified Test.Integration.Scenario.CLI.Wallets as WalletsCLI @@ -119,7 +120,10 @@ main = hspec $ do describe "Wallets CLI tests" WalletsCLI.spec describe "Transactions CLI tests" TransactionsCLI.spec describe "Addresses CLI tests" AddressesCLI.spec - where + + describe "CLI Server" ServerCLI.spec + +where oneSecond :: Int oneSecond = 1 * 1000 * 1000 -- 1 second in microseconds From 8df7e67863e63349ecd002df9300869700d7cd99 Mon Sep 17 00:00:00 2001 From: Rodney Lorrimar Date: Wed, 12 Jun 2019 14:09:55 +1000 Subject: [PATCH 05/12] tests: Add DaedalusIPC test --- .../Test/Integration/Scenario/CLI/Server.hs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/lib/core/test/integration/Test/Integration/Scenario/CLI/Server.hs b/lib/core/test/integration/Test/Integration/Scenario/CLI/Server.hs index f631a3d080a..e8e8cfff522 100644 --- a/lib/core/test/integration/Test/Integration/Scenario/CLI/Server.hs +++ b/lib/core/test/integration/Test/Integration/Scenario/CLI/Server.hs @@ -13,9 +13,15 @@ import System.Exit import System.IO.Temp ( withSystemTempDirectory ) import System.Process - ( CreateProcess, proc, withCreateProcess ) + ( CreateProcess + , createProcess + , proc + , terminateProcess + , waitForProcess + , withCreateProcess + ) import Test.Hspec - ( Spec, describe, it, shouldContain ) + ( Spec, describe, it, shouldContain, shouldReturn ) spec :: Spec spec = do @@ -31,6 +37,11 @@ spec = do ls <- listDirectory d ls `shouldContain` ["wallet.db"] + describe "DaedalusIPC" $ do + it "should reply with the port when asked" $ do + (_, _, _, ph) <- createProcess (proc "test/integration/js/mock-daedalus.js" []) + waitForProcess ph `shouldReturn` ExitSuccess + withTempDir :: (FilePath -> IO a) -> IO a withTempDir = withSystemTempDirectory "integration-state" From 3e2434b2693579aefe58171f1c06a7f01d098175 Mon Sep 17 00:00:00 2001 From: Rodney Lorrimar Date: Wed, 12 Jun 2019 14:45:47 +1000 Subject: [PATCH 06/12] DaedalusIPC: Remove Ping/Pong messages --- lib/core/src/Cardano/Wallet/DaedalusIPC.hs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/core/src/Cardano/Wallet/DaedalusIPC.hs b/lib/core/src/Cardano/Wallet/DaedalusIPC.hs index 7ca510c296d..d035dd895dd 100644 --- a/lib/core/src/Cardano/Wallet/DaedalusIPC.hs +++ b/lib/core/src/Cardano/Wallet/DaedalusIPC.hs @@ -77,9 +77,9 @@ import qualified Data.Text as T ---------------------------------------------------------------------------- -- Daedalus <-> Wallet child process port discovery protocol -data MsgIn = QueryPort | Ping +data MsgIn = QueryPort deriving (Show, Eq, Generic) -data MsgOut = Started | Pong | ReplyPort Int | ParseError Text +data MsgOut = Started | ReplyPort Int | ParseError Text deriving (Show, Eq, Generic) aesonOpts :: Options @@ -104,7 +104,6 @@ daedalusIPC port = withNodeChannel (pure . msg) action >>= \case -- How to respond to an incoming message, or when there is an incoming -- message that couldn't be parsed. msg (Right QueryPort) = Just (ReplyPort port) - msg (Right Ping) = Just Pong msg (Left e) = Just (ParseError e) -- What to do in context of withNodeChannel From bfaf008e3d1b227ac11c9a870a240fe3416975d4 Mon Sep 17 00:00:00 2001 From: Rodney Lorrimar Date: Thu, 13 Jun 2019 15:00:58 +1000 Subject: [PATCH 07/12] Regenerate nix --- nix/.stack.nix/cardano-wallet-core.nix | 2 ++ nix/.stack.nix/cardano-wallet-http-bridge.nix | 1 + nix/.stack.nix/cardano-wallet.nix | 1 + 3 files changed, 4 insertions(+) diff --git a/nix/.stack.nix/cardano-wallet-core.nix b/nix/.stack.nix/cardano-wallet-core.nix index bc8be9fc487..4c25e1acdb1 100644 --- a/nix/.stack.nix/cardano-wallet-core.nix +++ b/nix/.stack.nix/cardano-wallet-core.nix @@ -18,6 +18,7 @@ "library" = { depends = [ (hsPkgs.aeson) + (hsPkgs.async) (hsPkgs.base) (hsPkgs.basement) (hsPkgs.binary) @@ -51,6 +52,7 @@ (hsPkgs.text-class) (hsPkgs.time) (hsPkgs.transformers) + (hsPkgs.unordered-containers) (hsPkgs.vector) (hsPkgs.wai) (hsPkgs.warp) diff --git a/nix/.stack.nix/cardano-wallet-http-bridge.nix b/nix/.stack.nix/cardano-wallet-http-bridge.nix index eac7ef206c4..1ed38607114 100644 --- a/nix/.stack.nix/cardano-wallet-http-bridge.nix +++ b/nix/.stack.nix/cardano-wallet-http-bridge.nix @@ -99,6 +99,7 @@ (hsPkgs.process) (hsPkgs.retry) (hsPkgs.template-haskell) + (hsPkgs.temporary) (hsPkgs.text) (hsPkgs.text-class) (hsPkgs.time) diff --git a/nix/.stack.nix/cardano-wallet.nix b/nix/.stack.nix/cardano-wallet.nix index 19bb31ac451..8dce99aacb4 100644 --- a/nix/.stack.nix/cardano-wallet.nix +++ b/nix/.stack.nix/cardano-wallet.nix @@ -22,6 +22,7 @@ (hsPkgs.base) (hsPkgs.aeson) (hsPkgs.aeson-pretty) + (hsPkgs.async) (hsPkgs.bytestring) (hsPkgs.cardano-wallet-cli) (hsPkgs.cardano-wallet-core) From 03f3b0063fc28b00623f492eb504123f25c55786 Mon Sep 17 00:00:00 2001 From: Rodney Lorrimar Date: Thu, 13 Jun 2019 15:30:16 +1000 Subject: [PATCH 08/12] DaedalusIPC: Remove Cabal library dependency (yuck) --- exe/wallet/Main.hs | 2 -- lib/core/cardano-wallet-core.cabal | 1 - lib/core/src/Cardano/Wallet/DaedalusIPC.hs | 23 +++++++++++----------- 3 files changed, 11 insertions(+), 15 deletions(-) diff --git a/exe/wallet/Main.hs b/exe/wallet/Main.hs index f1304d0a54c..c39228162ef 100644 --- a/exe/wallet/Main.hs +++ b/exe/wallet/Main.hs @@ -106,8 +106,6 @@ import Network.HTTP.Client ( Manager, defaultManagerSettings, newManager ) import Paths_cardano_wallet ( version ) -import Say - ( sayErr ) import Servant ( (:<|>) (..), (:>) ) import Servant.Client diff --git a/lib/core/cardano-wallet-core.cabal b/lib/core/cardano-wallet-core.cabal index a8aa4aba6be..b3d34d48e52 100644 --- a/lib/core/cardano-wallet-core.cabal +++ b/lib/core/cardano-wallet-core.cabal @@ -37,7 +37,6 @@ library , basement , binary , bytestring - , Cabal , cardano-crypto , containers , cryptonite diff --git a/lib/core/src/Cardano/Wallet/DaedalusIPC.hs b/lib/core/src/Cardano/Wallet/DaedalusIPC.hs index d035dd895dd..2b3f3285630 100644 --- a/lib/core/src/Cardano/Wallet/DaedalusIPC.hs +++ b/lib/core/src/Cardano/Wallet/DaedalusIPC.hs @@ -53,8 +53,6 @@ import Data.Text ( Text ) import Data.Word ( Word32, Word64 ) -import Distribution.System - ( OS (Windows), buildOS ) import GHC.Generics ( Generic ) import GHC.IO.Handle.FD @@ -63,6 +61,8 @@ import Say ( sayErr, sayErrString ) import System.Environment ( lookupEnv ) +import System.Info + ( arch ) import System.IO ( Handle, hFlush, hGetLine, hSetNewlineMode, noNewlineTranslation, stdout ) import System.IO.Error @@ -181,9 +181,10 @@ ipcListener handle onMsg chan = do hFlush stdout readMessage :: Handle -> IO BL.ByteString -readMessage - | buildOS == Windows = windowsReadMessage - | otherwise = posixReadMessage +readMessage = if isWindows then windowsReadMessage else posixReadMessage + +isWindows :: Bool +isWindows = arch == "windows" windowsReadMessage :: Handle -> IO BL.ByteString windowsReadMessage handle = do @@ -209,12 +210,10 @@ posixReadMessage = fmap L8.pack . hGetLine sendMessage :: Handle -> BL.ByteString -> IO () sendMessage handle msg = send handle msg >> hFlush handle where - send - | buildOS == Windows = sendWindowsMessage - | otherwise = sendLinuxMessage + send = if isWindows then sendMessageWindows else sendMessagePosix -sendWindowsMessage :: Handle -> BL.ByteString -> IO () -sendWindowsMessage = sendWindowsMessage' 1 0 +sendMessageWindows :: Handle -> BL.ByteString -> IO () +sendMessageWindows = sendWindowsMessage' 1 0 sendWindowsMessage' :: Word32 -> Word32 -> Handle -> BL.ByteString -> IO () sendWindowsMessage' int1 int2 handle blob = @@ -228,5 +227,5 @@ sendWindowsMessage' int1 int2 handle blob = , putLazyByteString blob' ] -sendLinuxMessage :: Handle -> BL.ByteString -> IO () -sendLinuxMessage = L8.hPutStrLn +sendMessagePosix :: Handle -> BL.ByteString -> IO () +sendMessagePosix = L8.hPutStrLn From 51d375c70e5fceca049c4bddbf455deaf87369a0 Mon Sep 17 00:00:00 2001 From: Rodney Lorrimar Date: Fri, 14 Jun 2019 11:15:52 +1000 Subject: [PATCH 09/12] Fix rebase merge mistakes --- lib/core/src/Cardano/Wallet/Api/Server.hs | 6 +++--- lib/http-bridge/test/integration/Main.hs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/core/src/Cardano/Wallet/Api/Server.hs b/lib/core/src/Cardano/Wallet/Api/Server.hs index 34543e98cef..9de228ba745 100644 --- a/lib/core/src/Cardano/Wallet/Api/Server.hs +++ b/lib/core/src/Cardano/Wallet/Api/Server.hs @@ -163,7 +163,7 @@ start -> WalletLayer (SeqState t) t -> IO () start onStartup portOpt wl = - withListeningSocket portOpt $ \(port, socket) -> + void $ withListeningSocket portOpt $ \(port, socket) -> startOnSocket (mkWarpSettings onStartup port) socket wl -- | Start the application server, using the given settings and a bound socket. @@ -201,9 +201,9 @@ mkWarpSettings onStartup port = Warp.defaultSettings withListeningSocket :: Listen -- ^ Whether to listen on a given port, or random port. - -> ((Port, Socket) -> IO Port) + -> ((Port, Socket) -> IO ()) -- ^ Action to run with listening socket. - -> IO Port + -> IO () withListeningSocket portOpt = bracket acquire release where acquire = case portOpt of diff --git a/lib/http-bridge/test/integration/Main.hs b/lib/http-bridge/test/integration/Main.hs index fb303cbbd3b..76a9a380b37 100644 --- a/lib/http-bridge/test/integration/Main.hs +++ b/lib/http-bridge/test/integration/Main.hs @@ -123,7 +123,7 @@ main = hspec $ do describe "CLI Server" ServerCLI.spec -where + where oneSecond :: Int oneSecond = 1 * 1000 * 1000 -- 1 second in microseconds From 9ccfb53e174f2fa98af4417ee055cdd33415cc84 Mon Sep 17 00:00:00 2001 From: Rodney Lorrimar Date: Fri, 14 Jun 2019 11:16:26 +1000 Subject: [PATCH 10/12] Address review comments --- exe/wallet/Main.hs | 6 +- lib/core/src/Cardano/Wallet/DaedalusIPC.hs | 81 +++++++++++++++------- 2 files changed, 60 insertions(+), 27 deletions(-) diff --git a/exe/wallet/Main.hs b/exe/wallet/Main.hs index c39228162ef..deb667d2b78 100644 --- a/exe/wallet/Main.hs +++ b/exe/wallet/Main.hs @@ -414,9 +414,11 @@ execHttpBridge args _ = do wallet <- newWalletLayer @_ @(HttpBridge n) tracer block0 db nw tl let logStartup port = logInfo tracer $ "Wallet backend server listening on: " <> toText port - Server.withListeningSocket walletPort $ \(port, socket) -> do + Server.withListeningSocket walletListen $ \(port, socket) -> do let settings = Server.mkWarpSettings logStartup port - race_ (daedalusIPC port) (Server.startOnSocket settings socket wallet) + tracer' <- appendName "DaedalusIPC" tracer + race_ (daedalusIPC tracer' port) $ + Server.startOnSocket settings socket wallet -- | Generate a random mnemonic of the given size 'n' (n = number of words), -- and print it to stdout. diff --git a/lib/core/src/Cardano/Wallet/DaedalusIPC.hs b/lib/core/src/Cardano/Wallet/DaedalusIPC.hs index 2b3f3285630..60d9e71031a 100644 --- a/lib/core/src/Cardano/Wallet/DaedalusIPC.hs +++ b/lib/core/src/Cardano/Wallet/DaedalusIPC.hs @@ -20,16 +20,18 @@ module Cardano.Wallet.DaedalusIPC import Prelude +import Cardano.BM.Trace + ( Trace, logError, logInfo, logNotice ) import Control.Concurrent ( threadDelay ) import Control.Concurrent.Async - ( race_ ) + ( concurrently_, race ) import Control.Concurrent.MVar ( MVar, newEmptyMVar, putMVar, takeMVar ) import Control.Exception ( IOException, catch, tryJust ) import Control.Monad - ( forever, when ) + ( forever ) import Data.Aeson ( FromJSON (..) , ToJSON (..) @@ -47,26 +49,28 @@ import Data.Binary.Get ( getWord32le, getWord64le, runGet ) import Data.Binary.Put ( putLazyByteString, putWord32le, putWord64le, runPut ) +import Data.Functor + ( ($>) ) import Data.Maybe ( fromMaybe ) import Data.Text ( Text ) import Data.Word ( Word32, Word64 ) +import Fmt + ( fmt, (+||), (||+) ) import GHC.Generics ( Generic ) import GHC.IO.Handle.FD ( fdToHandle ) -import Say - ( sayErr, sayErrString ) import System.Environment ( lookupEnv ) import System.Info ( arch ) import System.IO - ( Handle, hFlush, hGetLine, hSetNewlineMode, noNewlineTranslation, stdout ) + ( Handle, hFlush, hGetLine, hSetNewlineMode, noNewlineTranslation ) import System.IO.Error - ( IOError, isEOFError ) + ( IOError ) import Text.Read ( readEither ) @@ -90,16 +94,33 @@ instance FromJSON MsgIn where instance ToJSON MsgOut where toEncoding = genericToEncoding aesonOpts -daedalusIPC :: Int -> IO () -daedalusIPC port = withNodeChannel (pure . msg) action >>= \case - Right act -> do - sayErr "[INFO] Daedalus IPC server starting" - act +-- | Start up the Daedalus IPC process. It's called 'daedalusIPC', but this +-- could be any nodejs program that needs to start cardano-wallet. All it does +-- is reply with a port number when asked, using a very nodejs-specific IPC +-- method. +-- +-- If the IPC channel was successfully set up, this function won't return until +-- the parent process exits. Otherwise, it will return immediately. Before +-- returning, it will log an message about why it has exited. +daedalusIPC + :: Trace IO Text + -- ^ Logging object + -> Int + -- ^ Port number to send to Daedalus + -> IO () +daedalusIPC trace port = withNodeChannel (pure . msg) action >>= \case + Right runServer -> do + logInfo trace "Daedalus IPC server starting" + runServer >>= \case + Left (NodeChannelFinished err) -> + logNotice trace $ fmt $ + "Daedalus IPC finished for this reason: "+||err||+"" + Right () -> logError trace "Unreachable code" Left NodeChannelDisabled -> do - sayErr "[INFO] Daedalus IPC is not enabled" + logInfo trace $ "Daedalus IPC is not enabled." sleep Left (NodeChannelBadFD err) -> - sayErr $ "[ERROR] Starting Daedalus IPC: " <> err + logError trace $ fmt $ "Problem starting Daedalus IPC: "+||err||+"" where -- How to respond to an incoming message, or when there is an incoming -- message that couldn't be parsed. @@ -116,6 +137,7 @@ daedalusIPC port = withNodeChannel (pure . msg) action >>= \case -- NodeJS child_process IPC protocol -- https://nodejs.org/api/child_process.html#child_process_child_process_spawn_command_args_options +-- | Possible reasons why the node channel can't be set up. data NodeChannelError = NodeChannelDisabled -- ^ This process has not been started as a nodejs @'ipc'@ child_process. @@ -123,20 +145,33 @@ data NodeChannelError -- ^ The @NODE_CHANNEL_FD@ environment variable has an incorrect value. deriving (Show, Eq) +-- | The only way a node channel finishes on its own is if there is some error +-- reading or writing to its file descriptor. +newtype NodeChannelFinished = NodeChannelFinished IOError + +-- | Communicate with a parent process using a NodeJS-specific protocol. This +-- process must have been spawned with one of @stdio@ array entries set to +-- @'ipc'@. +-- +-- If the channel could be set up, then it returns a function for communicating +-- with the parent process. withNodeChannel :: (FromJSON msgin, ToJSON msgout) => (Either Text msgin -> IO (Maybe msgout)) - -- ^ Incoming message handler + -- ^ Handler for messages coming from the parent process. Left values are + -- for JSON parse errors. The handler can optionally return a reply + -- message. -> ((msgout -> IO ()) -> IO a) - -- ^ Action to run - -> IO (Either NodeChannelError (IO ())) + -- ^ Action to run with the channel. It is passed a function for sending + -- messages to the parent process. + -> IO (Either NodeChannelError (IO (Either NodeChannelFinished a))) withNodeChannel onMsg handleMsg = fmap setup <$> lookupNodeChannel where setup handle = do chan <- newEmptyMVar let ipc = ipcListener handle onMsg chan action' = handleMsg (putMVar chan) - race_ action' ipc + race ipc action' -- | Parse the NODE_CHANNEL_FD variable, if it's set, and convert to a -- 'System.IO.Handle'. @@ -156,10 +191,10 @@ ipcListener => Handle -> (Either Text msgin -> IO (Maybe msgout)) -> MVar msgout - -> IO () -ipcListener handle onMsg chan = do + -> IO NodeChannelFinished +ipcListener handle onMsg chan = NodeChannelFinished <$> do hSetNewlineMode handle noNewlineTranslation - catch (race_ replyLoop sendLoop) onIOError + (concurrently_ replyLoop sendLoop $> unexpected) `catch` pure where sendLoop, replyLoop :: IO () replyLoop = forever (recvMsg >>= onMsg >>= maybeSend) @@ -174,11 +209,7 @@ ipcListener handle onMsg chan = do maybeSend :: Maybe msgout -> IO () maybeSend = maybe (pure ()) (putMVar chan) - onIOError :: IOError -> IO () - onIOError err = do - sayErrString $ "[ERROR] Exception caught in DaedalusIPC: " <> show err - when (isEOFError err) $ sayErr "[DEBUG] it's an eof" - hFlush stdout + unexpected = userError "ipcListener: unreachable code" readMessage :: Handle -> IO BL.ByteString readMessage = if isWindows then windowsReadMessage else posixReadMessage From ebdd0136abf12323028f9629b120c22944f1e1d5 Mon Sep 17 00:00:00 2001 From: Rodney Lorrimar Date: Fri, 14 Jun 2019 12:50:25 +1000 Subject: [PATCH 11/12] DaedalusIPC: Don't use generic aeson encoding - too fragile Removing a type constructor can change the shape of the JSON. --- lib/core/cardano-wallet-core.cabal | 1 + lib/core/src/Cardano/Wallet/DaedalusIPC.hs | 31 +++++++++---------- .../test/integration/js/mock-daedalus.js | 10 +++++- 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/lib/core/cardano-wallet-core.cabal b/lib/core/cardano-wallet-core.cabal index b3d34d48e52..d4a69a7c15f 100644 --- a/lib/core/cardano-wallet-core.cabal +++ b/lib/core/cardano-wallet-core.cabal @@ -66,6 +66,7 @@ library , text-class , time , transformers + , unordered-containers , vector , wai , warp diff --git a/lib/core/src/Cardano/Wallet/DaedalusIPC.hs b/lib/core/src/Cardano/Wallet/DaedalusIPC.hs index 60d9e71031a..01f96167107 100644 --- a/lib/core/src/Cardano/Wallet/DaedalusIPC.hs +++ b/lib/core/src/Cardano/Wallet/DaedalusIPC.hs @@ -1,4 +1,3 @@ -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RankNTypes #-} @@ -35,14 +34,14 @@ import Control.Monad import Data.Aeson ( FromJSON (..) , ToJSON (..) - , defaultOptions + , Value (..) , eitherDecode , encode - , genericParseJSON - , genericToEncoding + , object + , withObject + , (.:) + , (.=) ) -import Data.Aeson.Types - ( Options, SumEncoding (ObjectWithSingleField), sumEncoding ) import Data.Bifunctor ( first ) import Data.Binary.Get @@ -59,8 +58,6 @@ import Data.Word ( Word32, Word64 ) import Fmt ( fmt, (+||), (||+) ) -import GHC.Generics - ( Generic ) import GHC.IO.Handle.FD ( fdToHandle ) import System.Environment @@ -81,18 +78,20 @@ import qualified Data.Text as T ---------------------------------------------------------------------------- -- Daedalus <-> Wallet child process port discovery protocol -data MsgIn = QueryPort - deriving (Show, Eq, Generic) +data MsgIn = QueryPort + deriving (Show, Eq) data MsgOut = Started | ReplyPort Int | ParseError Text - deriving (Show, Eq, Generic) - -aesonOpts :: Options -aesonOpts = defaultOptions { sumEncoding = ObjectWithSingleField } + deriving (Show, Eq) instance FromJSON MsgIn where - parseJSON = genericParseJSON aesonOpts + parseJSON = withObject "MsgIn" $ \v -> do + (_ :: [()]) <- v .: "QueryPort" + pure QueryPort + instance ToJSON MsgOut where - toEncoding = genericToEncoding aesonOpts + toJSON Started = object [ "Started" .= Array mempty ] + toJSON (ReplyPort p) = object [ "ReplyPort" .= p ] + toJSON (ParseError e) = object [ "ParseError" .= e ] -- | Start up the Daedalus IPC process. It's called 'daedalusIPC', but this -- could be any nodejs program that needs to start cardano-wallet. All it does diff --git a/lib/http-bridge/test/integration/js/mock-daedalus.js b/lib/http-bridge/test/integration/js/mock-daedalus.js index a4aa4d36d78..fbd529a9fdb 100755 --- a/lib/http-bridge/test/integration/js/mock-daedalus.js +++ b/lib/http-bridge/test/integration/js/mock-daedalus.js @@ -34,8 +34,16 @@ function main() { proc.on("message", function(msg) { console.log("JS: message received", msg); + // See CardanoNode.js in Daedalus for the message types in use. if (msg.Started) { - proc.send("QueryPort"); + console.log("JS: sending a bogus message"); + proc.send("hello"); + } else if (msg.ParseError && msg.ParseError.match(/encountered String/)) { + console.log("JS: sending QueryPort"); + proc.send({ QueryPort: [] }); + } else if (msg.ParseError) { + console.log("JS: i did not expect that"); + process.exit(5); } else if (msg.ReplyPort) { http.get({ hostname: "localhost", From 54131015a442ad915b0358449f88788636c69ad4 Mon Sep 17 00:00:00 2001 From: KtorZ Date: Fri, 14 Jun 2019 19:46:07 +0200 Subject: [PATCH 12/12] change 'cardano-wallet-launcher' to 'cardano-wallet launch' --- .../test/integration/Test/Integration/Scenario/CLI/Server.hs | 5 +++-- lib/http-bridge/test/integration/Main.hs | 4 +--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/lib/core/test/integration/Test/Integration/Scenario/CLI/Server.hs b/lib/core/test/integration/Test/Integration/Scenario/CLI/Server.hs index e8e8cfff522..a6a6e65c70e 100644 --- a/lib/core/test/integration/Test/Integration/Scenario/CLI/Server.hs +++ b/lib/core/test/integration/Test/Integration/Scenario/CLI/Server.hs @@ -39,7 +39,8 @@ spec = do describe "DaedalusIPC" $ do it "should reply with the port when asked" $ do - (_, _, _, ph) <- createProcess (proc "test/integration/js/mock-daedalus.js" []) + (_, _, _, ph) <- + createProcess (proc "test/integration/js/mock-daedalus.js" []) waitForProcess ph `shouldReturn` ExitSuccess withTempDir :: (FilePath -> IO a) -> IO a @@ -53,7 +54,7 @@ launcher stateDir = withCreateProcess cmd $ \_ _ _ ph -> do waitForStartup terminateProcess ph where - cmd = proc' "cardano-wallet-launcher" ["--state-dir", stateDir] + cmd = proc' "cardano-wallet" ["launch", "--state-dir", stateDir] -- There is a dependency cycle in the packages. -- diff --git a/lib/http-bridge/test/integration/Main.hs b/lib/http-bridge/test/integration/Main.hs index 76a9a380b37..85c9ec9dd30 100644 --- a/lib/http-bridge/test/integration/Main.hs +++ b/lib/http-bridge/test/integration/Main.hs @@ -93,6 +93,7 @@ main = hspec $ do describe "Cardano.Wallet.HttpBridge.NetworkSpec" HttpBridge.spec describe "CLI commands not requiring bridge" $ do describe "Mnemonics CLI tests" MnemonicsCLI.spec + describe "Server CLI tests" ServerCLI.spec describe "--port CLI tests" $ do cardanoWalletServer Nothing & beforeAll @@ -111,7 +112,6 @@ main = hspec $ do $ describe "with random port" $ do PortCLI.specCommon PortCLI.specWithRandomPort defaultPort - beforeAll startCluster $ afterAll killCluster $ after tearDown $ do describe "Wallets API endpoint tests" Wallets.spec @@ -121,8 +121,6 @@ main = hspec $ do describe "Transactions CLI tests" TransactionsCLI.spec describe "Addresses CLI tests" AddressesCLI.spec - describe "CLI Server" ServerCLI.spec - where oneSecond :: Int oneSecond = 1 * 1000 * 1000 -- 1 second in microseconds