tp: publish completed bundles atomically

Write bundles through a base AtomicFile helper and publish with Commit()
&& only after the archive is complete and flushed. Preserve existing
output on failure, reject identical input/output files, and clean up
temporary files on ordinary failures without changing signal or
subprocess handling.
diff --git a/Android.bp b/Android.bp
index e7e6672..62233d8 100644
--- a/Android.bp
+++ b/Android.bp
@@ -15936,6 +15936,7 @@
     name: "perfetto_src_base_base",
     srcs: [
         "src/base/android_utils.cc",
+        "src/base/atomic_file.cc",
         "src/base/base64.cc",
         "src/base/cpu_info.cc",
         "src/base/crash_keys.cc",
@@ -16052,6 +16053,7 @@
 filegroup {
     name: "perfetto_src_base_unittests",
     srcs: [
+        "src/base/atomic_file_unittest.cc",
         "src/base/base64_unittest.cc",
         "src/base/bits_unittest.cc",
         "src/base/circular_queue_unittest.cc",
diff --git a/BUILD b/BUILD
index 6e664d7..3addae2 100644
--- a/BUILD
+++ b/BUILD
@@ -1405,6 +1405,7 @@
     name = "include_perfetto_ext_base_base",
     srcs = [
         "include/perfetto/ext/base/android_utils.h",
+        "include/perfetto/ext/base/atomic_file.h",
         "include/perfetto/ext/base/base64.h",
         "include/perfetto/ext/base/bits.h",
         "include/perfetto/ext/base/circular_queue.h",
@@ -1962,6 +1963,7 @@
     srcs = [
         ":src_base_check_cpu_optimizations",
         "src/base/android_utils.cc",
+        "src/base/atomic_file.cc",
         "src/base/base64.cc",
         "src/base/cpu_info.cc",
         "src/base/crash_keys.cc",
diff --git a/docs/learning-more/symbolization.md b/docs/learning-more/symbolization.md
index f0eeaf1..2eaa528 100644
--- a/docs/learning-more/symbolization.md
+++ b/docs/learning-more/symbolization.md
@@ -122,7 +122,7 @@
 suppress it when running in a terminal too.
 
 See the [bundle command reference](/docs/reference/trace-processor-cli.md#subcommand-bundle)
-for option semantics, color controls, and exit status.
+for option semantics, color controls, output replacement, and exit status.
 
 ### {#option-2-legacy-traceconv-symbolize-deobfuscate} Option 2: Legacy `trace_processor util symbolize` / `util deobfuscate`
 
diff --git a/docs/reference/trace-processor-cli.md b/docs/reference/trace-processor-cli.md
index 5c90ab6..5b31de0 100644
--- a/docs/reference/trace-processor-cli.md
+++ b/docs/reference/trace-processor-cli.md
@@ -293,9 +293,13 @@
 
 | Argument | Meaning |
 | --- | --- |
-| `input` | Input trace file path. Stdin is not supported. |
+| `input` | Existing regular trace file. Stdin is not supported. |
 | `output` | Destination file path. Stdout is not supported. Its parent directory must exist and be writable. |
 
+Input and output must refer to different files, including through hard links.
+An existing output must be a regular file. Output symlinks are rejected; specify
+the target path directly.
+
 #### Options
 
 - `--symbol-paths PATH1,PATH2,...`: additional directories to search for native
@@ -350,6 +354,18 @@
 directories containing the matching unstripped or debug binaries rather than
 mixing stripped and unstripped copies. Use `--verbose` to inspect lookup details.
 
+#### Output replacement and cleanup
+
+The command writes a temporary file beside the destination. It replaces the
+destination only after successfully writing and flushing the complete bundle.
+An existing output is preserved if reading, enrichment, or writing fails.
+
+Ordinary failures remove the temporary file on cleanup. Abrupt termination,
+including Ctrl-C, or a cleanup failure can leave a sibling file named
+`<output>.tmp.<uuid>`. Cleanup is best effort; incomplete data is never published
+as the destination. A leftover temporary file can be deleted once the process
+has stopped.
+
 #### Exit status
 
 A successfully written bundle exits with status 0, including when some symbols
diff --git a/include/perfetto/ext/base/BUILD.gn b/include/perfetto/ext/base/BUILD.gn
index 9fc0876..3953a66 100644
--- a/include/perfetto/ext/base/BUILD.gn
+++ b/include/perfetto/ext/base/BUILD.gn
@@ -17,6 +17,7 @@
 source_set("base") {
   sources = [
     "android_utils.h",
+    "atomic_file.h",
     "base64.h",
     "bits.h",
     "circular_queue.h",
diff --git a/include/perfetto/ext/base/atomic_file.h b/include/perfetto/ext/base/atomic_file.h
new file mode 100644
index 0000000..7f3aadc
--- /dev/null
+++ b/include/perfetto/ext/base/atomic_file.h
@@ -0,0 +1,52 @@
+/*
+ * Copyright (C) 2026 The Android Open Source Project
+ *
+ * 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.
+ */
+
+#ifndef INCLUDE_PERFETTO_EXT_BASE_ATOMIC_FILE_H_
+#define INCLUDE_PERFETTO_EXT_BASE_ATOMIC_FILE_H_
+
+#include <string>
+#include "perfetto/base/status.h"
+#include "perfetto/ext/base/scoped_file.h"
+
+namespace perfetto::base {
+
+// Writes a sibling temporary file and atomically replaces the destination on
+// Commit(). Until then an existing destination is untouched. Does not follow
+// destination symlinks. An uncommitted temporary file is deleted on
+// destruction.
+class AtomicFile {
+ public:
+  explicit AtomicFile(const std::string& destination);
+  ~AtomicFile();
+  AtomicFile(const AtomicFile&) = delete;
+  AtomicFile& operator=(const AtomicFile&) = delete;
+
+  Status Open();
+  ScopedFile DuplicateFD() const;
+  // Final operation: close all DuplicateFD() handles before committing.
+  // On failure, destruction still attempts to remove the temporary file.
+  Status Commit() &&;
+  const std::string& temp_path() const { return temp_path_; }
+
+ private:
+  const std::string destination_;
+  const std::string temp_path_;
+  ScopedFile fd_;
+  bool owns_temp_file_ = false;
+};
+
+}  // namespace perfetto::base
+#endif  // INCLUDE_PERFETTO_EXT_BASE_ATOMIC_FILE_H_
diff --git a/include/perfetto/ext/base/file_utils.h b/include/perfetto/ext/base/file_utils.h
index a5a8353..ca8bc01 100644
--- a/include/perfetto/ext/base/file_utils.h
+++ b/include/perfetto/ext/base/file_utils.h
@@ -104,6 +104,11 @@
 // Duplicates an open descriptor. The duplicate is not inherited across exec.
 ScopedFile DupFile(int fd);
 
+// These queries follow symbolic links. False also means a path could not be
+// inspected. IsSameFile compares file identities, so it detects hard links.
+bool IsRegularFile(const std::string& path);
+bool IsSameFile(const std::string& first, const std::string& second);
+
 // Moves the file offset to |offset| bytes from the beginning of the file.
 // Returns false if |offset| cannot be represented by the platform or the seek
 // fails.
diff --git a/src/base/BUILD.gn b/src/base/BUILD.gn
index 8866d32..a5b4920 100644
--- a/src/base/BUILD.gn
+++ b/src/base/BUILD.gn
@@ -35,6 +35,7 @@
   ]
   sources = [
     "android_utils.cc",
+    "atomic_file.cc",
     "base64.cc",
     "cpu_info.cc",
     "crash_keys.cc",
@@ -228,6 +229,7 @@
   }
 
   sources = [
+    "atomic_file_unittest.cc",
     "base64_unittest.cc",
     "bits_unittest.cc",
     "circular_queue_unittest.cc",
diff --git a/src/base/atomic_file.cc b/src/base/atomic_file.cc
new file mode 100644
index 0000000..88501fc
--- /dev/null
+++ b/src/base/atomic_file.cc
@@ -0,0 +1,115 @@
+/*
+ * Copyright (C) 2026 The Android Open Source Project
+ *
+ * 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 "perfetto/ext/base/atomic_file.h"
+
+#include <cerrno>
+#include <cstdio>
+#include <cstring>
+
+#include "perfetto/base/build_config.h"
+#include "perfetto/base/compiler.h"
+#include "perfetto/base/logging.h"
+#include "perfetto/ext/base/file_utils.h"
+#include "perfetto/ext/base/uuid.h"
+
+#if PERFETTO_BUILDFLAG(PERFETTO_OS_WIN)
+#include <io.h>
+#include <windows.h>
+#else
+#include <sys/stat.h>
+#include <unistd.h>
+#endif
+
+namespace perfetto::base {
+
+AtomicFile::AtomicFile(const std::string& destination)
+    : destination_(destination),
+      temp_path_(destination + ".tmp." + Uuidv4().ToPrettyString()) {}
+
+AtomicFile::~AtomicFile() {
+  fd_.reset();
+  if (owns_temp_file_)
+    Unlink(temp_path_.c_str());
+}
+
+base::Status AtomicFile::Open() {
+  PERFETTO_CHECK(!fd_ && !owns_temp_file_);
+#if PERFETTO_BUILDFLAG(PERFETTO_OS_WIN)
+  DWORD attributes = GetFileAttributesA(destination_.c_str());
+  if (attributes != INVALID_FILE_ATTRIBUTES &&
+      (attributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)))
+    return base::ErrStatus(
+        "output must be a regular file, not a directory or link");
+  HANDLE handle =
+      CreateFileA(temp_path_.c_str(), GENERIC_READ | GENERIC_WRITE,
+                  FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
+                  nullptr, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, nullptr);
+  if (handle == INVALID_HANDLE_VALUE)
+    return base::ErrStatus(
+        "cannot create temporary output beside '%s' (Windows error %lu)",
+        destination_.c_str(), GetLastError());
+  owns_temp_file_ = true;
+  fd_.reset(_open_osfhandle(reinterpret_cast<intptr_t>(handle), _O_BINARY));
+  if (!fd_)
+    CloseHandle(handle);
+#else
+  struct stat output{};
+  bool exists = lstat(destination_.c_str(), &output) == 0;
+  PERFETTO_MSAN_UNPOISON(&output, sizeof(output));
+  if (exists && !S_ISREG(output.st_mode))
+    return base::ErrStatus(
+        "output must be a regular file, not a directory or link: '%s'. Specify "
+        "the target path directly.",
+        destination_.c_str());
+  fd_ = base::OpenFile(temp_path_, O_CREAT | O_EXCL | O_RDWR, 0644);
+  owns_temp_file_ = static_cast<bool>(fd_);
+  if (fd_ && exists && fchmod(*fd_, output.st_mode & 0777) != 0)
+    return base::ErrStatus("cannot preserve output permissions: %s",
+                           strerror(errno));
+#endif
+  if (!fd_)
+    return base::ErrStatus(
+        "cannot create temporary output beside '%s': %s. Check that the parent "
+        "directory exists and is writable.",
+        destination_.c_str(), strerror(errno));
+  return base::OkStatus();
+}
+
+base::ScopedFile AtomicFile::DuplicateFD() const {
+  PERFETTO_CHECK(fd_);
+  return DupFile(*fd_);
+}
+
+base::Status AtomicFile::Commit() && {
+  PERFETTO_CHECK(fd_);
+  if (!base::FlushFile(*fd_))
+    return base::ErrStatus("failed to flush output: %s", strerror(errno));
+  fd_.reset();
+#if PERFETTO_BUILDFLAG(PERFETTO_OS_WIN)
+  if (!MoveFileExA(temp_path_.c_str(), destination_.c_str(),
+                   MOVEFILE_REPLACE_EXISTING))
+    return base::ErrStatus("failed to publish '%s' (Windows error %lu)",
+                           destination_.c_str(), GetLastError());
+#else
+  if (rename(temp_path_.c_str(), destination_.c_str()) != 0)
+    return base::ErrStatus("failed to publish '%s': %s", destination_.c_str(),
+                           strerror(errno));
+#endif
+  owns_temp_file_ = false;
+  return base::OkStatus();
+}
+}  // namespace perfetto::base
diff --git a/src/base/atomic_file_unittest.cc b/src/base/atomic_file_unittest.cc
new file mode 100644
index 0000000..eeab51f
--- /dev/null
+++ b/src/base/atomic_file_unittest.cc
@@ -0,0 +1,106 @@
+/*
+ * Copyright (C) 2026 The Android Open Source Project
+ *
+ * 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 "perfetto/ext/base/atomic_file.h"
+#include <utility>
+
+#include "perfetto/ext/base/file_utils.h"
+#include "perfetto/ext/base/temp_file.h"
+#include "test/gtest_and_gmock.h"
+
+namespace perfetto::base {
+namespace {
+std::string Contents(const std::string& path) {
+  std::string contents;
+  EXPECT_TRUE(ReadFile(path, &contents));
+  return contents;
+}
+
+TEST(AtomicFileTest, PreservesDestinationUntilCommit) {
+  auto destination = TempFile::Create();
+  ASSERT_EQ(WriteAll(destination.fd(), "old", 3), 3);
+  std::string temporary;
+  {
+    AtomicFile output(destination.path());
+    ASSERT_TRUE(output.Open().ok());
+    temporary = output.temp_path();
+    auto fd = output.DuplicateFD();
+    ASSERT_TRUE(fd);
+    ASSERT_EQ(WriteAll(*fd, "new", 3), 3);
+    fd.reset();
+    EXPECT_EQ(Contents(destination.path()), "old");
+    ASSERT_TRUE(std::move(output).Commit().ok());
+    EXPECT_EQ(Contents(destination.path()), "new");
+  }
+  EXPECT_FALSE(FileExists(temporary));
+  EXPECT_EQ(Contents(destination.path()), "new");
+}
+
+TEST(AtomicFileTest, AbandonPreservesDestinationAndDeletesTemporaryFile) {
+  auto destination = TempFile::Create();
+  ASSERT_EQ(WriteAll(destination.fd(), "old", 3), 3);
+  std::string temporary;
+  {
+    AtomicFile output(destination.path());
+    ASSERT_TRUE(output.Open().ok());
+    temporary = output.temp_path();
+    auto fd = output.DuplicateFD();
+    ASSERT_EQ(WriteAll(*fd, "incomplete", 10), 10);
+  }
+  EXPECT_FALSE(FileExists(temporary));
+  EXPECT_EQ(Contents(destination.path()), "old");
+}
+
+TEST(AtomicFileTest, FailedCommitDeletesTemporaryFile) {
+  auto directory = TempDir::Create();
+  std::string path = directory.path() + "/output";
+  std::string temporary;
+  {
+    AtomicFile output(path);
+    ASSERT_TRUE(output.Open().ok());
+    temporary = output.temp_path();
+    ASSERT_TRUE(Mkdir(path));
+    EXPECT_FALSE(std::move(output).Commit().ok());
+  }
+  EXPECT_TRUE(DirectoryExists(path));
+  EXPECT_FALSE(FileExists(temporary));
+  ASSERT_TRUE(Rmdir(path));
+}
+
+TEST(AtomicFileTest, FailedOpenDoesNotDeleteUnownedFile) {
+  auto directory = TempDir::Create();
+  std::string temporary;
+  {
+    AtomicFile output(directory.path() + "/output");
+    temporary = output.temp_path();
+    auto other = OpenFile(temporary, O_CREAT | O_EXCL | O_WRONLY, 0600);
+    ASSERT_TRUE(other);
+    ASSERT_EQ(WriteAll(*other, "other", 5), 5);
+    EXPECT_FALSE(output.Open().ok());
+  }
+  EXPECT_EQ(Contents(temporary), "other");
+  EXPECT_TRUE(Unlink(temporary.c_str()));
+}
+
+TEST(AtomicFileTest, InvalidParentFailsWithoutCreatingDestination) {
+  auto directory = TempDir::Create();
+  std::string path = directory.path() + "/missing/output";
+  AtomicFile output(path);
+  EXPECT_FALSE(output.Open().ok());
+  EXPECT_FALSE(FileExists(path));
+}
+}  // namespace
+}  // namespace perfetto::base
diff --git a/src/base/file_utils.cc b/src/base/file_utils.cc
index a0d8521..79a9859 100644
--- a/src/base/file_utils.cc
+++ b/src/base/file_utils.cc
@@ -279,6 +279,38 @@
 #endif
 }
 
+bool IsRegularFile(const std::string& path) {
+  struct stat st{};
+  if (stat(path.c_str(), &st) != 0)
+    return false;
+  PERFETTO_MSAN_UNPOISON(&st, sizeof(st));
+  return (st.st_mode & S_IFMT) == S_IFREG;
+}
+
+bool IsSameFile(const std::string& first, const std::string& second) {
+#if PERFETTO_BUILDFLAG(PERFETTO_OS_WIN)
+  auto identity = [](const std::string& path,
+                     BY_HANDLE_FILE_INFORMATION* info) {
+    ScopedPlatformHandle file(CreateFileA(
+        path.c_str(), 0, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
+        nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr));
+    return file && GetFileInformationByHandle(*file, info);
+  };
+  BY_HANDLE_FILE_INFORMATION a{}, b{};
+  return identity(first, &a) && identity(second, &b) &&
+         a.dwVolumeSerialNumber == b.dwVolumeSerialNumber &&
+         a.nFileIndexHigh == b.nFileIndexHigh &&
+         a.nFileIndexLow == b.nFileIndexLow;
+#else
+  struct stat a{}, b{};
+  if (stat(first.c_str(), &a) != 0 || stat(second.c_str(), &b) != 0)
+    return false;
+  PERFETTO_MSAN_UNPOISON(&a, sizeof(a));
+  PERFETTO_MSAN_UNPOISON(&b, sizeof(b));
+  return a.st_dev == b.st_dev && a.st_ino == b.st_ino;
+#endif
+}
+
 bool SeekFile(int fd, uint64_t offset) {
 #if PERFETTO_BUILDFLAG(PERFETTO_OS_WIN)
   if (fd < 0) {
diff --git a/src/trace_processor/shell/bundle_integrationtest.cc b/src/trace_processor/shell/bundle_integrationtest.cc
index 0756714..41d6779 100644
--- a/src/trace_processor/shell/bundle_integrationtest.cc
+++ b/src/trace_processor/shell/bundle_integrationtest.cc
@@ -638,7 +638,54 @@
   EXPECT_NE(invoker.Run(), 0);
 }
 
+TEST_F(TraceconvShellBundleTest, BundleFailurePreservesExistingOutput) {
+  base::TempFile trace = WriteTempFile("");
+  base::TempFile destination = WriteTempFile("existing output");
+  ArgvInvoker invoker;
+  invoker.Add("trace_processor_shell");
+  invoker.Add("bundle");
+  invoker.Add("--no-progress");
+  invoker.Add("--no-auto-symbol-paths");
+  invoker.Add("--proguard-map");
+  invoker.Add(temp_dir_.path() + "/missing.map");
+  invoker.Add(trace.path());
+  invoker.Add(destination.path());
+  EXPECT_NE(invoker.Run(), 0);
+  std::string contents;
+  ASSERT_TRUE(base::ReadFile(destination.path(), &contents));
+  EXPECT_EQ(contents, "existing output");
+}
+
+TEST_F(TraceconvShellBundleTest, BundleRejectsSameInputAndOutput) {
+  base::TempFile trace = WriteTempFile("original trace");
+  ArgvInvoker invoker;
+  invoker.Add("trace_processor_shell");
+  invoker.Add("bundle");
+  invoker.Add(trace.path());
+  invoker.Add(trace.path());
+  EXPECT_NE(invoker.Run(), 0);
+  std::string contents;
+  ASSERT_TRUE(base::ReadFile(trace.path(), &contents));
+  EXPECT_EQ(contents, "original trace");
+}
+
 #if !PERFETTO_BUILDFLAG(PERFETTO_OS_WIN)
+TEST_F(TraceconvShellBundleTest, BundleRejectsHardLinkedInputAndOutput) {
+  base::TempFile trace = WriteTempFile("original trace");
+  std::string alias = temp_dir_.path() + "/alias";
+  ASSERT_EQ(link(trace.path().c_str(), alias.c_str()), 0);
+  ArgvInvoker invoker;
+  invoker.Add("trace_processor_shell");
+  invoker.Add("bundle");
+  invoker.Add(trace.path());
+  invoker.Add(alias);
+  EXPECT_NE(invoker.Run(), 0);
+  std::string contents;
+  ASSERT_TRUE(base::ReadFile(trace.path(), &contents));
+  EXPECT_EQ(contents, "original trace");
+  ASSERT_TRUE(base::Unlink(alias.c_str()));
+}
+
 TEST_F(TraceconvShellBundleTest, RedirectedProgressIsPlainAndWarningsRemain) {
   base::TempFile trace = WriteTempFile(BuildFuncgraphTrace(false));
   for (bool no_progress : {false, true}) {
diff --git a/src/trace_processor/shell/bundle_subcommand.cc b/src/trace_processor/shell/bundle_subcommand.cc
index 2ea702b..ac530f3 100644
--- a/src/trace_processor/shell/bundle_subcommand.cc
+++ b/src/trace_processor/shell/bundle_subcommand.cc
@@ -89,6 +89,10 @@
 mappings needed to make it self-contained. Both <input> and <output> must be
 real file paths (stdin/stdout are not supported).
 
+The output is replaced only after the bundle is complete. Input and output
+must be different regular files; specify an output symlink's target directly.
+Abrupt termination may leave a temporary file beside the output.
+
 Live progress uses stderr only when it is a terminal (except TERM=dumb).
 Nonempty FORCE_COLOR forces ANSI color, overriding NO_COLOR. Otherwise,
 nonempty NO_COLOR disables automatic color. Color does not enable progress.)";
@@ -170,23 +174,6 @@
         output_file.c_str(), symlink_problem.c_str());
   }
 
-  // Fail fast on output paths that cannot be created, before spending time
-  // reading the (potentially huge) trace. The TarWriter re-opens with O_TRUNC
-  // once the trace has been read successfully.
-#if !PERFETTO_BUILDFLAG(PERFETTO_OS_WIN)
-  {
-    base::ScopedFile probe =
-        base::OpenFile(output_file, O_CREAT | O_WRONLY, 0644);
-    if (!probe) {
-      return base::ErrStatus(
-          "bundle: cannot create output file '%s' (errno: %d, %s). Check "
-          "that the parent directory exists and is writable, then try "
-          "again.",
-          output_file.c_str(), errno, strerror(errno));
-    }
-  }
-#endif
-
   trace_to_text::BundleContext context;
   if (!symbol_paths_.empty())
     context.symbol_paths = base::SplitString(symbol_paths_, ",");
diff --git a/src/traceconv/trace_to_bundle.cc b/src/traceconv/trace_to_bundle.cc
index 54634d5..d4ca9a8 100644
--- a/src/traceconv/trace_to_bundle.cc
+++ b/src/traceconv/trace_to_bundle.cc
@@ -18,11 +18,15 @@
 
 #include <cstdio>
 #include <string>
+#include <utility>
 
 #include "perfetto/base/build_config.h"
 #include "perfetto/base/logging.h"
 #include "perfetto/base/status.h"
+#include "perfetto/ext/base/atomic_file.h"
+#include "perfetto/ext/base/file_utils.h"
 #include "perfetto/ext/base/progress_reporter.h"
+#include "perfetto/ext/base/status_macros.h"
 #include "perfetto/ext/base/string_utils.h"
 #include "perfetto/trace_processor/read_trace.h"
 #include "perfetto/trace_processor/trace_processor.h"
@@ -30,10 +34,45 @@
 #include "src/trace_processor/util/trace_enrichment/trace_enrichment.h"
 
 namespace perfetto::trace_to_text {
+namespace {
+base::Status AddBundleEntries(
+    trace_processor::util::TarWriter* tar,
+    const std::string& input_file_path,
+    const trace_processor::util::EnrichmentResult& enrich_result) {
+  RETURN_IF_ERROR(tar->AddFileFromPath("trace.perfetto", input_file_path));
+  // Add symbols if available.
+  if (!enrich_result.native_symbols.empty()) {
+    auto add_status = tar->AddFile("symbols.pb", enrich_result.native_symbols);
+    if (!add_status.ok()) {
+      return base::ErrStatus("failed to add symbols to bundle: %s",
+                             add_status.c_message());
+    }
+  }
+
+  // Add deobfuscation data if available.
+  if (!enrich_result.deobfuscation_data.empty()) {
+    auto add_status =
+        tar->AddFile("deobfuscation.pb", enrich_result.deobfuscation_data);
+    if (!add_status.ok()) {
+      return base::ErrStatus("failed to add deobfuscation data to bundle: %s",
+                             add_status.c_message());
+    }
+  }
+
+  return base::OkStatus();
+}
+}  // namespace
 
 base::Status TraceToBundle(const std::string& input_file_path,
                            const std::string& output_file_path,
                            const BundleContext& context) {
+  if (!base::IsRegularFile(input_file_path))
+    return base::ErrStatus("bundle: input must be a regular file: '%s'",
+                           input_file_path.c_str());
+  if (base::IsSameFile(input_file_path, output_file_path))
+    return base::ErrStatus("bundle: input and output refer to the same file");
+  base::AtomicFile output(output_file_path);
+  RETURN_IF_ERROR(output.Open());
   base::ProgressReporter progress(!context.no_progress);
   auto tp = trace_processor::TraceProcessor::CreateInstance({});
 
@@ -51,18 +90,6 @@
     return base::ErrStatus("failed to read trace: %s", status.c_message());
   fprintf(stderr, "Read trace: %.2f MB.\n", loaded_mb);
 
-  // Add original trace file directly (memory efficient). If the output path
-  // cannot be opened, TarWriter fails gracefully and this propagates a
-  // descriptive error instead of crashing.
-  fprintf(stderr, "Adding trace to bundle...\n");
-  trace_processor::util::TarWriter tar(output_file_path);
-  auto add_trace_status =
-      tar.AddFileFromPath("trace.perfetto", input_file_path);
-  if (!add_trace_status.ok()) {
-    return base::ErrStatus("failed to create bundle: %s",
-                           add_trace_status.c_message());
-  }
-
   // Build enrichment configuration from context.
   trace_processor::util::EnrichmentConfig enrich_config;
   enrich_config.symbol_paths = context.symbol_paths;
@@ -86,25 +113,6 @@
       trace_processor::util::EnrichTrace(tp.get(), enrich_config);
   fprintf(stderr, "Enrichment done.\n");
 
-  // Add symbols if available.
-  if (!enrich_result.native_symbols.empty()) {
-    auto add_status = tar.AddFile("symbols.pb", enrich_result.native_symbols);
-    if (!add_status.ok()) {
-      return base::ErrStatus("failed to add symbols to bundle: %s",
-                             add_status.c_message());
-    }
-  }
-
-  // Add deobfuscation data if available.
-  if (!enrich_result.deobfuscation_data.empty()) {
-    auto add_status =
-        tar.AddFile("deobfuscation.pb", enrich_result.deobfuscation_data);
-    if (!add_status.ok()) {
-      return base::ErrStatus("failed to add deobfuscation data to bundle: %s",
-                             add_status.c_message());
-    }
-  }
-
   // Log any issues to stderr (without PERFETTO_LOG noise).
   if (!enrich_result.details.empty()) {
     fprintf(stderr, "%s", enrich_result.details.c_str());
@@ -124,7 +132,20 @@
         "the requested deobfuscation data.");
   }
 
-  return base::OkStatus();
+  fprintf(stderr, "Adding trace to bundle...\n");
+  {
+    auto fd = output.DuplicateFD();
+    if (!fd)
+      return base::ErrStatus("bundle: failed to duplicate output descriptor");
+    trace_processor::util::TarWriter tar(std::move(fd));
+    auto write_status = AddBundleEntries(&tar, input_file_path, enrich_result);
+    // Always finalize explicitly: the destructor otherwise crashes on an
+    // end-of-archive write failure (e.g. disk full).
+    auto finalize_status = tar.Finalize();
+    RETURN_IF_ERROR(write_status);
+    RETURN_IF_ERROR(finalize_status);
+  }
+  return std::move(output).Commit();
 }
 
 }  // namespace perfetto::trace_to_text