diff options
| author | Antonin Kral <a.kral@bobek.cz> | 2012-08-29 20:54:51 +0200 |
|---|---|---|
| committer | Antonin Kral <a.kral@bobek.cz> | 2012-08-29 20:54:51 +0200 |
| commit | 83957b73f9177f6e38bd5375bd93ca1f6a47188c (patch) | |
| tree | f20b7d6ac9a9c64ff5bb6b5910a24abbb356b1d5 /src/mongo/shell/shell_utils_launcher.cpp | |
| parent | 5071d203970edd4c995493d810abe20987e76fe9 (diff) | |
Imported Upstream version 2.2.0upstream/2.2.0
Diffstat (limited to 'src/mongo/shell/shell_utils_launcher.cpp')
| -rw-r--r-- | src/mongo/shell/shell_utils_launcher.cpp | 752 |
1 files changed, 752 insertions, 0 deletions
diff --git a/src/mongo/shell/shell_utils_launcher.cpp b/src/mongo/shell/shell_utils_launcher.cpp new file mode 100644 index 00000000000..60af4f3a6cb --- /dev/null +++ b/src/mongo/shell/shell_utils_launcher.cpp @@ -0,0 +1,752 @@ +// mongo/shell/shell_utils_launcher.cpp +/* + * Copyright 2010 10gen Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "mongo/pch.h" + +#include "mongo/shell/shell_utils_launcher.h" + +#include <boost/thread/thread.hpp> +#include <iostream> +#include <map> +#include <vector> + +#ifdef _WIN32 +# include <fcntl.h> +# include <io.h> +# define SIGKILL 9 +#else +# include <sys/socket.h> +# include <netinet/in.h> +# include <signal.h> +# include <sys/stat.h> +# include <sys/wait.h> +#endif + +#include "mongo/client/clientOnly-private.h" +#include "mongo/client/dbclientinterface.h" +#include "mongo/scripting/engine.h" +#include "mongo/shell/shell_utils.h" + +namespace mongo { + + extern bool dbexitCalled; + +#ifdef _WIN32 + inline int close(int fd) { return _close(fd); } + inline int read(int fd, void* buf, size_t size) { return _read(fd, buf, size); } + inline int pipe(int fds[2]) { return _pipe(fds, 4096, _O_TEXT | _O_NOINHERIT); } +#endif + + /** + * These utilities are thread safe but do not provide mutually exclusive access to resources + * identified by the caller. Resources identified by a pid or port should not be accessed + * by different threads. Dependent filesystem paths should not be accessed by different + * threads. + */ + namespace shell_utils { + + ProgramOutputMultiplexer programOutputLogger; + + bool ProgramRegistry::isPortRegistered( int port ) const { + boost::recursive_mutex::scoped_lock lk( _mutex ); + return _ports.count( port ) == 1; + } + + pid_t ProgramRegistry::pidForPort( int port ) const { + boost::recursive_mutex::scoped_lock lk( _mutex ); + verify( isPortRegistered( port ) ); + return _ports.find( port )->second.first; + } + + void ProgramRegistry::registerPort( int port, pid_t pid, int output ) { + boost::recursive_mutex::scoped_lock lk( _mutex ); + verify( !isPortRegistered( port ) ); + _ports.insert( make_pair( port, make_pair( pid, output ) ) ); + } + + void ProgramRegistry::deletePort( int port ) { + boost::recursive_mutex::scoped_lock lk( _mutex ); + if ( !isPortRegistered( port ) ) { + return; + } + close( _ports.find( port )->second.second ); + _ports.erase( port ); + } + + void ProgramRegistry::getRegisteredPorts( vector<int> &ports ) { + boost::recursive_mutex::scoped_lock lk( _mutex ); + for( map<int,pair<pid_t,int> >::const_iterator i = _ports.begin(); i != _ports.end(); + ++i ) { + ports.push_back( i->first ); + } + } + + bool ProgramRegistry::isPidRegistered( pid_t pid ) const { + boost::recursive_mutex::scoped_lock lk( _mutex ); + return _pids.count( pid ) == 1; + } + + void ProgramRegistry::registerPid( pid_t pid, int output ) { + boost::recursive_mutex::scoped_lock lk( _mutex ); + verify( !isPidRegistered( pid ) ); + _pids.insert( make_pair( pid, output ) ); + } + + void ProgramRegistry::deletePid( pid_t pid ) { + boost::recursive_mutex::scoped_lock lk( _mutex ); + if ( !isPidRegistered( pid ) ) { + return; + } + close( _pids.find( pid )->second ); + _pids.erase( pid ); + } + + void ProgramRegistry::getRegisteredPids( vector<pid_t> &pids ) { + boost::recursive_mutex::scoped_lock lk( _mutex ); + for( map<pid_t,int>::const_iterator i = _pids.begin(); i != _pids.end(); ++i ) { + pids.push_back( i->first ); + } + } + + ProgramRegistry ®istry = *( new ProgramRegistry() ); + + void goingAwaySoon() { + mongo::mutex::scoped_lock lk( mongoProgramOutputMutex ); + mongo::dbexitCalled = true; + } + + void ProgramOutputMultiplexer::appendLine( int port, int pid, const char *line ) { + mongo::mutex::scoped_lock lk( mongoProgramOutputMutex ); + if( mongo::dbexitCalled ) throw "program is terminating"; + stringstream buf; + if ( port > 0 ) + buf << " m" << port << "| " << line; + else + buf << "sh" << pid << "| " << line; + printf( "%s\n", buf.str().c_str() ); // cout << buf.str() << endl; + _buffer << buf.str() << endl; + } + + string ProgramOutputMultiplexer::str() const { + mongo::mutex::scoped_lock lk( mongoProgramOutputMutex ); + string ret = _buffer.str(); + size_t len = ret.length(); + if ( len > 100000 ) { + ret = ret.substr( len - 100000, 100000 ); + } + return ret; + } + + void ProgramOutputMultiplexer::clear() { + mongo::mutex::scoped_lock lk( mongoProgramOutputMutex ); + _buffer.str( "" ); + } + + ProgramRunner::ProgramRunner( const BSONObj &args ) { + verify( !args.isEmpty() ); + + string program( args.firstElement().valuestrsafe() ); + verify( !program.empty() ); + boost::filesystem::path programPath = findProgram(program); + + string prefix( "mongod-" ); + bool isMongodProgram = + string("mongod") == program || + program.compare( 0, prefix.size(), prefix ) == 0; + + prefix = "mongos-"; + bool isMongosProgram = + string("mongos") == program || + program.compare( 0, prefix.size(), prefix ) == 0; + +#if 0 + if (isMongosProgram == "mongos") { + _argv.push_back("valgrind"); + _argv.push_back("--log-file=/tmp/mongos-%p.valgrind"); + _argv.push_back("--leak-check=yes"); + _argv.push_back("--suppressions=valgrind.suppressions"); + //_argv.push_back("--error-exitcode=1"); + _argv.push_back("--"); + } +#endif + + _argv.push_back( programPath.native_file_string() ); + + _port = -1; + + BSONObjIterator j( args ); + j.next(); // skip program name (handled above) + while(j.more()) { + BSONElement e = j.next(); + string str; + if ( e.isNumber() ) { + stringstream ss; + ss << e.number(); + str = ss.str(); + } + else { + verify( e.type() == mongo::String ); + str = e.valuestr(); + } + if ( str == "--port" ) + _port = -2; + else if ( _port == -2 ) + _port = strtol( str.c_str(), 0, 10 ); + _argv.push_back(str); + } + + if ( ! isMongodProgram && ! isMongosProgram && program != "mongobridge" ) + _port = 0; + else { + if ( _port <= 0 ) + log() << "error: a port number is expected when running " << program << " from the shell" << endl; + verify( _port > 0 ); + } + if ( _port > 0 ) { + bool haveDbForPort = registry.isPortRegistered( _port ); + if ( haveDbForPort ) { + log() << "already have db for port: " << _port << endl; + verify( !haveDbForPort ); + } + } + } + + void ProgramRunner::start() { + int pipeEnds[ 2 ]; + verify( pipe( pipeEnds ) != -1 ); + + fflush( 0 ); + launchProcess(pipeEnds[1]); //sets _pid + + { + stringstream ss; + ss << "shell: started program"; + for (unsigned i=0; i < _argv.size(); i++) + ss << " " << _argv[i]; + log() << ss.str() << endl; + } + + if ( _port > 0 ) + registry.registerPort( _port, _pid, pipeEnds[ 1 ] ); + else + registry.registerPid( _pid, pipeEnds[ 1 ] ); + _pipe = pipeEnds[ 0 ]; + } + + void ProgramRunner::operator()() { + try { + // This assumes there aren't any 0's in the mongo program output. + // Hope that's ok. + const unsigned bufSize = 128 * 1024; + char buf[ bufSize ]; + char temp[ bufSize ]; + char *start = buf; + while( 1 ) { + int lenToRead = ( bufSize - 1 ) - ( start - buf ); + if ( lenToRead <= 0 ) { + log() << "error: lenToRead: " << lenToRead << endl; + log() << "first 300: " << string(buf,0,300) << endl; + } + verify( lenToRead > 0 ); + int ret = read( _pipe, (void *)start, lenToRead ); + if( mongo::dbexitCalled ) + break; + verify( ret != -1 ); + start[ ret ] = '\0'; + if ( strlen( start ) != unsigned( ret ) ) + programOutputLogger.appendLine( _port, _pid, "WARNING: mongod wrote null bytes to output" ); + char *last = buf; + for( char *i = strchr( buf, '\n' ); i; last = i + 1, i = strchr( last, '\n' ) ) { + *i = '\0'; + programOutputLogger.appendLine( _port, _pid, last ); + } + if ( ret == 0 ) { + if ( *last ) + programOutputLogger.appendLine( _port, _pid, last ); + close( _pipe ); + break; + } + if ( last != buf ) { + strcpy( temp, last ); + strcpy( buf, temp ); + } + else { + verify( strlen( buf ) < bufSize ); + } + start = buf + strlen( buf ); + } + } + catch(...) { + } + } + + boost::filesystem::path ProgramRunner::findProgram( const string &prog ) { + boost::filesystem::path p = prog; +#ifdef _WIN32 + p = change_extension(p, ".exe"); +#endif + + if( boost::filesystem::exists(p) ) { +#ifndef _WIN32 + p = boost::filesystem::initial_path() / p; +#endif + return p; + } + + { + boost::filesystem::path t = boost::filesystem::current_path() / p; + if( boost::filesystem::exists(t) ) return t; + } + { + boost::filesystem::path t = boost::filesystem::initial_path() / p; + if( boost::filesystem::exists(t) ) return t; + } + return p; // not found; might find via system path + } + + void ProgramRunner::launchProcess( int child_stdout ) { +#ifdef _WIN32 + stringstream ss; + for( unsigned i=0; i < _argv.size(); i++ ) { + if (i) ss << ' '; + if (_argv[i].find(' ') == string::npos) + ss << _argv[i]; + else { + ss << '"'; + // escape all embedded quotes + for (size_t j=0; j<_argv[i].size(); ++j) { + if (_argv[i][j]=='"') ss << '"'; + ss << _argv[i][j]; + } + ss << '"'; + } + } + + string args = ss.str(); + + boost::scoped_array<TCHAR> args_tchar (new TCHAR[args.size() + 1]); + size_t i; + for(i=0; i < args.size(); i++) + args_tchar[i] = args[i]; + args_tchar[i] = 0; + + HANDLE h = (HANDLE)_get_osfhandle(child_stdout); + verify(h != INVALID_HANDLE_VALUE); + verify(SetHandleInformation(h, HANDLE_FLAG_INHERIT, 1)); + + STARTUPINFO si; + ZeroMemory(&si, sizeof(si)); + si.cb = sizeof(si); + si.hStdError = h; + si.hStdOutput = h; + si.dwFlags |= STARTF_USESTDHANDLES; + + PROCESS_INFORMATION pi; + ZeroMemory(&pi, sizeof(pi)); + + bool success = CreateProcess( NULL, args_tchar.get(), NULL, NULL, true, 0, NULL, NULL, &si, &pi) != 0; + if (!success) { + LPSTR lpMsgBuf=0; + DWORD dw = GetLastError(); + FormatMessageA( + FORMAT_MESSAGE_ALLOCATE_BUFFER | + FORMAT_MESSAGE_FROM_SYSTEM | + FORMAT_MESSAGE_IGNORE_INSERTS, + NULL, + dw, + MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), + (LPSTR)&lpMsgBuf, + 0, NULL ); + stringstream ss; + ss << "couldn't start process " << _argv[0] << "; " << lpMsgBuf; + uassert(14042, ss.str(), success); + LocalFree(lpMsgBuf); + } + + CloseHandle(pi.hThread); + + _pid = pi.dwProcessId; + registry._handles.insert( make_pair( _pid, pi.hProcess ) ); + +#else + + scoped_array<const char *> argvStorage( new const char* [_argv.size()+1] ); + const char** argv = argvStorage.get(); + for (unsigned i=0; i < _argv.size(); i++) { + argv[i] = _argv[i].c_str(); + } + argv[_argv.size()] = 0; + + scoped_array<const char *> envStorage( new const char* [2] ); + const char** env = envStorage.get(); + env[0] = NULL; + env[1] = NULL; + + bool isMongos = ( _argv[0].find( "mongos" ) != string::npos ); + + _pid = fork(); + // Async signal unsafe functions should not be called in the child process. + + if ( _pid == -1 ) { + verify( _pid != -1 ); + } + else if ( _pid == 0 ) { + // DON'T ASSERT IN THIS BLOCK - very bad things will happen + + if ( dup2( child_stdout, STDOUT_FILENO ) == -1 || + dup2( child_stdout, STDERR_FILENO ) == -1 ) { + + // Async signal unsafe code reporting a terminal error condition. + cout << "Unable to dup2 child output: " << errnoWithDescription() << endl; + ::_Exit(-1); //do not pass go, do not call atexit handlers + } + + // Heap-check for mongos only. 'argv[0]' must be in the path format. + if ( isMongos ) { +#if defined(HEAP_CHECKING) + env[0] = "HEAPCHECK=normal"; + env[1] = NULL; + + // NOTE execve is async signal safe, but it is not clear that execvpe is async + // signal safe. + execvpe( argv[ 0 ], const_cast<char**>(argv) , const_cast<char**>(env) ); +#endif // HEAP_CHECKING + } + + // NOTE execve is async signal safe, but it is not clear that execvp is async + // signal safe. + execvp( argv[ 0 ], const_cast<char**>(argv) ); + + // Async signal unsafe code reporting a terminal error condition. + cout << "Unable to start program " << argv[0] << ' ' << errnoWithDescription() << endl; + ::_Exit(-1); + } + +#endif + } + + //returns true if process exited + bool wait_for_pid(pid_t pid, bool block=true, int* exit_code=NULL) { +#ifdef _WIN32 + verify(registry._handles.count(pid)); + HANDLE h = registry._handles[pid]; + + if (block) + WaitForSingleObject(h, INFINITE); + + DWORD tmp; + if(GetExitCodeProcess(h, &tmp)) { + if ( tmp == STILL_ACTIVE ) { + return false; + } + CloseHandle(h); + registry._handles.erase(pid); + if (exit_code) + *exit_code = tmp; + return true; + } + else { + return false; + } +#else + int tmp; + bool ret = (pid == waitpid(pid, &tmp, (block ? 0 : WNOHANG))); + if (exit_code) + *exit_code = WEXITSTATUS(tmp); + return ret; + +#endif + } + + BSONObj RawMongoProgramOutput( const BSONObj &args, void* data ) { + return BSON( "" << programOutputLogger.str() ); + } + + BSONObj ClearRawMongoProgramOutput( const BSONObj &args, void* data ) { + programOutputLogger.clear(); + return undefinedReturn; + } + + BSONObj WaitProgram( const BSONObj& a, void* data ) { + int pid = singleArg( a ).numberInt(); + BSONObj x = BSON( "" << wait_for_pid( pid ) ); + registry.deletePid( pid ); + return x; + } + + BSONObj StartMongoProgram( const BSONObj &a, void* data ) { + _nokillop = true; + ProgramRunner r( a ); + r.start(); + boost::thread t( r ); + return BSON( string( "" ) << int( r.pid() ) ); + } + + BSONObj RunMongoProgram( const BSONObj &a, void* data ) { + ProgramRunner r( a ); + r.start(); + boost::thread t( r ); + int exit_code; + wait_for_pid( r.pid(), true, &exit_code ); + if ( r.port() > 0 ) { + registry.deletePort( r.port() ); + } + else { + registry.deletePid( r.pid() ); + } + return BSON( string( "" ) << exit_code ); + } + + BSONObj RunProgram(const BSONObj &a, void* data) { + ProgramRunner r( a ); + r.start(); + boost::thread t( r ); + int exit_code; + wait_for_pid(r.pid(), true, &exit_code); + registry.deletePid( r.pid() ); + return BSON( string( "" ) << exit_code ); + } + + BSONObj ResetDbpath( const BSONObj &a, void* data ) { + verify( a.nFields() == 1 ); + string path = a.firstElement().valuestrsafe(); + verify( !path.empty() ); + if ( boost::filesystem::exists( path ) ) + boost::filesystem::remove_all( path ); + boost::filesystem::create_directory( path ); + return undefinedReturn; + } + + void copyDir( const boost::filesystem::path &from, const boost::filesystem::path &to ) { + boost::filesystem::directory_iterator end; + boost::filesystem::directory_iterator i( from ); + while( i != end ) { + boost::filesystem::path p = *i; + if ( p.leaf() != "mongod.lock" ) { + if ( boost::filesystem::is_directory( p ) ) { + boost::filesystem::path newDir = to / p.leaf(); + boost::filesystem::create_directory( newDir ); + copyDir( p, newDir ); + } + else { + boost::filesystem::copy_file( p, to / p.leaf() ); + } + } + ++i; + } + } + + // NOTE target dbpath will be cleared first + BSONObj CopyDbpath( const BSONObj &a, void* data ) { + verify( a.nFields() == 2 ); + BSONObjIterator i( a ); + string from = i.next().str(); + string to = i.next().str(); + verify( !from.empty() ); + verify( !to.empty() ); + if ( boost::filesystem::exists( to ) ) + boost::filesystem::remove_all( to ); + boost::filesystem::create_directory( to ); + copyDir( from, to ); + return undefinedReturn; + } + + inline void kill_wrapper( pid_t pid, int sig, int port, const BSONObj& opt ) { +#ifdef _WIN32 + if (sig == SIGKILL || port == 0) { + verify( registry._handles.count(pid) ); + TerminateProcess(registry._handles[pid], 1); // returns failure for "zombie" processes. + } + else { + DBClientConnection conn; + try { + conn.connect("127.0.0.1:" + BSONObjBuilder::numStr(port)); + + BSONElement authObj = opt["auth"]; + + if ( !authObj.eoo() ){ + string errMsg; + conn.auth( "admin", authObj["user"].String(), + authObj["pwd"].String(), errMsg ); + + if ( !errMsg.empty() ) { + cout << "Failed to authenticate before shutdown: " + << errMsg << endl; + } + } + + BSONObj info; + BSONObjBuilder b; + b.append( "shutdown", 1 ); + b.append( "force", 1 ); + conn.runCommand( "admin", b.done(), info ); + } + catch (...) { + //Do nothing. This command never returns data to the client and the driver doesn't like that. + } + } +#else + int x = kill( pid, sig ); + if ( x ) { + if ( errno == ESRCH ) { + } + else { + log() << "killFailed: " << errnoWithDescription() << endl; + verify( x == 0 ); + } + } + +#endif + } + + int killDb( int port, pid_t _pid, int signal, const BSONObj& opt ) { + pid_t pid; + int exitCode = 0; + if ( port > 0 ) { + if( !registry.isPortRegistered( port ) ) { + log() << "No db started on port: " << port << endl; + return 0; + } + pid = registry.pidForPort( port ); + } + else { + pid = _pid; + } + + kill_wrapper( pid, signal, port, opt ); + + int i = 0; + for( ; i < 130; ++i ) { + if ( i == 60 ) { + char now[64]; + time_t_to_String(time(0), now); + now[ 20 ] = 0; + log() << now << " process on port " << port << ", with pid " << pid << " not terminated, sending sigkill" << endl; + kill_wrapper( pid, SIGKILL, port, opt ); + } + if(wait_for_pid(pid, false, &exitCode)) + break; + sleepmillis( 1000 ); + } + if ( i == 130 ) { + char now[64]; + time_t_to_String(time(0), now); + now[ 20 ] = 0; + log() << now << " failed to terminate process on port " << port << ", with pid " << pid << endl; + verify( "Failed to terminate process" == 0 ); + } + + if ( port > 0 ) { + registry.deletePort( port ); + } + else { + registry.deletePid( pid ); + } + // FIXME I think the intention here is to do an extra sleep only when SIGKILL is sent to the child process. + // We may want to change the 4 below to 29, since values of i greater than that indicate we sent a SIGKILL. + if ( i > 4 || signal == SIGKILL ) { + sleepmillis( 4000 ); // allow operating system to reclaim resources + } + + return exitCode; + } + + int killDb( int port, pid_t _pid, int signal ) { + BSONObj dummyOpt; + return killDb( port, _pid, signal, dummyOpt ); + } + + int getSignal( const BSONObj &a ) { + int ret = SIGTERM; + if ( a.nFields() >= 2 ) { + BSONObjIterator i( a ); + i.next(); + BSONElement e = i.next(); + verify( e.isNumber() ); + ret = int( e.number() ); + } + return ret; + } + + BSONObj getStopMongodOpts( const BSONObj &a ) { + if ( a.nFields() == 3 ) { + BSONObjIterator i( a ); + i.next(); + i.next(); + BSONElement e = i.next(); + + if ( e.isABSONObj() ){ + return e.embeddedObject(); + } + } + + return BSONObj(); + } + + /** stopMongoProgram(port[, signal]) */ + BSONObj StopMongoProgram( const BSONObj &a, void* data ) { + verify( a.nFields() >= 1 || a.nFields() <= 3 ); + uassert( 15853 , "stopMongo needs a number" , a.firstElement().isNumber() ); + int port = int( a.firstElement().number() ); + int code = killDb( port, 0, getSignal( a ), getStopMongodOpts( a )); + log() << "shell: stopped mongo program on port " << port << endl; + return BSON( "" << (double)code ); + } + + BSONObj StopMongoProgramByPid( const BSONObj &a, void* data ) { + verify( a.nFields() == 1 || a.nFields() == 2 ); + uassert( 15852 , "stopMongoByPid needs a number" , a.firstElement().isNumber() ); + int pid = int( a.firstElement().number() ); + int code = killDb( 0, pid, getSignal( a ) ); + log() << "shell: stopped mongo program on pid " << pid << endl; + return BSON( "" << (double)code ); + } + + void KillMongoProgramInstances() { + vector< int > ports; + registry.getRegisteredPorts( ports ); + for( vector< int >::iterator i = ports.begin(); i != ports.end(); ++i ) + killDb( *i, 0, SIGTERM ); + vector< pid_t > pids; + registry.getRegisteredPids( pids ); + for( vector< pid_t >::iterator i = pids.begin(); i != pids.end(); ++i ) + killDb( 0, *i, SIGTERM ); + } + + MongoProgramScope::~MongoProgramScope() { + DESTRUCTOR_GUARD( + KillMongoProgramInstances(); + ClearRawMongoProgramOutput( BSONObj(), 0 ); + ) + } + + void installShellUtilsLauncher( Scope& scope ) { + scope.injectNative( "_startMongoProgram", StartMongoProgram ); + scope.injectNative( "runProgram", RunProgram ); + scope.injectNative( "run", RunProgram ); + scope.injectNative( "_runMongoProgram", RunMongoProgram ); + scope.injectNative( "stopMongod", StopMongoProgram ); + scope.injectNative( "stopMongoProgram", StopMongoProgram ); + scope.injectNative( "stopMongoProgramByPid", StopMongoProgramByPid ); + scope.injectNative( "rawMongoProgramOutput", RawMongoProgramOutput ); + scope.injectNative( "clearRawMongoProgramOutput", ClearRawMongoProgramOutput ); + scope.injectNative( "waitProgram" , WaitProgram ); + scope.injectNative( "resetDbpath", ResetDbpath ); + scope.injectNative( "copyDbpath", CopyDbpath ); + } + } +} |
