packages feed

plexus-synapse-3.10.1: src/Synapse/Self/Debugger.hs

{-# LANGUAGE OverloadedStrings #-}

-- | Protocol-level debugging tools for synapse
--
-- Use: synapse _self debug <host> <port> <backend>
--
-- This module provides tools to debug WebSocket connection issues,
-- protocol mismatches, and transport-level problems.
module Synapse.Self.Debugger
  ( debugConnection
  , validateProtocol
  , testMethod
  ) where

import Control.Exception (SomeException, catch, try)
import Control.Monad (unless, forM_)
import Control.Monad.IO.Class (liftIO)
import qualified Data.Aeson as Aeson
import Data.Aeson (Value(..), object, eitherDecode)
import Data.Aeson.Encode.Pretty (encodePretty)
import qualified Data.Aeson.Key as K
import qualified Data.Aeson.KeyMap as KM
import qualified Data.ByteString.Char8 as BS
import qualified Data.ByteString.Lazy as LBS
import qualified Data.ByteString.Lazy.Char8 as LBS8
import Data.List (partition)
import qualified Data.Map.Strict as Map
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.IO as TIO
import qualified Data.Text.Encoding as TE
import qualified Network.Socket as NS
import qualified Network.Socket.ByteString as NSB
import qualified Network.WebSockets as WS
import Plexus.Client (SubstrateConfig(..))
import System.Exit (exitFailure, exitSuccess)
import System.IO (hFlush, stdout, stderr, hPutStrLn)
import System.Timeout (timeout)

import Synapse.CLI.Parse (parseParams)
import Synapse.IR.Builder (buildIR)
import Synapse.IR.Types (irMethods, MethodDef, mdParams, pdName)
import Synapse.Log (Logger, makeLogger)
import qualified Katip
import Synapse.Monad (SynapseEnv(..), initEnv, runSynapseM)
import qualified Synapse.Self.Protocol.TestRunner as TestRunner

-- | Debug connection to a Plexus backend
--
-- Tests:
-- 1. TCP connection
-- 2. HTTP upgrade request
-- 3. WebSocket handshake
-- 4. Protocol version compatibility
debugConnection :: String -> Int -> Text -> IO ()
debugConnection host port backend = do
  putStrLn $ "Debugging connection to " <> host <> ":" <> show port
  putStrLn $ "Backend: " <> T.unpack backend
  putStrLn ""

  -- Step 1: TCP connection
  putStr "[1/4] Testing TCP connection... "
  hFlush stdout
  tcpResult <- timeout 3000000 $ testTCPConnection host port
  case tcpResult of
    Nothing -> do
      putStrLn "TIMEOUT (3s)"
      putStrLn "      TCP connection timed out. Server may be down or unreachable."
      return ()
    Just (Left err) -> do
      putStrLn $ "FAILED: " <> show err
      putStrLn "      Cannot establish TCP connection. Check if server is running."
      return ()
    Just (Right ()) -> do
      putStrLn "OK"

      -- Step 2: HTTP check
      putStr "[2/4] Testing HTTP endpoint... "
      hFlush stdout
      httpResult <- timeout 3000000 $ testHTTPEndpoint host port
      case httpResult of
        Nothing -> putStrLn "TIMEOUT (3s)"
        Just (Left err) -> putStrLn $ "FAILED: " <> show err
        Just (Right response) -> do
          putStrLn "OK"
          putStrLn $ "      " <> T.unpack response

      -- Step 3: WebSocket handshake
      putStr "[3/4] Testing WebSocket upgrade... "
      hFlush stdout
      wsResult <- timeout 5000000 $ testWebSocketHandshake host port
      case wsResult of
        Nothing -> do
          putStrLn "TIMEOUT (5s)"
          putStrLn ""
          putStrLn "ROOT CAUSE: WebSocket handshake hangs"
          putStrLn "  The server accepts TCP connections but doesn't complete"
          putStrLn "  the WebSocket upgrade. This indicates:"
          putStrLn "  - Server is not a WebSocket server on this port"
          putStrLn "  - Protocol version mismatch"
          putStrLn "  - Server is waiting for different handshake format"
        Just (Left err) -> do
          putStrLn $ "FAILED: " <> show err
          putStrLn ""
          putStrLn "WebSocket handshake rejected by server"
        Just (Right ()) -> do
          putStrLn "OK"

          -- Step 4: Protocol test
          putStr "[4/4] Testing Plexus RPC protocol... "
          hFlush stdout
          rpcResult <- timeout 5000000 $ testPlexusRPC host port backend
          case rpcResult of
            Nothing -> do
              putStrLn "TIMEOUT (5s)"
              putStrLn ""
              putStrLn "ROOT CAUSE: Plexus RPC protocol timeout"
              putStrLn "  The server accepts WebSocket connections but:"
              putStrLn "  - Does not send required StreamDone messages"
              putStrLn "  - Does not respond to _info requests correctly"
              putStrLn "  - May not be a valid Plexus RPC server"
            Just (Left err) -> putStrLn $ "FAILED: " <> show err
            Just (Right ()) -> putStrLn "OK"

-- | Test TCP connection
testTCPConnection :: String -> Int -> IO (Either SomeException ())
testTCPConnection host port = try $ do
  addr <- NS.getAddrInfo
    (Just NS.defaultHints { NS.addrSocketType = NS.Stream })
    (Just host)
    (Just $ show port)
  case addr of
    [] -> error "No address info"
    (serverAddr:_) -> do
      sock <- NS.socket (NS.addrFamily serverAddr) NS.Stream NS.defaultProtocol
      NS.connect sock (NS.addrAddress serverAddr)
      NS.close sock

-- | Test HTTP endpoint
testHTTPEndpoint :: String -> Int -> IO (Either SomeException Text)
testHTTPEndpoint host port = try $ do
  addr <- NS.getAddrInfo
    (Just NS.defaultHints { NS.addrSocketType = NS.Stream })
    (Just host)
    (Just $ show port)
  case addr of
    [] -> error "No address info"
    (serverAddr:_) -> do
      sock <- NS.socket (NS.addrFamily serverAddr) NS.Stream NS.defaultProtocol
      NS.connect sock (NS.addrAddress serverAddr)
      let request = "GET / HTTP/1.1\r\nHost: " <> BS.pack (host <> ":" <> show port) <> "\r\n\r\n"
      NSB.sendAll sock request
      response <- NSB.recv sock 1024
      NS.close sock
      return $ T.pack $ BS.unpack $ BS.take 100 response

-- | Test WebSocket handshake
testWebSocketHandshake :: String -> Int -> IO (Either SomeException ())
testWebSocketHandshake host port = try $
  WS.runClient host port "/" $ \_conn -> return ()

-- | Test Plexus RPC protocol
testPlexusRPC :: String -> Int -> Text -> IO (Either SomeException ())
testPlexusRPC host port backend = try $
  WS.runClient host port "/" $ \conn -> do
    -- Send _info request
    let request = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"" <> T.unpack backend <> "._info\",\"params\":{}}"
    WS.sendTextData conn (T.pack request :: Text)

    -- Wait for response (simplified - real implementation would parse)
    _response <- WS.receiveData conn :: IO Text
    return ()

-- | Validate protocol compliance of a backend
--
-- Runs the full protocol validation test suite against the specified backend
-- and reports any violations found.
--
-- Use: synapse _self validate <host> <port> <backend>
--
-- This connects to the backend and runs various protocol validation tests:
-- - Basic protocol compliance (_debug.protocol_test)
-- - Stream handling with slow data (_debug.stream_test)
-- - Progress reporting (_debug.stream_test with progress)
-- - Error handling (_debug.error_test)
-- - Metadata edge cases (_debug.metadata_test)
--
-- Exits with code 0 on success, 1 on protocol violations or errors.
validateProtocol :: String -> Int -> Text -> IO ()
validateProtocol host port backend = do
  TIO.putStrLn $ "Running protocol validation tests against " <> T.pack host <> ":" <> T.pack (show port) <> "..."
  TIO.putStrLn $ "Backend: " <> backend
  TIO.putStrLn ""

  -- Run the protocol test suite
  result <- TestRunner.runProtocolTests (T.pack host) port backend

  case result of
    Left errorReport -> do
      -- Protocol violations detected or test errors
      TIO.putStrLn errorReport
      TIO.putStrLn ""
      exitFailure

    Right successReport -> do
      -- All tests passed
      TIO.putStrLn successReport
      TIO.putStrLn ""
      exitSuccess

-- | Test an arbitrary method with protocol validation
--
-- Three modes:
-- - Mode 1 (default): Validated - uses parseParams, rejects unknown params
-- - Mode 2 (--allow-unknown): Warns about unknown params but passes them through
-- - Mode 3 (--raw <json>): Parses params as raw JSON, skips schema validation
--
-- Use: synapse _self test [--allow-unknown] [--raw <json>] <host> <port> <backend> <method> [--param value ...]
--
-- Examples:
--   synapse _self test localhost 5001 substrate echo.echo --message "hello"
--   synapse _self test --allow-unknown localhost 5001 substrate echo.echo --message "hello" --fake "test"
--   synapse _self test --raw '{"message":"hello"}' localhost 5001 substrate echo.echo
testMethod :: SubstrateConfig -> Text -> [(Text, Text)] -> Bool -> Maybe Text -> IO ()
testMethod config method cliParams allowUnknown rawJson = do
  let backend = substrateBackend config
  let host = substrateHost config
  let port = substratePort config

  -- Mode 3: Raw JSON
  case rawJson of
    Just jsonStr -> do
      case eitherDecode (LBS.fromStrict $ TE.encodeUtf8 jsonStr) of
        Left err -> do
          TIO.putStrLn $ "Error: Invalid JSON: " <> T.pack err
          exitFailure
        Right params -> runTest config method params

    -- Mode 1 & 2: Fetch schema and parse params
    Nothing -> do
      putStr "Fetching schema... "
      hFlush stdout

      -- Build IR for the method's namespace
      let methodParts = T.splitOn "." method
      let namespacePath = if length methodParts > 1 then init methodParts else []

      -- Create a SynapseM environment for buildIR
      logger <- makeLogger Katip.ErrorS  -- Use error-level logger for test command
      env <- initEnv (T.pack host) port backend logger Nothing

      -- Run buildIR within SynapseM
      buildResult <- runSynapseM env (buildIR [] namespacePath)

      case buildResult of
        Left err -> do
          putStrLn "FAILED"
          TIO.putStrLn ""
          TIO.putStrLn "[SCHEMA ERROR] Failed to build IR from schema"
          TIO.putStrLn $ "Error: " <> T.pack (show err)
          TIO.putStrLn ""
          TIO.putStrLn "This usually means:"
          TIO.putStrLn "  1. Method schema is incompatible with synapse client"
          TIO.putStrLn "  2. Schema format differs from expected structure"
          TIO.putStrLn "  3. Method was generated by incompatible macro (hub_methods vs plexus-derive)"
          TIO.putStrLn ""
          TIO.putStrLn "Try testing with raw WebSocket to verify method works:"
          TIO.putStrLn $ "  echo '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"" <> method <> "\",\"params\":{...}}' | websocat ws://" <> T.pack host <> ":" <> T.pack (show port)
          TIO.putStrLn ""
          TIO.putStrLn "Or use the websocket-raw-test:"
          TIO.putStrLn "  cabal test websocket-raw-test"
          exitFailure
        Right ir -> do
          putStrLn "OK"

          -- Look up method definition
          case Map.lookup method (irMethods ir) of
            Nothing -> do
              TIO.putStrLn $ "Error: Method not found in schema: " <> method
              exitFailure
            Just methodDef -> do
              -- Mode 1: Validated (strict)
              if not allowUnknown
                then do
                  case parseParams ir methodDef cliParams of
                    Left errors -> do
                      TIO.putStrLn "Parameter validation failed:"
                      mapM_ (TIO.putStrLn . T.pack . show) errors
                      exitFailure
                    Right params ->
                      runTest config method params

              -- Mode 2: Allow unknown params
              else do
                let (knownParams, unknownParams) = partitionParams methodDef cliParams

                unless (null unknownParams) $
                  TIO.putStrLn $ "⚠️  Unknown parameters: " <> T.intercalate ", " (map fst unknownParams) <> " (passing through anyway)"

                -- Parse known params with IR validation
                knownParsed <- case parseParams ir methodDef knownParams of
                  Left errors -> do
                    TIO.putStrLn "Error parsing known parameters:"
                    mapM_ (TIO.putStrLn . T.pack . show) errors
                    exitFailure
                  Right p -> pure p

                -- Parse unknown params with best-effort type inference
                let unknownParsed = parseGeneric unknownParams

                -- Merge the two
                let params = mergeJson knownParsed unknownParsed
                runTest config method params

-- | Run the actual test and report results
runTest :: SubstrateConfig -> Text -> Value -> IO ()
runTest config method params = do
  TIO.putStrLn $ "Testing: " <> method
  result <- TestRunner.testEndpoint config method params

  case result of
    Left err -> do
      TIO.putStrLn "[FAIL] Test FAILED"
      TIO.putStrLn err
      exitFailure

    Right (violations, msgCount, rawMessages) -> do
      if null violations
        then do
          TIO.putStrLn $ "[PASS] Protocol VALID (" <> T.pack (show msgCount) <> " messages)"
          TIO.putStrLn "   - StreamDone sent"
          TIO.putStrLn "   - Metadata structure correct"
          TIO.putStrLn "   - Field naming valid"
          exitSuccess
        else do
          TIO.putStrLn $ "[FAIL] Protocol violations (" <> T.pack (show (length violations)) <> ")"
          TIO.putStrLn ""
          TIO.putStrLn "Raw messages received:"
          forM_ (zip [1..] rawMessages) $ \(idx, msg) -> do
            TIO.putStrLn $ "  Message " <> T.pack (show idx) <> ":"
            TIO.putStrLn $ "    " <> (TE.decodeUtf8 $ LBS.toStrict $ encodePretty msg)
          TIO.putStrLn ""
          TIO.putStrLn "Violations detected:"
          mapM_ (TIO.putStrLn . ("  " <>) . T.pack . show) violations
          exitFailure

-- | Partition params into known (in method def) and unknown
-- Handles dotted parameters like "identifier.type" by extracting the first segment
partitionParams :: MethodDef -> [(Text, Text)] -> ([(Text, Text)], [(Text, Text)])
partitionParams methodDef params =
  let paramNames = map pdName (mdParams methodDef)
      isKnown (key, _) =
        let baseKey = case T.breakOn "." key of
              (base, "") -> base  -- No dot, use the whole key
              (base, _)  -> base  -- Has dot, use first segment
        in baseKey `elem` paramNames
  in partition isKnown params

-- | Parse unknown params with best-effort type inference
parseGeneric :: [(Text, Text)] -> Value
parseGeneric pairs = object
  [ (K.fromText k, inferValue v) | (k, v) <- pairs ]
  where
    inferValue :: Text -> Value
    inferValue t
      | t == "true" = Bool True
      | t == "false" = Bool False
      | t == "" = Bool True  -- Flag with no value
      | Just n <- readMaybeInt t = Number (fromIntegral n)
      | Just n <- readMaybeDouble t = Number (realToFrac n)
      | otherwise = String t

    readMaybeInt :: Text -> Maybe Integer
    readMaybeInt = readMaybe . T.unpack

    readMaybeDouble :: Text -> Maybe Double
    readMaybeDouble = readMaybe . T.unpack

-- | Merge two JSON objects
mergeJson :: Value -> Value -> Value
mergeJson (Object a) (Object b) = Object (KM.union a b)
mergeJson (Object a) _ = Object a
mergeJson _ (Object b) = Object b
mergeJson a _ = a

-- | Read helper
readMaybe :: Read a => String -> Maybe a
readMaybe s = case reads s of
  [(x, "")] -> Just x
  _ -> Nothing