unix-process-conduit 0.2.0.2 → 0.2.1
raw patch · 5 files changed
+575/−66 lines, 5 filesdep +directorydep +filepathdep +stm
Dependencies added: directory, filepath, stm, time
Files
- Data/Conduit/LogFile.hs +141/−0
- Data/Conduit/Process/Unix.hs +310/−38
- LICENSE +17/−27
- cbits/process-tracker.c +100/−0
- unix-process-conduit.cabal +7/−1
+ Data/Conduit/LogFile.hs view
@@ -0,0 +1,141 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TemplateHaskell #-}+module Data.Conduit.LogFile+ ( RotatingLog+ , openRotatingLog+ , addChunk+ , close+ , defaultMaxTotal+ , dummy+ ) where++import Control.Concurrent (forkIO)+import Control.Concurrent.STM (atomically)+import Control.Concurrent.STM.TBQueue+import Control.Concurrent.STM.TVar+import Control.Exception (bracket, bracketOnError,+ finally)+import Control.Monad (void, when)+import qualified Data.ByteString as S+import Data.Time (UTCTime, getCurrentTime)+import Data.Word (Word)+import System.Directory (createDirectoryIfMissing,+ doesFileExist, renameFile)+import System.FilePath ((<.>), (</>))+import qualified System.IO as SIO+import System.IO.Unsafe (unsafePerformIO)+import System.Mem.Weak (addFinalizer)++data Command = AddChunk !S.ByteString+ | Close++-- | Represents a folder used for totating log files.+--+-- Since 0.2.1+data RotatingLog = RotatingLog !(TVar State)+-- Use a data instead of a newtype so that we can attach a finalizer.++-- | A @RotatingLog@ which performs no logging.+--+-- Since 0.2.1+dummy :: RotatingLog+dummy = RotatingLog $! unsafePerformIO $! newTVarIO Closed++data State = Closed+ | Running !SIO.Handle !(TBQueue Command)++queue :: Command -> RotatingLog -> IO ()+queue cmd (RotatingLog ts) = atomically $ do+ s <- readTVar ts+ case s of+ Closed -> return ()+ Running _ q -> writeTBQueue q cmd++addChunk :: RotatingLog -> S.ByteString -> IO ()+addChunk lf bs = queue (AddChunk bs) lf++close :: RotatingLog -> IO ()+close = queue Close++-- | Create a new @RotatingLog@.+--+-- Since 0.2.1+openRotatingLog :: FilePath -- ^ folder to contain logs+ -> Word -- ^ maximum log file size, in bytes+ -> IO RotatingLog+openRotatingLog dir maxTotal = do+ createDirectoryIfMissing True dir+ bracketOnError (moveCurrent dir) SIO.hClose $ \handle -> do+ queue <- newTBQueueIO 5+ let s = Running handle queue+ ts <- newTVarIO s+ void $ forkIO $ loop dir ts maxTotal+ let rl = RotatingLog ts+ addFinalizer rl (atomically (writeTBQueue queue Close))+ return rl++current :: FilePath -- ^ folder containing logs+ -> FilePath+current = (</> "current.log")++moveCurrent :: FilePath -- ^ folder containing logs+ -> IO SIO.Handle -- ^ new handle+moveCurrent dir = do+ let curr = current dir+ x <- doesFileExist curr+ when x $ do+ now <- getCurrentTime+ renameFile curr $ dir </> suffix now+ SIO.openFile curr SIO.WriteMode++suffix :: UTCTime -> FilePath+suffix now =+ (concatMap fix $ takeWhile (/= '.') $ show now) <.> "log"+ where+ fix ' ' = "_"+ fix c | '0' <= c && c <= '9' = [c]+ fix _ = ""++loop :: FilePath -- ^ folder containing logs+ -> TVar State+ -> Word -- ^ maximum total log size+ -> IO ()+loop dir ts maxTotal =+ go 0 `finally` (closeCurrentHandle `finally` atomically (writeTVar ts Closed))+ where+ closeCurrentHandle = bracket+ (atomically $ do+ s <- readTVar ts+ case s of+ Closed -> return Nothing+ Running h _ -> return $! Just h)+ (maybe (return ()) SIO.hClose)+ (const $ return ())++ go total = do+ res <- atomically $ do+ s <- readTVar ts+ case s of+ Closed -> return Nothing+ Running handle queue -> do+ cmd <- readTBQueue queue+ case cmd of+ Close -> return Nothing+ AddChunk bs -> return $! Just (handle, queue, bs)+ case res of+ Nothing -> return ()+ Just (handle, queue, bs) -> do+ let total' = total + fromIntegral (S.length bs)+ S.hPut handle bs+ SIO.hFlush handle+ if total' > maxTotal+ then do+ bracket+ (SIO.hClose handle >> moveCurrent dir)+ (\handle' -> atomically $ writeTVar ts $ Running handle' queue)+ (const $ return ())+ go 0+ else go total'++defaultMaxTotal :: Word+defaultMaxTotal = 5 * 1024 * 1024 -- 5 MB
Data/Conduit/Process/Unix.hs view
@@ -1,49 +1,96 @@-{-# LANGUAGE ForeignFunctionInterface, OverloadedStrings, ScopedTypeVariables #-}+{-# LANGUAGE DeriveDataTypeable #-}+{-# LANGUAGE ForeignFunctionInterface #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-} module Data.Conduit.Process.Unix- ( forkExecuteFile+ ( -- * Starting processes+ forkExecuteFile , killProcess , terminateProcess , waitForProcess , ProcessStatus (..) , signalProcessHandle , signalProcessHandleGroup+ -- * Process tracking+ -- $processTracker++ -- ** Types+ , ProcessTracker+ , TrackedProcess+ , ProcessTrackerException (..)+ -- ** Functions+ , initProcessTracker+ , trackProcess+ , untrackProcess++ -- * Logging+ , RotatingLog+ , openRotatingLog+ , forkExecuteLog++ -- * Monitored process+ , MonitoredProcess+ , monitorProcess+ , terminateMonitoredProcess ) where -import Control.Applicative ((<$>), (<*>))-import Control.Arrow ((***))-import Control.Concurrent (forkIO)-import Control.Exception (finally, mask, onException, handle, SomeException)-import Control.Monad (unless, void, zipWithM_, when)-import Control.Monad.Trans.Class (lift)-import Data.ByteString (ByteString, null, concat, append, singleton)-import qualified Data.ByteString.Char8 as S8-import Data.ByteString.Unsafe (unsafePackCStringFinalizer,- unsafeUseAsCStringLen,- unsafeUseAsCString)-import Data.Conduit (Sink, Source, yield, ($$))-import Data.Conduit.Binary (sinkHandle, sourceHandle)-import Data.Conduit.List (mapM_)-import Foreign.Marshal.Alloc (free, mallocBytes, allocaBytes)-import Foreign.Ptr (castPtr, Ptr, nullPtr)-import Foreign.Storable (sizeOf, pokeElemOff)-import Prelude (Bool (..), IO, Maybe (..),- Monad (..), flip,- fromIntegral, fst, maybe,- snd, ($), (.), (==), error, id, length, (*),- head, map, const, fmap)-import System.Posix.Types (Fd)-import System.Posix.IO.ByteString (closeFd, createPipe,- fdReadBuf, fdWriteBuf,- setFdOption, FdOption (CloseOnExec))-import System.Posix.Process.ByteString (ProcessStatus (..),- getProcessStatus, getProcessGroupIDOf)-import System.Posix.Signals (sigKILL, signalProcess, Signal, signalProcessGroup)-import System.Posix.Types (ProcessID)-import System.IO (hClose)-import Foreign.C.Types-import Foreign.C.String-import System.Process-import System.Process.Internals+import Control.Applicative ((<$>), (<*>))+import Control.Arrow ((***))+import Control.Concurrent (forkIO)+import Control.Concurrent (threadDelay)+import Control.Concurrent.MVar (MVar, modifyMVar, newMVar,+ readMVar, swapMVar)+import Control.Exception (Exception, SomeException,+ bracketOnError, finally,+ handle, mask, mask_,+ onException, throwIO, try)+import Control.Monad (unless, void, when, zipWithM_)+import Control.Monad.Trans.Class (lift)+import Data.ByteString (ByteString, append, concat,+ null, singleton)+import qualified Data.ByteString.Char8 as S8+import Data.ByteString.Unsafe (unsafePackCStringFinalizer,+ unsafeUseAsCString,+ unsafeUseAsCStringLen)+import Data.Conduit (Sink, Source, yield, ($$))+import Data.Conduit.Binary (sinkHandle, sourceHandle)+import Data.Conduit.List (mapM_)+import qualified Data.Conduit.List as CL+import Data.Conduit.LogFile+import Data.IORef (IORef, newIORef, readIORef,+ writeIORef)+import Data.Time (getCurrentTime)+import Data.Time (diffUTCTime)+import Data.Typeable (Typeable)+import Foreign.C.String+import Foreign.C.Types+import Foreign.Marshal.Alloc (allocaBytes, free,+ mallocBytes)+import Foreign.Ptr (Ptr, castPtr, nullPtr)+import Foreign.Storable (pokeElemOff, sizeOf)+import Prelude (Bool (..), Either (..), IO,+ Maybe (..), Monad (..), Show,+ const, error, flip, fmap,+ fromIntegral, fst, head, id,+ length, map, maybe, show, snd,+ ($), ($!), (*), (.), (<),+ (==))+import System.IO (hClose)+import System.Posix.IO.ByteString (FdOption (CloseOnExec),+ closeFd, createPipe,+ fdReadBuf, fdToHandle,+ fdWriteBuf, setFdOption)+import System.Posix.Process.ByteString (ProcessStatus (..),+ getProcessGroupIDOf,+ getProcessStatus)+import System.Posix.Signals (Signal, sigKILL,+ signalProcess,+ signalProcessGroup)+import System.Posix.Types (CPid (..), Fd, ProcessID)+import System.Process+import System.Process.Internals (ProcessHandle (..),+ ProcessHandle__ (..),+ withProcessHandle_) -- | Kill a process by sending it the KILL (9) signal. --@@ -109,7 +156,6 @@ Nothing -> return () return ph where- ignoreExceptions = handle (\(_ :: SomeException) -> return ()) cp = CreateProcess { cmdspec = RawCommand (S8.unpack cmd) (map S8.unpack args) , cwd = S8.unpack <$> mwdir@@ -121,6 +167,9 @@ , create_group = True } +ignoreExceptions :: IO () -> IO ()+ignoreExceptions = handle (\(_ :: SomeException) -> return ())+ closeOnExec :: Fd -> IO () closeOnExec fd = setFdOption fd CloseOnExec True @@ -146,3 +195,226 @@ run ptrs = allocaBytes (length ptrs * sizeOf (head ptrs)) $ \res -> zipWithM_ (pokeElemOff res) [0..] ptrs >> f res++-- $processTracker+--+-- Ensure that child processes are killed, regardless of how the parent process exits.+--+-- The technique used here is:+--+-- * Create a pipe.+--+-- * Fork a new child process that listens on the pipe.+--+-- * In the current process, send updates about processes that should be auto-killed.+--+-- * When the parent process dies, listening on the pipe in the child process will get an EOF.+--+-- * When the child process receives that EOF, it kills all processes it was told to auto-kill.+--+-- This code was originally written for Keter, but was moved to unix-process+-- conduit in the 0.2.1 release.++foreign import ccall unsafe "launch_process_tracker"+ c_launch_process_tracker :: IO CInt++foreign import ccall unsafe "track_process"+ c_track_process :: ProcessTracker -> CPid -> CInt -> IO ()++-- | Represents the child process which handles process cleanup.+--+-- Since 0.2.1+newtype ProcessTracker = ProcessTracker CInt++-- | Represents a child process which is currently being tracked by the cleanup+-- child process.+--+-- Since 0.2.1+data TrackedProcess = TrackedProcess !ProcessTracker !(IORef MaybePid)++data MaybePid = NoPid | Pid !CPid++-- | Fork off the child cleanup process.+--+-- This will ideally only be run once for your entire application.+--+-- Since 0.2.1+initProcessTracker :: IO ProcessTracker+initProcessTracker = do+ i <- c_launch_process_tracker+ if i == -1+ then throwIO CannotLaunchProcessTracker+ else return $! ProcessTracker i++-- | Since 0.2.1+data ProcessTrackerException = CannotLaunchProcessTracker+ deriving (Show, Typeable)+instance Exception ProcessTrackerException++-- | Begin tracking the given process. If the 'ProcessHandle' refers to a+-- closed process, no tracking will occur. If the process is closed, then it+-- will be untracked automatically.+--+-- Note that you /must/ compile your program with @-threaded@; see+-- 'waitForProcess'.+--+-- Since 0.2.1+trackProcess :: ProcessTracker -> ProcessHandle -> IO TrackedProcess+trackProcess pt ph@(ProcessHandle mph) = mask_ $ do+ mpid <- readMVar mph+ mpid' <- case mpid of+ ClosedHandle{} -> return NoPid+ OpenHandle pid -> do+ c_track_process pt pid 1+ return $ Pid pid+ ipid <- newIORef mpid'+ let tp = TrackedProcess pt ipid+ case mpid' of+ NoPid -> return ()+ Pid _ -> void $ forkIO $ do+ void $ waitForProcess ph+ untrackProcess tp+ return $! tp++-- | Explicitly remove the given process from the tracked process list in the+-- cleanup process.+--+-- Since 0.2.1+untrackProcess :: TrackedProcess -> IO ()+untrackProcess (TrackedProcess pt ipid) = mask_ $ do+ mpid <- readIORef ipid+ case mpid of+ NoPid -> return ()+ Pid pid -> do+ c_track_process pt pid 0+ writeIORef ipid NoPid++-- | Fork and execute a subprocess, sending stdout and stderr to the specified+-- rotating log.+--+-- Since 0.2.1+forkExecuteLog :: ByteString -- ^ command+ -> [ByteString] -- ^ args+ -> Maybe [(ByteString, ByteString)] -- ^ environment+ -> Maybe ByteString -- ^ working directory+ -> Maybe (Source IO ByteString) -- ^ stdin+ -> RotatingLog -- ^ both stdout and stderr will be sent to this location+ -> IO ProcessHandle+forkExecuteLog cmd args menv mwdir mstdin rlog = bracketOnError+ setupPipe+ cleanupPipes+ usePipes+ where+ setupPipe = bracketOnError+ createPipe+ (\(x, y) -> closeFd x `finally` closeFd y)+ (\(x, y) -> (,) <$> fdToHandle x <*> fdToHandle y)+ cleanupPipes (x, y) = hClose x `finally` hClose y++ usePipes pipes@(readerH, writerH) = do+ (min, _, _, ph) <- createProcess CreateProcess+ { cmdspec = RawCommand (S8.unpack cmd) (map S8.unpack args)+ , cwd = S8.unpack <$> mwdir+ , env = map (S8.unpack *** S8.unpack) <$> menv+ , std_in = maybe Inherit (const CreatePipe) mstdin+ , std_out = UseHandle writerH+ , std_err = UseHandle writerH+ , close_fds = True+ , create_group = True+ }+ ignoreExceptions $ addAttachMessage pipes ph+ void $ forkIO $ ignoreExceptions $+ (sourceHandle readerH $$ CL.mapM_ (addChunk rlog)) `finally` hClose readerH+ case (min, mstdin) of+ (Just h, Just source) -> void $ forkIO $ ignoreExceptions $+ (source $$ sinkHandle h) `finally` hClose h+ (Nothing, Nothing) -> return ()+ _ -> error $ "Invariant violated: Data.Conduit.Process.Unix.forkExecuteLog"+ return ph++ addAttachMessage pipes ph = withProcessHandle_ ph $ \p_ -> do+ now <- getCurrentTime+ case p_ of+ ClosedHandle ec -> do+ addChunk rlog $ S8.concat+ [ "\n\n"+ , S8.pack $ show now+ , ": Process immediately died with exit code "+ , S8.pack $ show ec+ , "\n\n"+ ]+ cleanupPipes pipes+ OpenHandle h -> do+ addChunk rlog $ S8.concat+ [ "\n\n"+ , S8.pack $ show now+ , ": Attached new process "+ , S8.pack $ show h+ , "\n\n"+ ]+ return p_++data Status = NeedsRestart | NoRestart | Running ProcessHandle++-- | Run the given command, restarting if the process dies.+monitorProcess+ :: (ByteString -> IO ()) -- ^ log+ -> ProcessTracker+ -> Maybe S8.ByteString -- ^ setuid+ -> S8.ByteString -- ^ executable+ -> S8.ByteString -- ^ working directory+ -> [S8.ByteString] -- ^ command line parameter+ -> [(S8.ByteString, S8.ByteString)] -- ^ environment+ -> RotatingLog+ -> IO MonitoredProcess+monitorProcess log processTracker msetuid exec dir args env rlog = do+ mstatus <- newMVar NeedsRestart+ let loop mlast = do+ next <- modifyMVar mstatus $ \status ->+ case status of+ NoRestart -> return (NoRestart, return ())+ _ -> do+ now <- getCurrentTime+ case mlast of+ Just last | diffUTCTime now last < 5 -> do+ log $ "Process restarting too quickly, waiting before trying again: " `S8.append` exec+ threadDelay $ 5 * 1000 * 1000+ _ -> return ()+ let (cmd, args') =+ case msetuid of+ Nothing -> (exec, args)+ Just setuid -> ("sudo", "-E" : "-u" : setuid : "--" : exec : args)+ res <- try $ forkExecuteLog+ cmd+ args'+ (Just env)+ (Just dir)+ (Just $ return ())+ rlog+ case res of+ Left e -> do+ log $ "Data.Conduit.Process.Unix.monitorProcess: " `S8.append` S8.pack (show (e :: SomeException))+ return (NeedsRestart, return ())+ Right pid -> do+ log $ "Process created: " `S8.append` exec+ return (Running pid, do+ void $ trackProcess processTracker pid+ void $ waitForProcess pid+ loop (Just now))+ next+ forkIO $ loop Nothing+ return $ MonitoredProcess mstatus++-- | Abstract type containing information on a process which will be restarted.+newtype MonitoredProcess = MonitoredProcess (MVar Status)++-- | Terminate the process and prevent it from being restarted.+terminateMonitoredProcess :: MonitoredProcess -> IO ()+terminateMonitoredProcess (MonitoredProcess mstatus) = do+ status <- swapMVar mstatus NoRestart+ case status of+ Running pid -> do+ terminateProcess pid+ threadDelay 1000000+ killProcess pid+ _ -> return ()
LICENSE view
@@ -1,30 +1,20 @@-Copyright (c)2011, Michael Snoyman--All rights reserved.--Redistribution and use in source and binary forms, with or without-modification, are permitted provided that the following conditions are met:-- * Redistributions of source code must retain the above copyright- notice, this list of conditions and the following disclaimer.+Copyright (c) 2012 Michael Snoyman, http://www.yesodweb.com/ - * Redistributions in binary form must reproduce the above- copyright notice, this list of conditions and the following- disclaimer in the documentation and/or other materials provided- with the distribution.+Permission is hereby granted, free of charge, to any person obtaining+a copy of this software and associated documentation files (the+"Software"), to deal in the Software without restriction, including+without limitation the rights to use, copy, modify, merge, publish,+distribute, sublicense, and/or sell copies of the Software, and to+permit persons to whom the Software is furnished to do so, subject to+the following conditions: - * Neither the name of Michael Snoyman nor the names of other- contributors may be used to endorse or promote products derived- from this software without specific prior written permission.+The above copyright notice and this permission notice shall be+included in all copies or substantial portions of the Software. -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS-"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT-LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR-A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT-OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,-SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT-LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,-DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY-THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT-(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE-OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ cbits/process-tracker.c view
@@ -0,0 +1,100 @@+#include <unistd.h>+#include <stdlib.h>+#include <stdio.h>+#include <signal.h>++struct node {+ pid_t pid;+ struct node *next;+};++static struct node * add_node(pid_t pid, struct node *n) {+ struct node *n2 = malloc(sizeof(struct node));+ n2->pid = pid;+ n2->next = n;+ return n2;+}++static struct node * remove_node(pid_t pid, struct node *n) {+ if (!n) {+ return n;+ }+ else if (n->pid == pid) {+ struct node *n2 = n->next;+ free(n);+ return remove_node(pid, n2);+ }+ else {+ n->next = remove_node(pid, n->next);+ return n;+ }+}++extern void track_process(int fd, pid_t pid, int b) {+ unsigned int buffer[2];++ //printf("Tracking process %d, %d\n", pid, b);++ buffer[0] = pid;+ buffer[1] = b;+ if (! write(fd, buffer, sizeof(unsigned int) * 2)) {+ //printf("Error writing to fd %d\n", fd);+ }+}++// Returns FD to write to, or -1 on failure.+extern int launch_process_tracker(void) {+ int pipes[2];+ pid_t child;++ if (pipe(pipes)) {+ return -1;+ }++ child = fork();++ if (child < 0) {+ return -1;+ }+ else if (child == 0) {+ unsigned int buffer[2];+ struct node *n = 0, *n2;++ close(pipes[1]);++ // Prevent monitoring programs like Upstart from killing this+ // new process along with the parent+ setpgid(0, 0);++ while (read(pipes[0], buffer, sizeof(unsigned int) * 2) > 0) {+ if (buffer[1]) {+ //printf("Adding node %d\n", buffer[0]);+ n = add_node(buffer[0], n);+ }+ else {+ //printf("Removing node %d\n", buffer[0]);+ n = remove_node(buffer[0], n);+ }+ }++ for (n2 = n; n2; n2 = n2->next) {+ //printf("Sending process %d TERM signal\n", n2->pid);+ kill(n2->pid, SIGTERM);+ }++ sleep(2);++ while (n) {+ //printf("Sending process %d KILL signal\n", n->pid);+ kill(n2->pid, SIGKILL);++ n2 = n;+ n = n->next;+ free(n2);+ }+ }+ else {+ close(pipes[0]);+ return pipes[1];+ }+}
unix-process-conduit.cabal view
@@ -2,7 +2,7 @@ -- documentation, see http://haskell.org/cabal/users-guide/ name: unix-process-conduit-version: 0.2.0.2+version: 0.2.1 synopsis: Run processes on Unix systems, with a conduit interface description: This library allows you to provide @conduit@ datatypes for the input and output streams. Note that you must compile your programs with @-threaded@. homepage: https://github.com/snoyberg/conduit@@ -16,12 +16,18 @@ library exposed-modules: Data.Conduit.Process.Unix+ Data.Conduit.LogFile build-depends: base >= 4 && < 5 , transformers , bytestring , conduit >= 0.5 && < 1.1 , unix >= 2.5 , process >= 1.1+ , time+ , filepath+ , stm+ , directory+ c-sources: cbits/process-tracker.c test-suite test hs-source-dirs: test