| // Copyright 2018 The Chromium OS Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #include "crash-reporter/crash_sender_util.h" |
| |
| #include <stdlib.h> |
| #include <sys/types.h> |
| #include <sys/wait.h> |
| |
| #include <algorithm> |
| #include <map> |
| #include <string> |
| #include <utility> |
| #include <vector> |
| |
| #include <base/files/file_enumerator.h> |
| #include <base/files/file_util.h> |
| #include <base/rand_util.h> |
| #include <base/strings/string_number_conversions.h> |
| #include <base/strings/string_split.h> |
| #include <base/strings/string_util.h> |
| #include <base/strings/stringprintf.h> |
| #include <base/threading/platform_thread.h> |
| #include <base/time/time.h> |
| #include <brillo/flag_helper.h> |
| |
| #include "crash-reporter/crash_sender_paths.h" |
| #include "crash-reporter/paths.h" |
| #include "crash-reporter/util.h" |
| |
| namespace util { |
| |
| namespace { |
| |
| // getenv() wrapper that returns an empty string, if the environment variable is |
| // not defined. |
| std::string GetEnv(const std::string& name) { |
| const char* value = getenv(name.c_str()); |
| return value ? value : ""; |
| } |
| |
| // Shows the usage of crash_sender and exits the process as a success. |
| void ShowUsageAndExit() { |
| printf( |
| "Usage: crash_sender [options]\n" |
| "Options:\n" |
| " -e <var>=<val> Set env |var| to |val| (only some vars)\n"); |
| exit(EXIT_SUCCESS); |
| } |
| |
| |
| // Returns true if the given report kind is known. |
| // TODO(satorux): Move collector constants to a common file. |
| bool IsKnownKind(const std::string& kind) { |
| return (kind == "minidump" || kind == "kcrash" || kind == "log" || |
| kind == "devcore" || kind == "eccrash" || kind == "bertdump"); |
| } |
| |
| // Returns true if the given key is valid for crash metadata. |
| bool IsValidKey(const std::string& key) { |
| if (key.empty()) |
| return false; |
| |
| for (const char c : key) { |
| if (!(base::IsAsciiAlpha(c) || base::IsAsciiDigit(c) || c == '_' || |
| c == '-' || c == '.')) { |
| return false; |
| } |
| } |
| |
| return true; |
| } |
| |
| // Converts metadata into CrashInfo. |
| void MetadataToCrashInfo(const brillo::KeyValueStore& metadata, |
| CrashInfo* info) { |
| info->payload_file = GetBaseNameFromMetadata(metadata, "payload"); |
| info->payload_kind = GetKindFromPayloadPath(info->payload_file); |
| } |
| |
| } // namespace |
| |
| void ParseCommandLine(int argc, |
| const char* const* argv, |
| CommandLineFlags* flags) { |
| std::map<std::string, std::string> env_vars; |
| for (const EnvPair& pair : kEnvironmentVariables) { |
| // Honor the existing value if it's already set. |
| const char* value = getenv(pair.name); |
| env_vars[pair.name] = value ? value : pair.value; |
| } |
| |
| // Process -e options, and collect other options. |
| std::vector<const char*> new_argv; |
| new_argv.push_back(argv[0]); |
| for (int i = 1; i < argc; ++i) { |
| if (std::string(argv[i]) == "-e") { |
| if (i + 1 < argc) { |
| ++i; |
| std::string name_value = argv[i]; |
| std::vector<std::string> pair = base::SplitString( |
| name_value, "=", base::KEEP_WHITESPACE, base::SPLIT_WANT_ALL); |
| if (pair.size() == 2) { |
| if (env_vars.count(pair[0]) == 0) { |
| LOG(ERROR) << "Unknown variable name: " << pair[0]; |
| exit(EXIT_FAILURE); |
| } |
| env_vars[pair[0]] = pair[1]; |
| } else { |
| LOG(ERROR) << "Malformed value for -e: " << name_value; |
| exit(EXIT_FAILURE); |
| } |
| } else { |
| LOG(ERROR) << "Value for -e is missing"; |
| exit(EXIT_FAILURE); |
| } |
| } else { |
| new_argv.push_back(argv[i]); |
| } |
| } |
| // argv[argc] should be a null pointer per the C standard. |
| new_argv.push_back(nullptr); |
| |
| // Process the remaining flags. |
| DEFINE_bool(h, false, "Show this help and exit"); |
| DEFINE_int32(max_spread_time, kMaxSpreadTimeInSeconds, |
| "Max time in secs to sleep before sending (0 to send now)"); |
| brillo::FlagHelper::Init(new_argv.size() - 1, new_argv.data(), |
| "Chromium OS Crash Sender"); |
| // TODO(satorux): Remove this once -e option is gone. |
| if (FLAGS_h) |
| ShowUsageAndExit(); |
| |
| if (FLAGS_max_spread_time < 0) { |
| LOG(ERROR) << "Invalid value for max spread time: " |
| << FLAGS_max_spread_time; |
| exit(EXIT_FAILURE); |
| } |
| flags->max_spread_time = base::TimeDelta::FromSeconds(FLAGS_max_spread_time); |
| |
| // Set the predefined environment variables. |
| for (const auto& it : env_vars) |
| setenv(it.first.c_str(), it.second.c_str(), 1 /* overwrite */); |
| } |
| |
| bool IsMock() { |
| return base::PathExists( |
| paths::GetAt(paths::kSystemRunStateDirectory, paths::kMockCrashSending)); |
| } |
| |
| bool ShouldPauseSending() { |
| return (base::PathExists(paths::Get(paths::kPauseCrashSending)) && |
| GetEnv("OVERRIDE_PAUSE_SENDING") == "0"); |
| } |
| |
| bool CheckDependencies(base::FilePath* missing_path) { |
| const char* const kDependencies[] = { |
| paths::kFind, paths::kMetricsClient, |
| paths::kRestrictedCertificatesDirectory, |
| }; |
| |
| for (const char* dependency : kDependencies) { |
| const base::FilePath path = paths::Get(dependency); |
| int permissions = 0; |
| // Check if |path| is an executable or a directory. |
| if (!(base::GetPosixFilePermissions(path, &permissions) && |
| (permissions & base::FILE_PERMISSION_EXECUTE_BY_USER))) { |
| *missing_path = path; |
| return false; |
| } |
| } |
| return true; |
| } |
| |
| base::FilePath GetBasePartOfCrashFile(const base::FilePath& file_name) { |
| std::vector<std::string> components; |
| file_name.GetComponents(&components); |
| |
| std::vector<std::string> parts = base::SplitString( |
| components.back(), ".", base::KEEP_WHITESPACE, base::SPLIT_WANT_ALL); |
| if (parts.size() < 4) { |
| LOG(ERROR) << "Unexpected file name format: " << file_name.value(); |
| return file_name; |
| } |
| |
| parts.resize(4); |
| const std::string base_name = base::JoinString(parts, "."); |
| |
| if (components.size() == 1) |
| return base::FilePath(base_name); |
| return file_name.DirName().Append(base_name); |
| } |
| |
| void RemoveOrphanedCrashFiles(const base::FilePath& crash_dir) { |
| base::FileEnumerator iter(crash_dir, true /* recursive */, |
| base::FileEnumerator::FILES, "*"); |
| for (base::FilePath file = iter.Next(); !file.empty(); file = iter.Next()) { |
| // Get the meta data file path. |
| const base::FilePath meta_file = |
| base::FilePath(GetBasePartOfCrashFile(file).value() + ".meta"); |
| |
| // Check how old the file is. |
| base::File::Info info; |
| if (!base::GetFileInfo(file, &info)) { |
| LOG(WARNING) << "Failed to get file info: " << file.value(); |
| continue; |
| } |
| base::TimeDelta delta = base::Time::Now() - info.last_modified; |
| |
| if (!base::PathExists(meta_file) && delta.InHours() >= 24) { |
| LOG(INFO) << "Removing old orphaned file: " << file.value(); |
| if (!base::DeleteFile(file, false /* recursive */)) |
| PLOG(WARNING) << "Failed to remove " << file.value(); |
| } |
| } |
| } |
| |
| Action ChooseAction(const base::FilePath& meta_file, |
| MetricsLibraryInterface* metrics_lib, |
| std::string* reason, |
| CrashInfo* info) { |
| if (!IsMock() && !IsOfficialImage()) { |
| *reason = "Not an official OS version"; |
| return kRemove; |
| } |
| |
| // AreMetricsEnabled() returns false in guest mode, thus IsGuestMode() should |
| // also be checked here (otherwise, all crash files are deleted in guest |
| // mode). |
| // |
| // Note that this check is slightly racey, but should be rare enough for us |
| // not to care: |
| // |
| // - crash_sender checks IsGuestMode() and it returns false |
| // - User logs in to guest mode |
| // - crash_sender checks AreMetricsEnabled() and it's now false |
| // - Reports are deleted |
| if (!metrics_lib->IsGuestMode() && !metrics_lib->AreMetricsEnabled()) { |
| *reason = "Crash reporting is disabled"; |
| return kRemove; |
| } |
| |
| std::string raw_metadata; |
| if (!base::ReadFileToString(meta_file, &raw_metadata)) { |
| PLOG(WARNING) << "Igonoring: metadata file is inaccessible"; |
| return kIgnore; |
| } |
| |
| if (!ParseMetadata(raw_metadata, &info->metadata)) { |
| *reason = "Corrupted metadata: " + raw_metadata; |
| return kRemove; |
| } |
| |
| MetadataToCrashInfo(info->metadata, info); |
| |
| if (info->payload_file.empty()) { |
| *reason = "Payload is not found in the meta data: " + raw_metadata; |
| return kRemove; |
| } |
| |
| // Make it an absolute path. |
| info->payload_file = meta_file.DirName().Append(info->payload_file); |
| |
| if (!base::PathExists(info->payload_file)) { |
| // TODO(satorux): logging_CrashSender.py expects "Missing payload" in the |
| // error message. Revise the autotest once the rewrite to C++ is complete. |
| *reason = "Missing payload: " + info->payload_file.value(); |
| return kRemove; |
| } |
| |
| if (!IsKnownKind(info->payload_kind)) { |
| *reason = "Unknown kind: " + info->payload_kind; |
| return kRemove; |
| } |
| |
| if (!IsCompleteMetadata(info->metadata)) { |
| base::File::Info info; |
| if (!base::GetFileInfo(meta_file, &info)) { |
| // Should not happen since it succeeded to read the file. |
| *reason = "Failed to get file info"; |
| return kIgnore; |
| } |
| |
| const base::TimeDelta delta = base::Time::Now() - info.last_modified; |
| if (delta.InHours() >= 24) { |
| // TODO(satorux): logging_CrashSender.py expects the following string as |
| // error message. Revise the autotest once the rewrite to C++ is complete. |
| *reason = "Removing old incomplete metadata"; |
| return kRemove; |
| } else { |
| *reason = "Recent incomplete metadata"; |
| return kIgnore; |
| } |
| } |
| |
| if (info->payload_kind == "devcore" && !IsDeviceCoredumpUploadAllowed()) { |
| *reason = "Device coredump upload not allowed"; |
| return kIgnore; |
| } |
| |
| return kSend; |
| } |
| |
| void RemoveAndPickCrashFiles(const base::FilePath& crash_dir, |
| MetricsLibraryInterface* metrics_lib, |
| std::vector<MetaFile>* to_send) { |
| std::vector<base::FilePath> meta_files = GetMetaFiles(crash_dir); |
| |
| for (const auto& meta_file : meta_files) { |
| LOG(INFO) << "Checking metadata: " << meta_file.value(); |
| |
| std::string reason; |
| std::unique_ptr<CrashInfo> info = std::make_unique<CrashInfo>(); |
| switch (ChooseAction(meta_file, metrics_lib, &reason, info.get())) { |
| case kRemove: |
| LOG(INFO) << "Removing: " << reason; |
| RemoveReportFiles(meta_file); |
| break; |
| case kIgnore: |
| LOG(INFO) << "Igonoring: " << reason; |
| break; |
| case kSend: |
| to_send->push_back(std::make_pair(meta_file, std::move(info))); |
| break; |
| default: |
| NOTREACHED(); |
| } |
| } |
| } |
| |
| void RemoveReportFiles(const base::FilePath& meta_file) { |
| if (meta_file.Extension() != ".meta") { |
| LOG(ERROR) << "Not a meta file: " << meta_file.value(); |
| return; |
| } |
| |
| const std::string pattern = |
| meta_file.BaseName().RemoveExtension().value() + ".*"; |
| |
| base::FileEnumerator iter(meta_file.DirName(), false /* recursive */, |
| base::FileEnumerator::FILES, pattern); |
| for (base::FilePath file = iter.Next(); !file.empty(); file = iter.Next()) { |
| if (!base::DeleteFile(file, false /* recursive */)) |
| PLOG(WARNING) << "Failed to remove " << file.value(); |
| } |
| } |
| |
| std::vector<base::FilePath> GetMetaFiles(const base::FilePath& crash_dir) { |
| base::FileEnumerator iter(crash_dir, false /* recursive */, |
| base::FileEnumerator::FILES, "*.meta"); |
| std::vector<std::pair<base::Time, base::FilePath>> time_meta_pairs; |
| for (base::FilePath file = iter.Next(); !file.empty(); file = iter.Next()) { |
| base::File::Info info; |
| if (!base::GetFileInfo(file, &info)) { |
| PLOG(WARNING) << "Failed to get file info: " << file.value(); |
| continue; |
| } |
| time_meta_pairs.push_back(std::make_pair(info.last_modified, file)); |
| } |
| std::sort(time_meta_pairs.begin(), time_meta_pairs.end()); |
| |
| std::vector<base::FilePath> meta_files; |
| for (const auto& pair : time_meta_pairs) |
| meta_files.push_back(pair.second); |
| return meta_files; |
| } |
| |
| base::FilePath GetBaseNameFromMetadata(const brillo::KeyValueStore& metadata, |
| const std::string& key) { |
| std::string value; |
| if (!metadata.GetString(key, &value)) |
| return base::FilePath(); |
| |
| return base::FilePath(value).BaseName(); |
| } |
| |
| std::string GetKindFromPayloadPath(const base::FilePath& payload_path) { |
| std::vector<std::string> parts = |
| base::SplitString(payload_path.BaseName().value(), ".", |
| base::KEEP_WHITESPACE, base::SPLIT_WANT_ALL); |
| // Suppress "gz". |
| if (parts.size() >= 2 && parts.back() == "gz") |
| parts.pop_back(); |
| |
| if (parts.size() <= 1) |
| return ""; |
| |
| std::string extension = parts.back(); |
| if (extension == "dmp") |
| return "minidump"; |
| |
| return extension; |
| } |
| |
| bool ParseMetadata(const std::string& raw_metadata, |
| brillo::KeyValueStore* metadata) { |
| metadata->Clear(); |
| if (!metadata->LoadFromString(raw_metadata)) |
| return false; |
| |
| for (const auto& key : metadata->GetKeys()) { |
| if (!IsValidKey(key)) |
| return false; |
| } |
| |
| return true; |
| } |
| |
| bool IsCompleteMetadata(const brillo::KeyValueStore& metadata) { |
| // *.meta files always end with done=1 so we can tell if they are complete. |
| std::string value; |
| if (!metadata.GetString("done", &value)) |
| return false; |
| return value == "1"; |
| } |
| |
| bool IsTimestampNewEnough(const base::FilePath& timestamp_file) { |
| const base::Time threshold = |
| base::Time::Now() - base::TimeDelta::FromHours(24); |
| |
| base::File::Info info; |
| if (!base::GetFileInfo(timestamp_file, &info)) { |
| PLOG(ERROR) << "Failed to get file info: " << timestamp_file.value(); |
| return false; |
| } |
| |
| return threshold < info.last_modified; |
| } |
| |
| bool IsBelowRate(const base::FilePath& timestamps_dir, |
| int max_crash_rate, |
| int* current_rate) { |
| if (!base::CreateDirectory(timestamps_dir)) { |
| PLOG(ERROR) << "Failed to create a timestamps directory: " |
| << timestamps_dir.value(); |
| return false; |
| } |
| |
| // Count the number of timestamp files, that were written in the past 24 |
| // hours. Remove files that are older. |
| *current_rate = 0; |
| base::FileEnumerator iter(timestamps_dir, false /* recursive */, |
| base::FileEnumerator::FILES, "*"); |
| for (base::FilePath file = iter.Next(); !file.empty(); file = iter.Next()) { |
| if (IsTimestampNewEnough(file)) { |
| ++(*current_rate); |
| } else { |
| if (!base::DeleteFile(file, false /* recursive */)) |
| PLOG(WARNING) << "Failed to remove " << file.value(); |
| } |
| } |
| LOG(INFO) << "Current send rate: " << *current_rate << "sends/24hrs"; |
| |
| if (*current_rate < max_crash_rate) { |
| // It's OK to send a new crash report now. Create a new timestamp to record |
| // that a new attempt is made to send a crash report. |
| base::FilePath temp_file; |
| if (!base::CreateTemporaryFileInDir(timestamps_dir, &temp_file)) { |
| PLOG(ERROR) << "Failed to create a file in " << timestamps_dir.value(); |
| return false; |
| } |
| return true; |
| } |
| |
| return false; |
| } |
| |
| bool GetSleepTime(const base::FilePath& meta_file, |
| const base::TimeDelta& max_spread_time, |
| base::TimeDelta* sleep_time) { |
| base::File::Info info; |
| if (!base::GetFileInfo(meta_file, &info)) { |
| PLOG(ERROR) << "Failed to get file info: " << meta_file.value(); |
| return false; |
| } |
| |
| // The meta file should be written *after* all to-be-uploaded files that it |
| // references. Nevertheless, as a safeguard, a hold-off time of |
| // kMaxHoldOffTimeInSeconds after writing the meta file is ensured. Also, |
| // sending of crash reports is spread out randomly by up to |max_spread_time|. |
| // Thus, for the sleep call the greater of the two delays is used. |
| const base::TimeDelta max_holdoff_time = |
| base::TimeDelta::FromSeconds(kMaxHoldOffTimeInSeconds); |
| // Use max() to ensure that holdoff_time is not negative. |
| const base::TimeDelta holdoff_time = |
| std::max(info.last_modified + max_holdoff_time - base::Time::Now(), |
| base::TimeDelta()); |
| |
| const int seconds = (max_spread_time.InSeconds() <= 0 |
| ? 0 |
| : base::RandInt(0, max_spread_time.InSeconds())); |
| const base::TimeDelta spread_time = base::TimeDelta::FromSeconds(seconds); |
| |
| *sleep_time = std::max(spread_time, holdoff_time); |
| |
| return true; |
| } |
| |
| std::string GetValueOrUndefined(const brillo::KeyValueStore& store, |
| const std::string& key) { |
| std::string value; |
| if (!store.GetString(key, &value)) |
| return "undefined"; |
| return value; |
| } |
| |
| Sender::Sender(std::unique_ptr<MetricsLibraryInterface> metrics_lib, |
| const Sender::Options& options) |
| : metrics_lib_(std::move(metrics_lib)), |
| shell_script_(options.shell_script), |
| proxy_(options.proxy), |
| max_crash_rate_(options.max_crash_rate), |
| max_spread_time_(options.max_spread_time), |
| sleep_function_(options.sleep_function) {} |
| |
| bool Sender::Init() { |
| if (!scoped_temp_dir_.CreateUniqueTempDir()) { |
| PLOG(ERROR) << "Failed to create a temporary directory"; |
| return false; |
| } |
| |
| if (sleep_function_.is_null()) |
| sleep_function_ = base::Bind(&base::PlatformThread::Sleep); |
| |
| return true; |
| } |
| |
| bool Sender::SendCrashes(const base::FilePath& crash_dir) { |
| if (!base::DirectoryExists(crash_dir)) { |
| // Directory not existing is not an error. |
| return true; |
| } |
| |
| RemoveOrphanedCrashFiles(crash_dir); |
| |
| std::vector<MetaFile> to_send; |
| RemoveAndPickCrashFiles(crash_dir, metrics_lib_.get(), &to_send); |
| |
| bool success = true; |
| for (const auto& pair : to_send) { |
| const base::FilePath& meta_file = pair.first; |
| const CrashInfo& info = *pair.second; |
| LOG(INFO) << "Evaluating crash report: " << meta_file.value(); |
| |
| // This should be checked inside of the loop, since the device can enter |
| // guest mode while sending crash reports with an interval up to |
| // max_spread_time_ between sends. |
| if (metrics_lib_->IsGuestMode()) { |
| LOG(INFO) << "Guest mode has been entered. Delaying crash sending"; |
| return success; |
| } |
| |
| int rate = 0; |
| const base::FilePath timestamps_dir = |
| paths::Get(paths::kTimestampsDirectory); |
| if (!IsBelowRate(timestamps_dir, max_crash_rate_, &rate)) { |
| LOG(INFO) << "Cannot send more crashes. Sending " << meta_file.value() |
| << " would exceed the max rate: " << max_crash_rate_; |
| return success; |
| } |
| |
| base::TimeDelta sleep_time; |
| if (!GetSleepTime(meta_file, max_spread_time_, &sleep_time)) { |
| LOG(WARNING) << "Failed to compute sleep time for " << meta_file.value(); |
| continue; |
| } |
| |
| LOG(INFO) << "Scheduled to send in " << sleep_time.InSeconds() << "s"; |
| if (!IsMock()) |
| sleep_function_.Run(sleep_time); |
| |
| // User-specific crash reports become inaccessible if the user signs out |
| // while sleeping, thus we need to check if the metadata is still |
| // accessible. |
| if (!base::PathExists(meta_file)) { |
| LOG(INFO) << "Metadata is no longer accessible: " << meta_file.value(); |
| continue; |
| } |
| |
| const CrashDetails details = { |
| .meta_file = meta_file, |
| .payload_file = info.payload_file, |
| .payload_kind = info.payload_kind, |
| .exec_name = GetValueOrUndefined(info.metadata, "exec_name"), |
| }; |
| if (!RequestToSendCrash(details)) { |
| LOG(WARNING) << "Failed to send " << meta_file.value() |
| << ", not removing; will retry later"; |
| success = false; |
| continue; |
| } |
| LOG(INFO) << "Successfully sent crash " << meta_file.value() |
| << " and removing."; |
| RemoveReportFiles(meta_file); |
| } |
| return success; |
| } |
| |
| bool Sender::SendUserCrashes() { |
| scoped_refptr<dbus::Bus> bus; |
| bool fully_successful = true; |
| |
| // Set up the session manager proxy if it's not given from the options. |
| if (!proxy_) { |
| dbus::Bus::Options options; |
| options.bus_type = dbus::Bus::SYSTEM; |
| scoped_refptr<dbus::Bus> bus = new dbus::Bus(options); |
| CHECK(bus->Connect()); |
| proxy_.reset(new org::chromium::SessionManagerInterfaceProxy(bus)); |
| } |
| |
| std::vector<base::FilePath> directories; |
| if (util::GetUserCrashDirectories(proxy_.get(), &directories)) { |
| for (auto directory : directories) { |
| if (!SendCrashes(directory)) { |
| LOG(ERROR) << "Skipped " << directory.value(); |
| fully_successful = false; |
| } |
| } |
| } |
| |
| if (bus) |
| bus->ShutdownAndBlock(); |
| |
| return fully_successful; |
| } |
| |
| bool Sender::RequestToSendCrash(const CrashDetails& details) { |
| const int child_pid = fork(); |
| if (child_pid == 0) { |
| char* shell_script_path = const_cast<char*>(shell_script_.value().c_str()); |
| char* temp_dir_path = |
| const_cast<char*>(scoped_temp_dir_.GetPath().value().c_str()); |
| char* meta_file = const_cast<char*>(details.meta_file.value().c_str()); |
| char* payload_file = |
| const_cast<char*>(details.payload_file.value().c_str()); |
| char* payload_kind = const_cast<char*>(details.payload_kind.c_str()); |
| char* exec_name = const_cast<char*>(details.exec_name.c_str()); |
| char* shell_argv[] = {shell_script_path, |
| temp_dir_path, |
| meta_file, // $1 in send_crash |
| payload_file, // $2 in send_crash |
| payload_kind, // $3 in send_crash |
| exec_name, // $4 in send_crash |
| nullptr}; |
| |
| setenv("IS_CHROMELESS_TTY", USE_CHROMELESS_TTY ? "true" : "false", |
| 1 /* overwrite */); |
| execve(shell_script_path, shell_argv, environ); |
| // execve() failed. |
| exit(EXIT_FAILURE); |
| } else { |
| int status = 0; |
| if (waitpid(child_pid, &status, 0) < 0) { |
| PLOG(ERROR) << "Failed to wait for the child process: " << child_pid; |
| return false; |
| } |
| if (!WIFEXITED(status)) { |
| LOG(ERROR) << "Terminated abnormally: " << status; |
| return false; |
| } |
| int exit_code = WEXITSTATUS(status); |
| if (exit_code != 0) { |
| LOG(ERROR) << "Terminated with non-zero exit code: " << exit_code; |
| return false; |
| } |
| } |
| |
| return true; |
| } |
| |
| } // namespace util |