2024-05-16 23:47:20 +00:00
|
|
|
#include <lix/config.h> // IWYU pragma: keep
|
2023-12-10 14:47:56 +00:00
|
|
|
|
2024-05-16 23:47:20 +00:00
|
|
|
#include <lix/libexpr/eval-settings.hh>
|
|
|
|
#include <lix/libmain/shared.hh>
|
|
|
|
#include <lix/libutil/sync.hh>
|
|
|
|
#include <lix/libexpr/eval.hh>
|
|
|
|
#include <lix/libutil/signals.hh>
|
2020-11-29 14:33:55 +00:00
|
|
|
#include <sys/wait.h>
|
2023-12-11 21:05:02 +00:00
|
|
|
#include <nlohmann/json.hpp>
|
|
|
|
#include <errno.h>
|
2024-06-09 13:20:20 +00:00
|
|
|
#include <pthread.h>
|
2023-12-11 21:05:02 +00:00
|
|
|
#include <signal.h>
|
|
|
|
#include <stdlib.h>
|
|
|
|
#include <string.h>
|
|
|
|
#include <unistd.h>
|
2024-05-16 23:47:20 +00:00
|
|
|
#include <lix/libexpr/attr-set.hh>
|
|
|
|
#include <lix/libutil/config.hh>
|
|
|
|
#include <lix/libutil/error.hh>
|
|
|
|
#include <lix/libstore/globals.hh>
|
|
|
|
#include <lix/libutil/logging.hh>
|
2024-05-30 02:17:54 +00:00
|
|
|
#include <lix/libutil/terminal.hh>
|
2023-12-11 21:05:02 +00:00
|
|
|
#include <nlohmann/detail/iterators/iter_impl.hpp>
|
|
|
|
#include <nlohmann/detail/json_ref.hpp>
|
|
|
|
#include <nlohmann/json_fwd.hpp>
|
2024-05-16 23:47:20 +00:00
|
|
|
#include <lix/libutil/ref.hh>
|
|
|
|
#include <lix/libstore/store-api.hh>
|
2023-12-11 21:05:02 +00:00
|
|
|
#include <map>
|
|
|
|
#include <condition_variable>
|
|
|
|
#include <filesystem>
|
|
|
|
#include <exception>
|
|
|
|
#include <functional>
|
|
|
|
#include <iostream>
|
|
|
|
#include <memory>
|
|
|
|
#include <optional>
|
|
|
|
#include <set>
|
|
|
|
#include <string>
|
|
|
|
#include <string_view>
|
|
|
|
#include <utility>
|
|
|
|
#include <vector>
|
2020-11-29 14:33:55 +00:00
|
|
|
|
2023-12-10 13:22:32 +00:00
|
|
|
#include "eval-args.hh"
|
2023-12-10 14:41:51 +00:00
|
|
|
#include "buffered-io.hh"
|
|
|
|
#include "worker.hh"
|
2023-12-10 13:22:32 +00:00
|
|
|
|
2020-11-29 14:33:55 +00:00
|
|
|
using namespace nix;
|
2022-04-26 05:11:17 +00:00
|
|
|
using namespace nlohmann;
|
2020-11-29 14:33:55 +00:00
|
|
|
|
|
|
|
static MyArgs myArgs;
|
|
|
|
|
2023-01-22 09:28:47 +00:00
|
|
|
typedef std::function<void(ref<EvalState> state, Bindings &autoArgs,
|
2023-12-10 14:41:51 +00:00
|
|
|
AutoCloseFD &to, AutoCloseFD &from, MyArgs &args)>
|
2022-04-15 06:28:19 +00:00
|
|
|
Processor;
|
|
|
|
|
|
|
|
/* Auto-cleanup of fork's process and fds. */
|
|
|
|
struct Proc {
|
|
|
|
AutoCloseFD to, from;
|
|
|
|
Pid pid;
|
|
|
|
|
2022-04-26 09:15:56 +00:00
|
|
|
Proc(const Processor &proc) {
|
2022-04-15 06:28:19 +00:00
|
|
|
Pipe toPipe, fromPipe;
|
|
|
|
toPipe.create();
|
|
|
|
fromPipe.create();
|
|
|
|
auto p = startProcess(
|
|
|
|
[&,
|
|
|
|
to{std::make_shared<AutoCloseFD>(std::move(fromPipe.writeSide))},
|
2022-04-26 09:15:56 +00:00
|
|
|
from{
|
|
|
|
std::make_shared<AutoCloseFD>(std::move(toPipe.readSide))}]() {
|
2022-04-15 06:28:19 +00:00
|
|
|
debug("created worker process %d", getpid());
|
|
|
|
try {
|
2024-04-23 07:07:53 +00:00
|
|
|
auto evalStore = myArgs.evalStoreUrl
|
|
|
|
? openStore(*myArgs.evalStoreUrl)
|
|
|
|
: openStore();
|
|
|
|
auto state = std::make_shared<EvalState>(myArgs.searchPath,
|
|
|
|
evalStore);
|
2023-01-22 09:28:47 +00:00
|
|
|
Bindings &autoArgs = *myArgs.getAutoArgs(*state);
|
2023-12-10 14:41:51 +00:00
|
|
|
proc(ref<EvalState>(state), autoArgs, *to, *from, myArgs);
|
2022-04-26 09:15:56 +00:00
|
|
|
} catch (Error &e) {
|
2022-04-15 06:28:19 +00:00
|
|
|
nlohmann::json err;
|
|
|
|
auto msg = e.msg();
|
2023-12-10 14:41:51 +00:00
|
|
|
err["error"] = nix::filterANSIEscapes(msg, true);
|
2022-04-15 06:28:19 +00:00
|
|
|
printError(msg);
|
2023-12-09 16:17:01 +00:00
|
|
|
if (tryWriteLine(to->get(), err.dump()) < 0) {
|
|
|
|
return; // main process died
|
|
|
|
};
|
2022-04-15 06:28:19 +00:00
|
|
|
// Don't forget to print it into the STDERR log, this is
|
|
|
|
// what's shown in the Hydra UI.
|
2023-12-09 16:17:01 +00:00
|
|
|
if (tryWriteLine(to->get(), "restart") < 0) {
|
|
|
|
return; // main process died
|
|
|
|
}
|
2022-04-15 06:28:19 +00:00
|
|
|
}
|
|
|
|
},
|
2024-04-15 17:30:56 +00:00
|
|
|
ProcessOptions{});
|
2022-04-15 06:28:19 +00:00
|
|
|
|
|
|
|
to = std::move(toPipe.writeSide);
|
|
|
|
from = std::move(fromPipe.readSide);
|
2024-06-25 21:57:51 +00:00
|
|
|
pid = std::move(p);
|
2022-04-15 06:28:19 +00:00
|
|
|
}
|
|
|
|
|
2022-04-26 09:15:56 +00:00
|
|
|
~Proc() {}
|
2022-04-15 06:28:19 +00:00
|
|
|
};
|
|
|
|
|
2024-06-09 13:20:20 +00:00
|
|
|
// We'd highly prefer using std::thread here; but this won't let us configure the stack
|
|
|
|
// size. macOS uses 512KiB size stacks for non-main threads, and musl defaults to 128k.
|
|
|
|
// While Nix configures a 64MiB size for the main thread, this doesn't propagate to the
|
|
|
|
// threads we launch here. It turns out, running the evaluator under an anemic stack of
|
|
|
|
// 0.5MiB has it overflow way too quickly. Hence, we have our own custom Thread struct.
|
|
|
|
struct Thread {
|
|
|
|
pthread_t thread;
|
|
|
|
|
|
|
|
Thread(const Thread &) = delete;
|
|
|
|
Thread(Thread &&) noexcept = default;
|
|
|
|
|
|
|
|
Thread(std::function<void(void)> f) {
|
|
|
|
int s;
|
|
|
|
pthread_attr_t attr;
|
|
|
|
|
|
|
|
auto func = std::make_unique<std::function<void(void)>>(std::move(f));
|
|
|
|
|
|
|
|
if ((s = pthread_attr_init(&attr)) != 0) {
|
|
|
|
throw SysError(s, "calling pthread_attr_init");
|
|
|
|
}
|
|
|
|
if ((s = pthread_attr_setstacksize(&attr, 64 * 1024 * 1024)) != 0) {
|
|
|
|
throw SysError(s, "calling pthread_attr_setstacksize");
|
|
|
|
}
|
|
|
|
if ((s = pthread_create(&thread, &attr, Thread::init, func.release())) != 0) {
|
|
|
|
throw SysError(s, "calling pthread_launch");
|
|
|
|
}
|
|
|
|
if ((s = pthread_attr_destroy(&attr)) != 0) {
|
|
|
|
throw SysError(s, "calling pthread_attr_destroy");
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
void join() {
|
|
|
|
int s;
|
|
|
|
s = pthread_join(thread, nullptr);
|
|
|
|
if (s != 0) {
|
|
|
|
throw SysError(s, "calling pthread_join");
|
|
|
|
}
|
|
|
|
}
|
|
|
|
private:
|
|
|
|
static void *init(void *ptr) {
|
|
|
|
std::unique_ptr<std::function<void(void)>> func;
|
|
|
|
func.reset(static_cast<std::function<void(void)> *>(ptr));
|
|
|
|
|
|
|
|
(*func)();
|
|
|
|
return 0;
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2022-04-26 09:15:56 +00:00
|
|
|
struct State {
|
|
|
|
std::set<json> todo = json::array({json::array()});
|
|
|
|
std::set<json> active;
|
|
|
|
std::exception_ptr exc;
|
2022-04-21 04:01:02 +00:00
|
|
|
};
|
|
|
|
|
2023-12-16 09:10:49 +00:00
|
|
|
void handleBrokenWorkerPipe(Proc &proc, std::string_view msg) {
|
2023-12-10 20:20:45 +00:00
|
|
|
// we already took the process status from Proc, no
|
|
|
|
// need to wait for it again to avoid error messages
|
|
|
|
pid_t pid = proc.pid.release();
|
2023-12-09 16:17:01 +00:00
|
|
|
while (1) {
|
2023-12-16 08:48:33 +00:00
|
|
|
int status;
|
|
|
|
int rc = waitpid(pid, &status, WNOHANG);
|
2023-12-09 16:17:01 +00:00
|
|
|
if (rc == 0) {
|
2023-12-10 20:20:45 +00:00
|
|
|
kill(pid, SIGKILL);
|
2023-12-16 10:44:02 +00:00
|
|
|
throw Error("BUG: while %s, worker pipe got closed but evaluation "
|
|
|
|
"worker still running?",
|
|
|
|
msg);
|
2023-12-09 16:17:01 +00:00
|
|
|
} else if (rc == -1) {
|
2023-12-10 20:20:45 +00:00
|
|
|
kill(pid, SIGKILL);
|
2023-12-16 10:44:02 +00:00
|
|
|
throw Error(
|
|
|
|
"BUG: while %s, waitpid for evaluation worker failed: %s", msg,
|
|
|
|
strerror(errno));
|
2023-12-09 16:17:01 +00:00
|
|
|
} else {
|
2023-12-16 08:48:33 +00:00
|
|
|
if (WIFEXITED(status)) {
|
2023-12-16 09:10:49 +00:00
|
|
|
if (WEXITSTATUS(status) == 1) {
|
|
|
|
throw Error(
|
|
|
|
"while %s, evaluation worker exited with exit code 1, "
|
2023-12-16 10:53:10 +00:00
|
|
|
"(possible infinite recursion)",
|
2023-12-16 09:10:49 +00:00
|
|
|
msg);
|
|
|
|
}
|
2023-12-16 10:44:02 +00:00
|
|
|
throw Error("while %s, evaluation worker exited with %d", msg,
|
|
|
|
WEXITSTATUS(status));
|
2023-12-16 08:48:33 +00:00
|
|
|
} else if (WIFSIGNALED(status)) {
|
2023-12-16 10:53:10 +00:00
|
|
|
switch (WTERMSIG(status)) {
|
|
|
|
case SIGKILL:
|
|
|
|
throw Error(
|
|
|
|
"while %s, evaluation worker got killed by SIGKILL, "
|
|
|
|
"maybe "
|
|
|
|
"memory limit reached?",
|
|
|
|
msg);
|
|
|
|
break;
|
|
|
|
#ifdef __APPLE__
|
|
|
|
case SIGBUS:
|
|
|
|
throw Error(
|
|
|
|
"while %s, evaluation worker got killed by SIGBUS, "
|
|
|
|
"(possible infinite recursion)",
|
|
|
|
msg);
|
|
|
|
break;
|
|
|
|
#else
|
|
|
|
case SIGSEGV:
|
|
|
|
throw Error(
|
|
|
|
"while %s, evaluation worker got killed by SIGSEGV, "
|
|
|
|
"(possible infinite recursion)",
|
|
|
|
msg);
|
|
|
|
#endif
|
2023-12-10 07:16:54 +00:00
|
|
|
}
|
2023-12-16 09:10:49 +00:00
|
|
|
throw Error(
|
|
|
|
"while %s, evaluation worker got killed by signal %d (%s)",
|
|
|
|
msg, WTERMSIG(status), strsignal(WTERMSIG(status)));
|
2023-12-09 16:17:01 +00:00
|
|
|
} // else ignore WIFSTOPPED and WIFCONTINUED
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-12-16 09:10:49 +00:00
|
|
|
std::string joinAttrPath(json &attrPath) {
|
|
|
|
std::string joined;
|
2023-12-16 10:44:02 +00:00
|
|
|
for (auto &element : attrPath) {
|
2023-12-16 09:10:49 +00:00
|
|
|
if (!joined.empty()) {
|
|
|
|
joined += '.';
|
|
|
|
}
|
|
|
|
joined += element.get<std::string>();
|
|
|
|
}
|
|
|
|
return joined;
|
|
|
|
}
|
|
|
|
|
2023-12-10 16:19:47 +00:00
|
|
|
void collector(Sync<State> &state_, std::condition_variable &wakeup) {
|
|
|
|
try {
|
|
|
|
std::optional<std::unique_ptr<Proc>> proc_;
|
|
|
|
std::optional<std::unique_ptr<LineReader>> fromReader_;
|
|
|
|
|
|
|
|
while (true) {
|
|
|
|
if (!proc_.has_value()) {
|
|
|
|
proc_ = std::make_unique<Proc>(worker);
|
|
|
|
fromReader_ =
|
|
|
|
std::make_unique<LineReader>(proc_.value()->from.release());
|
|
|
|
}
|
|
|
|
auto proc = std::move(proc_.value());
|
|
|
|
auto fromReader = std::move(fromReader_.value());
|
|
|
|
|
|
|
|
/* Check whether the existing worker process is still there. */
|
|
|
|
auto s = fromReader->readLine();
|
2023-12-10 20:18:04 +00:00
|
|
|
if (s.empty()) {
|
2023-12-16 09:10:49 +00:00
|
|
|
handleBrokenWorkerPipe(*proc.get(), "checking worker process");
|
2023-12-10 16:19:47 +00:00
|
|
|
} else if (s == "restart") {
|
|
|
|
proc_ = std::nullopt;
|
|
|
|
fromReader_ = std::nullopt;
|
|
|
|
continue;
|
|
|
|
} else if (s != "next") {
|
|
|
|
try {
|
|
|
|
auto json = json::parse(s);
|
|
|
|
throw Error("worker error: %s", (std::string)json["error"]);
|
|
|
|
} catch (const json::exception &e) {
|
2023-12-10 20:18:04 +00:00
|
|
|
throw Error(
|
|
|
|
"Received invalid JSON from worker: %s\n json: '%s'",
|
|
|
|
e.what(), s);
|
2022-04-21 04:01:02 +00:00
|
|
|
}
|
2023-12-10 16:19:47 +00:00
|
|
|
}
|
2022-04-21 04:01:02 +00:00
|
|
|
|
2023-12-10 16:19:47 +00:00
|
|
|
/* Wait for a job name to become available. */
|
|
|
|
json attrPath;
|
|
|
|
|
|
|
|
while (true) {
|
|
|
|
checkInterrupt();
|
|
|
|
auto state(state_.lock());
|
|
|
|
if ((state->todo.empty() && state->active.empty()) ||
|
|
|
|
state->exc) {
|
|
|
|
if (tryWriteLine(proc->to.get(), "exit") < 0) {
|
2023-12-16 09:10:49 +00:00
|
|
|
handleBrokenWorkerPipe(*proc.get(), "sending exit");
|
2022-04-21 04:01:02 +00:00
|
|
|
}
|
2023-12-10 16:19:47 +00:00
|
|
|
return;
|
2022-04-21 04:01:02 +00:00
|
|
|
}
|
2023-12-10 16:19:47 +00:00
|
|
|
if (!state->todo.empty()) {
|
|
|
|
attrPath = *state->todo.begin();
|
|
|
|
state->todo.erase(state->todo.begin());
|
|
|
|
state->active.insert(attrPath);
|
|
|
|
break;
|
|
|
|
} else
|
|
|
|
state.wait(wakeup);
|
|
|
|
}
|
2022-04-21 04:01:02 +00:00
|
|
|
|
2023-12-10 16:19:47 +00:00
|
|
|
/* Tell the worker to evaluate it. */
|
|
|
|
if (tryWriteLine(proc->to.get(), "do " + attrPath.dump()) < 0) {
|
2023-12-16 09:10:49 +00:00
|
|
|
auto msg = "sending attrPath '" + joinAttrPath(attrPath) + "'";
|
|
|
|
handleBrokenWorkerPipe(*proc.get(), msg);
|
2023-12-10 16:19:47 +00:00
|
|
|
}
|
2022-04-21 04:01:02 +00:00
|
|
|
|
2023-12-10 16:19:47 +00:00
|
|
|
/* Wait for the response. */
|
|
|
|
auto respString = fromReader->readLine();
|
2023-12-10 20:18:04 +00:00
|
|
|
if (respString.empty()) {
|
2023-12-16 10:44:02 +00:00
|
|
|
auto msg = "reading result for attrPath '" +
|
|
|
|
joinAttrPath(attrPath) + "'";
|
2023-12-16 09:10:49 +00:00
|
|
|
handleBrokenWorkerPipe(*proc.get(), msg);
|
2023-12-10 16:19:47 +00:00
|
|
|
}
|
|
|
|
json response;
|
|
|
|
try {
|
|
|
|
response = json::parse(respString);
|
|
|
|
} catch (const json::exception &e) {
|
2023-12-10 20:18:04 +00:00
|
|
|
throw Error(
|
|
|
|
"Received invalid JSON from worker: %s\n json: '%s'",
|
|
|
|
e.what(), respString);
|
2023-12-10 16:19:47 +00:00
|
|
|
}
|
2022-04-21 04:01:02 +00:00
|
|
|
|
2023-12-10 16:19:47 +00:00
|
|
|
/* Handle the response. */
|
|
|
|
std::vector<json> newAttrs;
|
|
|
|
if (response.find("attrs") != response.end()) {
|
|
|
|
for (auto &i : response["attrs"]) {
|
|
|
|
json newAttr = json(response["attrPath"]);
|
|
|
|
newAttr.emplace_back(i);
|
|
|
|
newAttrs.push_back(newAttr);
|
2022-04-21 04:01:02 +00:00
|
|
|
}
|
2023-12-10 16:19:47 +00:00
|
|
|
} else {
|
|
|
|
auto state(state_.lock());
|
|
|
|
std::cout << respString << "\n" << std::flush;
|
|
|
|
}
|
2022-04-21 04:01:02 +00:00
|
|
|
|
2023-12-10 16:19:47 +00:00
|
|
|
proc_ = std::move(proc);
|
|
|
|
fromReader_ = std::move(fromReader);
|
2022-04-21 04:01:02 +00:00
|
|
|
|
2023-12-10 16:19:47 +00:00
|
|
|
/* Add newly discovered job names to the queue. */
|
|
|
|
{
|
|
|
|
auto state(state_.lock());
|
|
|
|
state->active.erase(attrPath);
|
|
|
|
for (auto p : newAttrs) {
|
|
|
|
state->todo.insert(p);
|
2022-04-21 04:01:02 +00:00
|
|
|
}
|
2023-12-10 16:19:47 +00:00
|
|
|
wakeup.notify_all();
|
2022-04-21 04:01:02 +00:00
|
|
|
}
|
|
|
|
}
|
2023-12-10 16:19:47 +00:00
|
|
|
} catch (...) {
|
|
|
|
auto state(state_.lock());
|
|
|
|
state->exc = std::current_exception();
|
|
|
|
wakeup.notify_all();
|
|
|
|
}
|
2022-04-21 04:01:02 +00:00
|
|
|
}
|
|
|
|
|
2022-04-26 09:15:56 +00:00
|
|
|
int main(int argc, char **argv) {
|
2023-12-10 16:19:47 +00:00
|
|
|
|
2020-11-29 14:33:55 +00:00
|
|
|
/* Prevent undeclared dependencies in the evaluation via
|
|
|
|
$NIX_PATH. */
|
|
|
|
unsetenv("NIX_PATH");
|
|
|
|
|
2022-04-21 16:31:53 +00:00
|
|
|
/* We are doing the garbage collection by killing forks */
|
|
|
|
setenv("GC_DONT_GC", "1", 1);
|
|
|
|
|
2020-11-29 14:33:55 +00:00
|
|
|
return handleExceptions(argv[0], [&]() {
|
|
|
|
initNix();
|
|
|
|
initGC();
|
|
|
|
|
2023-12-10 13:22:32 +00:00
|
|
|
myArgs.parseArgs(argv, argc);
|
2020-11-29 14:33:55 +00:00
|
|
|
|
2022-04-26 09:15:56 +00:00
|
|
|
/* FIXME: The build hook in conjunction with import-from-derivation is
|
|
|
|
* causing "unexpected EOF" during eval */
|
2020-11-29 14:33:55 +00:00
|
|
|
settings.builders = "";
|
|
|
|
|
|
|
|
/* Prevent access to paths outside of the Nix search path and
|
|
|
|
to the environment. */
|
2021-03-14 22:32:36 +00:00
|
|
|
evalSettings.restrictEval = false;
|
2020-11-29 14:33:55 +00:00
|
|
|
|
|
|
|
/* When building a flake, use pure evaluation (no access to
|
|
|
|
'getEnv', 'currentSystem' etc. */
|
2022-08-17 06:38:37 +00:00
|
|
|
if (myArgs.impure) {
|
|
|
|
evalSettings.pureEval = false;
|
|
|
|
} else if (myArgs.flake) {
|
|
|
|
evalSettings.pureEval = true;
|
|
|
|
}
|
2020-11-29 14:33:55 +00:00
|
|
|
|
2022-04-26 09:15:56 +00:00
|
|
|
if (myArgs.releaseExpr == "")
|
|
|
|
throw UsageError("no expression specified");
|
2020-11-29 14:33:55 +00:00
|
|
|
|
2022-09-20 18:12:55 +00:00
|
|
|
if (myArgs.gcRootsDir == "") {
|
2022-04-26 09:15:56 +00:00
|
|
|
printMsg(lvlError, "warning: `--gc-roots-dir' not specified");
|
2022-09-20 18:12:55 +00:00
|
|
|
} else {
|
|
|
|
myArgs.gcRootsDir = std::filesystem::absolute(myArgs.gcRootsDir);
|
|
|
|
}
|
2020-11-29 14:33:55 +00:00
|
|
|
|
2022-01-07 07:20:41 +00:00
|
|
|
if (myArgs.showTrace) {
|
|
|
|
loggerSettings.showTrace.assign(true);
|
|
|
|
}
|
|
|
|
|
2020-11-29 14:33:55 +00:00
|
|
|
Sync<State> state_;
|
|
|
|
|
2022-04-21 04:01:02 +00:00
|
|
|
/* Start a collector thread per worker process. */
|
2024-06-09 13:20:20 +00:00
|
|
|
std::vector<Thread> threads;
|
2022-04-21 04:01:02 +00:00
|
|
|
std::condition_variable wakeup;
|
2023-12-10 16:19:47 +00:00
|
|
|
for (size_t i = 0; i < myArgs.nrWorkers; i++) {
|
2024-06-09 13:20:20 +00:00
|
|
|
threads.emplace_back(std::bind(collector, std::ref(state_), std::ref(wakeup)));
|
2023-12-10 16:19:47 +00:00
|
|
|
}
|
2020-11-29 14:33:55 +00:00
|
|
|
|
2022-04-26 09:15:56 +00:00
|
|
|
for (auto &thread : threads)
|
2020-11-29 14:33:55 +00:00
|
|
|
thread.join();
|
|
|
|
|
|
|
|
auto state(state_.lock());
|
|
|
|
|
|
|
|
if (state->exc)
|
|
|
|
std::rethrow_exception(state->exc);
|
|
|
|
});
|
|
|
|
}
|