Commit 6ff9769f authored by Kenton Varda's avatar Kenton Varda

Initial implementation of promises.

parent 7739b539
// Copyright (c) 2013, Kenton Varda <temporal@gmail.com>
// All rights reserved.
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are met:
//
// 1. Redistributions of source code must retain the above copyright notice, this
// list of conditions and the following disclaimer.
// 2. 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.
//
// 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.
#include "async.h"
#include "mutex.h"
#include "debug.h"
#include "thread.h"
#include <gtest/gtest.h>
namespace kj {
namespace {
TEST(Async, EvalVoid) {
SimpleEventLoop loop;
bool done = false;
Promise<void> promise = loop.evalLater([&]() { done = true; });
EXPECT_FALSE(done);
loop.wait(kj::mv(promise));
EXPECT_TRUE(done);
}
TEST(Async, EvalInt) {
SimpleEventLoop loop;
bool done = false;
Promise<int> promise = loop.evalLater([&]() { done = true; return 123; });
EXPECT_FALSE(done);
EXPECT_EQ(123, loop.wait(kj::mv(promise)));
EXPECT_TRUE(done);
}
TEST(Async, There) {
SimpleEventLoop loop;
Promise<int> a = 123;
bool done = false;
Promise<int> promise = loop.there(kj::mv(a), [&](int ai) { done = true; return ai + 321; });
EXPECT_FALSE(done);
EXPECT_EQ(444, loop.wait(kj::mv(promise)));
EXPECT_TRUE(done);
}
TEST(Async, ThereVoid) {
SimpleEventLoop loop;
Promise<int> a = 123;
int value = 0;
Promise<void> promise = loop.there(kj::mv(a), [&](int ai) { value = ai; });
EXPECT_EQ(0, value);
loop.wait(kj::mv(promise));
EXPECT_EQ(123, value);
}
TEST(Async, Exception) {
SimpleEventLoop loop;
Promise<int> promise = loop.evalLater([&]() -> int { KJ_FAIL_ASSERT("foo") { return 123; } });
EXPECT_TRUE(kj::runCatchingExceptions([&]() {
// wait() only returns when compiling with -fno-exceptions.
EXPECT_EQ(123, loop.wait(kj::mv(promise)));
}) != nullptr);
}
TEST(Async, HandleException) {
SimpleEventLoop loop;
Promise<int> promise = loop.evalLater([&]() -> int { KJ_FAIL_ASSERT("foo") { return 123; } });
int line = __LINE__ - 1;
promise = loop.there(kj::mv(promise),
[](int i) { return i + 1; },
[&](Exception&& e) { EXPECT_EQ(line, e.getLine()); return 345; });
EXPECT_EQ(345, loop.wait(kj::mv(promise)));
}
TEST(Async, PropagateException) {
SimpleEventLoop loop;
Promise<int> promise = loop.evalLater([&]() -> int { KJ_FAIL_ASSERT("foo") { return 123; } });
int line = __LINE__ - 1;
promise = loop.there(kj::mv(promise),
[](int i) { return i + 1; });
promise = loop.there(kj::mv(promise),
[](int i) { return i + 2; },
[&](Exception&& e) { EXPECT_EQ(line, e.getLine()); return 345; });
EXPECT_EQ(345, loop.wait(kj::mv(promise)));
}
TEST(Async, PropagateExceptionTypeChange) {
SimpleEventLoop loop;
Promise<int> promise = loop.evalLater([&]() -> int { KJ_FAIL_ASSERT("foo") { return 123; } });
int line = __LINE__ - 1;
Promise<StringPtr> promise2 = loop.there(kj::mv(promise),
[](int i) -> StringPtr { return "foo"; });
promise2 = loop.there(kj::mv(promise2),
[](StringPtr s) -> StringPtr { return "bar"; },
[&](Exception&& e) -> StringPtr { EXPECT_EQ(line, e.getLine()); return "baz"; });
EXPECT_EQ("baz", loop.wait(kj::mv(promise2)));
}
TEST(Async, Then) {
SimpleEventLoop loop;
Promise<int> promise = nullptr;
bool outerDone = false;
bool innerDone = false;
loop.wait(loop.evalLater([&]() {
outerDone = true;
promise = Promise<int>(123).then([&](int i) {
EXPECT_EQ(&loop, &EventLoop::current());
innerDone = true;
return i + 321;
});
}));
EXPECT_TRUE(outerDone);
EXPECT_FALSE(innerDone);
EXPECT_EQ(444, loop.wait(kj::mv(promise)));
EXPECT_TRUE(innerDone);
}
TEST(Async, Chain) {
SimpleEventLoop loop;
Promise<int> promise = loop.evalLater([&]() -> int { return 123; });
Promise<int> promise2 = loop.evalLater([&]() -> int { return 321; });
auto promise3 = loop.there(kj::mv(promise),
[&](int i) {
EXPECT_EQ(&loop, &EventLoop::current());
return promise2.then([&loop,i](int j) {
EXPECT_EQ(&loop, &EventLoop::current());
return i + j;
});
});
EXPECT_EQ(444, loop.wait(kj::mv(promise3)));
}
TEST(Async, SeparateFulfiller) {
SimpleEventLoop loop;
auto pair = newPromiseAndFulfiller<int>();
EXPECT_TRUE(pair.fulfiller->isWaiting());
pair.fulfiller->fulfill(123);
EXPECT_FALSE(pair.fulfiller->isWaiting());
EXPECT_EQ(123, loop.wait(kj::mv(pair.promise)));
}
TEST(Async, SeparateFulfillerVoid) {
SimpleEventLoop loop;
auto pair = newPromiseAndFulfiller<void>();
EXPECT_TRUE(pair.fulfiller->isWaiting());
pair.fulfiller->fulfill();
EXPECT_FALSE(pair.fulfiller->isWaiting());
loop.wait(kj::mv(pair.promise));
}
TEST(Async, SeparateFulfillerCanceled) {
auto pair = newPromiseAndFulfiller<void>();
EXPECT_TRUE(pair.fulfiller->isWaiting());
pair.promise.absolve();
EXPECT_FALSE(pair.fulfiller->isWaiting());
}
#if KJ_NO_EXCEPTIONS
#undef EXPECT_ANY_THROW
#define EXPECT_ANY_THROW(code) EXPECT_DEATH(code, ".")
#endif
TEST(Async, Threads) {
EXPECT_ANY_THROW(EventLoop::current());
SimpleEventLoop loop1;
SimpleEventLoop loop2;
auto exitThread = newPromiseAndFulfiller<void>();
Promise<int> promise = loop1.evalLater([]() { return 123; });
promise = loop2.there(kj::mv(promise), [](int ai) { return ai + 321; });
for (uint i = 0; i < 100; i++) {
promise = loop1.there(kj::mv(promise), [&](int ai) {
EXPECT_EQ(&loop1, &EventLoop::current());
return ai + 1;
});
promise = loop2.there(kj::mv(promise), [&](int ai) {
EXPECT_EQ(&loop2, &EventLoop::current());
return ai + 1000;
});
}
Thread thread([&]() {
EXPECT_ANY_THROW(EventLoop::current());
loop2.wait(kj::mv(exitThread.promise));
EXPECT_ANY_THROW(EventLoop::current());
});
// Make sure the thread will exit.
KJ_DEFER(exitThread.fulfiller->fulfill());
EXPECT_EQ(100544, loop1.wait(kj::mv(promise)));
EXPECT_ANY_THROW(EventLoop::current());
}
TEST(Async, Yield) {
SimpleEventLoop loop1;
SimpleEventLoop loop2;
int counter = 0;
Promise<void> promises[6] = {nullptr, nullptr, nullptr, nullptr, nullptr, nullptr};
promises[0] = loop2.evalLater([&]() {
EXPECT_EQ(3, counter++);
});
promises[1] = loop2.evalLater([&]() {
EXPECT_EQ(0, counter++);
promises[2] = loop2.evalLater([&]() {
EXPECT_EQ(2, counter++);
});
promises[3] = loop2.there(loop2.yield(), [&]() {
EXPECT_EQ(4, counter++);
});
promises[4] = loop2.evalLater([&]() {
EXPECT_EQ(1, counter++);
});
promises[5] = loop2.there(loop2.yield(), [&]() {
EXPECT_EQ(5, counter++);
});
});
auto exitThread = newPromiseAndFulfiller<void>();
Thread thread([&]() {
EXPECT_ANY_THROW(EventLoop::current());
loop2.wait(kj::mv(exitThread.promise));
EXPECT_ANY_THROW(EventLoop::current());
});
// Make sure the thread will exit.
KJ_DEFER(exitThread.fulfiller->fulfill());
for (auto i: indices(promises)) {
loop1.wait(kj::mv(promises[i]));
}
EXPECT_EQ(6, counter);
}
} // namespace
} // namespace kj
// Copyright (c) 2013, Kenton Varda <temporal@gmail.com>
// All rights reserved.
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are met:
//
// 1. Redistributions of source code must retain the above copyright notice, this
// list of conditions and the following disclaimer.
// 2. 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.
//
// 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.
#include "async.h"
#include "debug.h"
#include <exception>
// TODO(now): Encapsulate in mutex.h, with portable implementation.
#include <unistd.h>
#include <sys/syscall.h>
#include <linux/futex.h>
namespace kj {
namespace {
#if __GNUC__ < 4 || (__GNUC__ == 4 && __GNUC_MINOR__ < 8)
#define thread_local __thread
#endif
thread_local EventLoop* threadLocalEventLoop = nullptr;
class YieldPromiseNode final: public _::PromiseNode<_::Void>, public EventLoop::Event {
// A PromiseNode used to implement EventLoop::yield().
public:
YieldPromiseNode(const EventLoop& loop): Event(loop) {}
~YieldPromiseNode() {
disarm();
}
bool onReady(EventLoop::Event& event) noexcept override {
if (onReadyEvent == _kJ_ALREADY_READY) {
return true;
} else {
onReadyEvent = &event;
return false;
}
}
_::ExceptionOr<_::Void> get() noexcept override {
return _::Void();
}
Maybe<const EventLoop&> getSafeEventLoop() noexcept override {
return getEventLoop();
}
void fire() override {
if (onReadyEvent != nullptr) {
onReadyEvent->arm();
}
}
private:
EventLoop::Event* onReadyEvent = nullptr;
};
} // namespace
EventLoop& EventLoop::current() {
EventLoop* result = threadLocalEventLoop;
KJ_REQUIRE(result != nullptr, "No event loop is running on this thread.");
return *result;
}
void EventLoop::EventListHead::fire() {
KJ_FAIL_ASSERT("Fired event list head.");
}
EventLoop::EventLoop(): queue(*this) {
queue.next = &queue;
queue.prev = &queue;
}
void EventLoop::loopWhile(bool& keepGoing) {
EventLoop* oldEventLoop = threadLocalEventLoop;
threadLocalEventLoop = this;
KJ_DEFER(threadLocalEventLoop = oldEventLoop);
while (keepGoing) {
queue.mutex.lock(_::Mutex::EXCLUSIVE);
// Get the first event in the queue.
Event* event = queue.next;
if (event == &queue) {
// No events in the queue.
prepareToSleep();
queue.mutex.unlock(_::Mutex::EXCLUSIVE);
sleep();
continue;
}
// Remove it from the queue.
queue.next = event->next;
event->next->prev = &queue;
event->next = nullptr;
event->prev = nullptr;
// Lock it before we unlock the queue mutex.
event->mutex.lock(_::Mutex::EXCLUSIVE);
// Now we can unlock the queue.
queue.mutex.unlock(_::Mutex::EXCLUSIVE);
// Fire the event, making sure we unlock the mutex afterwards.
KJ_DEFER(event->mutex.unlock(_::Mutex::EXCLUSIVE));
event->fire();
}
}
Promise<void> EventLoop::yield() {
auto node = heap<YieldPromiseNode>(*this);
// Insert the node at the *end* of the queue.
queue.mutex.lock(_::Mutex::EXCLUSIVE);
node->prev = queue.prev;
node->next = &queue;
queue.prev->next = node;
queue.prev = node;
if (node->prev == &queue) {
// Queue was empty previously. Make sure to wake it up if it is sleeping.
wake();
}
queue.mutex.unlock(_::Mutex::EXCLUSIVE);
return Promise<void>(kj::mv(node));
}
EventLoop::Event::~Event() noexcept(false) {
if (this != &loop.queue) {
KJ_ASSERT(next == nullptr || std::uncaught_exception(), "Event destroyed while armed.");
}
}
void EventLoop::Event::arm() {
loop.queue.mutex.lock(_::Mutex::EXCLUSIVE);
KJ_DEFER(loop.queue.mutex.unlock(_::Mutex::EXCLUSIVE));
if (next == nullptr) {
// Insert the event into the queue. We put it at the front rather than the back so that related
// events are executed together and so that increasing the granularity of events does not cause
// your code to "lose priority" compared to simultaneously-running code with less granularity.
next = loop.queue.next;
prev = next->prev;
next->prev = this;
prev->next = this;
if (next == &loop.queue) {
// Queue was empty previously. Make sure to wake it up if it is sleeping.
loop.wake();
}
}
}
void EventLoop::Event::disarm() {
if (next != nullptr) {
loop.queue.mutex.lock(_::Mutex::EXCLUSIVE);
next->prev = prev;
prev->next = next;
next = nullptr;
prev = nullptr;
loop.queue.mutex.unlock(_::Mutex::EXCLUSIVE);
}
// Ensure that if fire() is currently running, it completes before disarm() returns.
mutex.lock(_::Mutex::EXCLUSIVE);
mutex.unlock(_::Mutex::EXCLUSIVE);
}
// =======================================================================================
SimpleEventLoop::SimpleEventLoop() {}
SimpleEventLoop::~SimpleEventLoop() noexcept(false) {}
void SimpleEventLoop::prepareToSleep() noexcept {
__atomic_store_n(&preparedToSleep, 1, __ATOMIC_RELAXED);
}
void SimpleEventLoop::sleep() {
while (__atomic_load_n(&preparedToSleep, __ATOMIC_RELAXED) == 1) {
syscall(SYS_futex, &preparedToSleep, FUTEX_WAIT_PRIVATE, 1, NULL, NULL, 0);
}
}
void SimpleEventLoop::wake() const {
if (__atomic_exchange_n(&preparedToSleep, 0, __ATOMIC_RELAXED) != 0) {
// preparedToSleep was 1 before the exchange, so a sleep must be in progress in another thread.
syscall(SYS_futex, &preparedToSleep, FUTEX_WAKE_PRIVATE, 1, NULL, NULL, 0);
}
}
} // namespace kj
This diff is collapsed.
......@@ -39,6 +39,17 @@ void inlineRequireFailure(const char* file, int line, const char* expectation,
}
}
void inlineAssertFailure(const char* file, int line, const char* expectation,
const char* macroArgs, const char* message) {
if (message == nullptr) {
Debug::Fault f(file, line, Exception::Nature::LOCAL_BUG, 0, expectation, macroArgs);
f.fatal();
} else {
Debug::Fault f(file, line, Exception::Nature::LOCAL_BUG, 0, expectation, macroArgs, message);
f.fatal();
}
}
void unreachable() {
KJ_FAIL_ASSERT("Supposendly-unreachable branch executed.");
......
......@@ -132,6 +132,8 @@ the compiler flag -DNDEBUG."
#define KJ_NORETURN __attribute__((noreturn))
#define KJ_UNUSED __attribute__((unused))
#define KJ_WARN_UNUSED_RESULT __attribute__((warn_unused_result))
#if __clang__
#define KJ_UNUSED_MEMBER __attribute__((unused))
// Inhibits "unused" warning for member variables. Only Clang produces such a warning, while GCC
......@@ -145,6 +147,9 @@ namespace _ { // private
void inlineRequireFailure(
const char* file, int line, const char* expectation, const char* macroArgs,
const char* message = nullptr) KJ_NORETURN;
void inlineAssertFailure(
const char* file, int line, const char* expectation, const char* macroArgs,
const char* message = nullptr) KJ_NORETURN;
void unreachable() KJ_NORETURN;
......@@ -154,12 +159,19 @@ void unreachable() KJ_NORETURN;
#define KJ_IREQUIRE(condition, ...) \
if (KJ_LIKELY(condition)); else ::kj::_::inlineRequireFailure( \
__FILE__, __LINE__, #condition, #__VA_ARGS__, ##__VA_ARGS__)
// Version of KJ_REQUIRE() which is safe to use in headers that are #included by users. Used to
// Version of KJ_DREQUIRE() which is safe to use in headers that are #included by users. Used to
// check preconditions inside inline methods. KJ_IREQUIRE is particularly useful in that
// it will be enabled depending on whether the application is compiled in debug mode rather than
// whether libkj is.
#define KJ_IASSERT(condition, ...) \
if (KJ_LIKELY(condition)); else ::kj::_::inlineAssertFailure( \
__FILE__, __LINE__, #condition, #__VA_ARGS__, ##__VA_ARGS__)
// Version of KJ_DASSERT() which is safe to use in headers that are #included by users. Used to
// check state inside inline and templated methods.
#else
#define KJ_IREQUIRE(condition, ...)
#define KJ_IASSERT(condition, ...)
#endif
#define KJ_UNREACHABLE ::kj::_::unreachable();
......@@ -590,12 +602,14 @@ private: // internal interface used by friends only
inline NullableValue& operator=(NullableValue&& other) {
if (&other != this) {
// Careful about throwing destructors/constructors here.
if (isSet) {
isSet = false;
dtor(value);
}
isSet = other.isSet;
if (isSet) {
if (other.isSet) {
ctor(value, kj::mv(other.value));
isSet = true;
}
}
return *this;
......@@ -603,12 +617,14 @@ private: // internal interface used by friends only
inline NullableValue& operator=(NullableValue& other) {
if (&other != this) {
// Careful about throwing destructors/constructors here.
if (isSet) {
isSet = false;
dtor(value);
}
isSet = other.isSet;
if (isSet) {
if (other.isSet) {
ctor(value, other.value);
isSet = true;
}
}
return *this;
......@@ -616,12 +632,14 @@ private: // internal interface used by friends only
inline NullableValue& operator=(const NullableValue& other) {
if (&other != this) {
// Careful about throwing destructors/constructors here.
if (isSet) {
isSet = false;
dtor(value);
}
isSet = other.isSet;
if (isSet) {
if (other.isSet) {
ctor(value, other.value);
isSet = true;
}
}
return *this;
......
......@@ -198,7 +198,7 @@ Debug::Fault::~Fault() noexcept(false) {
if (exception != nullptr) {
Exception copy = mv(*exception);
delete exception;
getExceptionCallback().onRecoverableException(mv(copy));
throwRecoverableException(mv(copy));
}
}
......@@ -206,7 +206,7 @@ void Debug::Fault::fatal() {
Exception copy = mv(*exception);
delete exception;
exception = nullptr;
getExceptionCallback().onFatalException(mv(copy));
throwFatalException(mv(copy));
abort();
}
......
......@@ -342,6 +342,15 @@ ExceptionCallback& getExceptionCallback() {
return scoped != nullptr ? *scoped : defaultCallback;
}
void throwFatalException(kj::Exception&& exception) {
getExceptionCallback().onFatalException(kj::mv(exception));
abort();
}
void throwRecoverableException(kj::Exception&& exception) {
getExceptionCallback().onRecoverableException(kj::mv(exception));
}
// =======================================================================================
namespace _ { // private
......@@ -422,7 +431,7 @@ public:
Maybe<Exception> caught;
};
Maybe<Exception> runCatchingExceptions(Runnable& runnable) {
Maybe<Exception> runCatchingExceptions(Runnable& runnable) noexcept {
#if KJ_NO_EXCEPTIONS
RecoverableExceptionCatcher catcher;
runnable.run();
......
......@@ -182,12 +182,20 @@ private:
ExceptionCallback& getExceptionCallback();
// Returns the current exception callback.
void throwFatalException(kj::Exception&& exception) KJ_NORETURN;
// Invoke the exception callback to throw the given fatal exception. If the exception callback
// returns, abort.
void throwRecoverableException(kj::Exception&& exception);
// Invoke the exception acllback to throw the given recoverable exception. If the exception
// callback returns, return normally.
// =======================================================================================
namespace _ { class Runnable; }
template <typename Func>
Maybe<Exception> runCatchingExceptions(Func&& func);
Maybe<Exception> runCatchingExceptions(Func&& func) noexcept;
// Executes the given function (usually, a lambda returning nothing) catching any exceptions that
// are thrown. Returns the Exception if there was one, or null if the operation completed normally.
// Non-KJ exceptions will be wrapped.
......@@ -242,12 +250,12 @@ private:
Func func;
};
Maybe<Exception> runCatchingExceptions(Runnable& runnable);
Maybe<Exception> runCatchingExceptions(Runnable& runnable) noexcept;
} // namespace _ (private)
template <typename Func>
Maybe<Exception> runCatchingExceptions(Func&& func) {
Maybe<Exception> runCatchingExceptions(Func&& func) noexcept {
_::RunnableImpl<Decay<Func>> runnable(kj::fwd<Func>(func));
return _::runCatchingExceptions(runnable);
}
......
......@@ -147,7 +147,7 @@ public:
}
inline Locked& operator=(Locked&& other) {
if (mutex != nullptr) mutex->unlock(isConst<T>());
if (mutex != nullptr) mutex->unlock(isConst<T>() ? _::Mutex::SHARED : _::Mutex::EXCLUSIVE);
mutex = other.mutex;
ptr = other.ptr;
other.mutex = nullptr;
......@@ -155,6 +155,12 @@ public:
return *this;
}
inline void release() {
if (mutex != nullptr) mutex->unlock(isConst<T>() ? _::Mutex::SHARED : _::Mutex::EXCLUSIVE);
mutex = nullptr;
ptr = nullptr;
}
inline T* operator->() { return ptr; }
inline const T* operator->() const { return ptr; }
inline T& operator*() { return *ptr; }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment