[stable] Fix Process::Exec stdio handle inheritance

Issue description: dart run crashes on Windows in GitBash or similar
terminals.

What is the fix: Fix Process::Exec to account for situations when
stdout and stderr are the same handles.

Why cherry-pick: Currently dart run is completely broken on certain
terminal emulators.

Risk: Low, landed on main and tested.

Issue link(s): https://github.com/dart-lang/sdk/issues/61981

TEST=vm/dart/regress_61981

Cherry-pick: https://dart-review.googlesource.com/c/sdk/+/464382
Change-Id: I357725f8982814c02f28267a8629ce5099fe4b39
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/465560
Reviewed-by: Alexander Thomas <athom@google.com>
Commit-Queue: Slava Egorov <vegorov@google.com>
Reviewed-by: Martin Kustermann <kustermann@google.com>
diff --git a/BUILD.gn b/BUILD.gn
index 25f4b6a..6f33af7 100644
--- a/BUILD.gn
+++ b/BUILD.gn
@@ -51,8 +51,8 @@
     "samples/embedder:kernel",
     "samples/ffi/http:fake_http",
     "samples/ffi/httpIG:fake_httpIG",
-    "utils/dartdev:dartdev",
-    "utils/kernel-service:kernel-service",
+    "utils/dartdev",
+    "utils/kernel-service",
   ]
 
   # The following dependencies allow dartdev to start the resident frontend
@@ -68,8 +68,8 @@
     ]
   } else {
     deps += [
-      "utils/dds:dds",
-      "utils/dtd:dtd",
+      "utils/dds",
+      "utils/dtd",
       "utils/kernel-service:frontend_server",
     ]
   }
@@ -104,6 +104,8 @@
     deps += [ "runtime/bin:abstract_socket_test" ]
   } else if (is_fuchsia) {
     deps += [ ":fuchsia_test_package" ]
+  } else if (is_win) {
+    deps += [ "runtime/bin:create_process_test_helper" ]
   }
 }
 
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4f13164..24c539e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,9 @@
 - Fixes an issue with the analyzer not emitting an error when using a dot
   shorthand with type arguments on a factory constructor in an abstract class.
   (issue [dart-lang/sdk#61978])
+- Fixes an issue with `dart run` not working and simply exiting with
+  `Process::Exec - (null)` under GitBash on Windows.
+  (issue [dart-lang/sdk#61981])
 
 [Dart-Code/Dart-Code#61978]: https://github.com/Dart-Code/Dart-Code/issues/5810
 [dart-lang/sdk#61996]: https://github.com/dart-lang/sdk/issues/61996
@@ -23,6 +26,7 @@
 [flutter/flutter#178740]: https://github.com/flutter/flutter/issues/178740
 [dart-lang/sdk#62136]: https://github.com/dart-lang/sdk/issues/62136
 [dart-lang/sdk#61978]: https://github.com/dart-lang/sdk/issues/61978
+[dart-lang/sdk#61981]: https://github.com/dart-lang/sdk/issues/61981
 
 ## 3.10.2
 
diff --git a/runtime/bin/BUILD.gn b/runtime/bin/BUILD.gn
index b3b71b3..cfb5ede 100644
--- a/runtime/bin/BUILD.gn
+++ b/runtime/bin/BUILD.gn
@@ -1142,6 +1142,11 @@
   include_dirs = [ ".." ]
 }
 
+executable("create_process_test_helper") {
+  sources = [ "create_process_test_helper.cc" ]
+  include_dirs = [ ".." ]
+}
+
 source_set("run_vm_tests_set") {
   if (target_os == "fuchsia") {
     testonly = true
diff --git a/runtime/bin/create_process_test_helper.cc b/runtime/bin/create_process_test_helper.cc
new file mode 100644
index 0000000..5e37e03
--- /dev/null
+++ b/runtime/bin/create_process_test_helper.cc
@@ -0,0 +1,138 @@
+// Copyright (c) 2025, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+// This is a utility program for testing that Dart binary correctly handles
+// situations when stdout and stderr handles are the same.
+//
+// This can happen in certain terminal emulators, e.g. one used by GitBash
+// see https://github.com/dart-lang/sdk/issues/61981 for an example.
+
+#include "platform/globals.h"
+#if defined(DART_HOST_OS_WINDOWS)
+
+#include <cstdio>
+#include <source_location>
+#include <sstream>
+#include <vector>
+
+struct StdioHandles {
+  HANDLE in;
+  HANDLE out;
+  HANDLE err;
+};
+
+static void ReportErrorAndAbort(
+    std::source_location location = std::source_location::current()) {
+  const auto code = GetLastError();
+
+  wchar_t buffer[512];
+  auto message_size =
+      FormatMessageW(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
+                     nullptr, code, MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT),
+                     buffer, ARRAY_SIZE(buffer), nullptr);
+  if (message_size == 0) {
+    _snwprintf(buffer, ARRAY_SIZE(buffer), L"OS Error %d", code);
+  }
+  fprintf(stderr, "error at %s:%d: %ls", location.file_name(), location.line(),
+          buffer);
+  abort();
+}
+
+static void LaunchProcessWith(wchar_t* command_line,
+                              const StdioHandles& stdio_handles) {
+  fprintf(stderr, "LAUNCHING %ls\n", command_line);
+
+  // Setup info
+  STARTUPINFOEXW startup_info;
+  ZeroMemory(&startup_info, sizeof(startup_info));
+  startup_info.StartupInfo.cb = sizeof(startup_info);
+
+  // Setup the handles to inherit. We only want to inherit the three
+  // handles for stdin, stdout and stderr.
+  startup_info.StartupInfo.hStdInput = stdio_handles.in;
+  startup_info.StartupInfo.hStdOutput = stdio_handles.out;
+  startup_info.StartupInfo.hStdError = stdio_handles.err;
+  startup_info.StartupInfo.dwFlags = STARTF_USESTDHANDLES;
+  SIZE_T size = 0;
+  // The call to determine the size of an attribute list always fails with
+  // ERROR_INSUFFICIENT_BUFFER and that error should be ignored.
+  if (!InitializeProcThreadAttributeList(nullptr, 1, 0, &size) &&
+      (GetLastError() != ERROR_INSUFFICIENT_BUFFER)) {
+    return ReportErrorAndAbort();
+  }
+  auto attribute_list =
+      reinterpret_cast<LPPROC_THREAD_ATTRIBUTE_LIST>(malloc(size));
+  ZeroMemory(attribute_list, size);
+  if (!InitializeProcThreadAttributeList(attribute_list, 1, 0, &size)) {
+    return ReportErrorAndAbort();
+  }
+  std::vector<HANDLE> inherited_handles = {stdio_handles.in};
+  if (stdio_handles.out != stdio_handles.in) {
+    inherited_handles.push_back(stdio_handles.out);
+  }
+  if (stdio_handles.err != stdio_handles.out &&
+      stdio_handles.err != stdio_handles.in) {
+    inherited_handles.push_back(stdio_handles.err);
+  }
+  if (!UpdateProcThreadAttribute(
+          attribute_list, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST,
+          inherited_handles.data(), inherited_handles.size() * sizeof(HANDLE),
+          nullptr, nullptr)) {
+    return ReportErrorAndAbort();
+  }
+  startup_info.lpAttributeList = attribute_list;
+
+  PROCESS_INFORMATION process_info;
+  ZeroMemory(&process_info, sizeof(process_info));
+
+  // Create process.
+  BOOL result = CreateProcessW(
+      /*lpApplicationName=*/nullptr, command_line,
+      /*lpProcessAttributes=*/nullptr,
+      /*lpThreadAttributes=*/nullptr,
+      /*bInheritHandles=*/TRUE,
+      /*dwCreationFlags=*/EXTENDED_STARTUPINFO_PRESENT,
+      /*lpEnvironment=*/nullptr,
+      /*lpCurrentDirectory=*/nullptr,
+      reinterpret_cast<STARTUPINFOW*>(&startup_info), &process_info);
+
+  if (result == 0) {
+    return ReportErrorAndAbort();
+  }
+
+  WaitForSingleObject(process_info.hProcess, INFINITE);
+  CloseHandle(process_info.hProcess);
+  CloseHandle(process_info.hThread);
+}
+
+int main(int argc, char* argv[]) {
+  if (argc <= 1) {
+    fprintf(stderr, "Usage: %s <executable> <arg0> ... <argN>\n", argv[0]);
+    return -1;
+  }
+
+  // Generate command line. Assume that it does not contain any white-space
+  // in the arguments.
+  std::wstringstream wstr;
+  for (int i = 1; i < argc; i++) {
+    wstr << (i > 1 ? " " : "") << argv[i];
+  }
+
+  HANDLE stdin_handle = GetStdHandle(STD_INPUT_HANDLE);
+  HANDLE stdout_handle = GetStdHandle(STD_OUTPUT_HANDLE);
+  LaunchProcessWith(
+      wstr.str().data(),
+      {.in = stdin_handle, .out = stdout_handle, .err = stdout_handle});
+  CloseHandle(stdin_handle);
+  CloseHandle(stdout_handle);
+  return 0;
+}
+
+#else
+
+int main() {
+  return -1;
+}
+
+#endif  // defined(DART_HOST_OS_WINDOWS)
diff --git a/runtime/bin/process.cc b/runtime/bin/process.cc
index 027734d..4691823 100644
--- a/runtime/bin/process.cc
+++ b/runtime/bin/process.cc
@@ -155,7 +155,7 @@
   Dart_Handle stderr_handle = Dart_GetNativeArgument(args, 9);
   Dart_Handle exit_handle = Dart_GetNativeArgument(args, 10);
   intptr_t pid = -1;
-  char* os_error_message = nullptr;  // Scope allocated by Process::Start.
+  char* os_error_message = nullptr;
 
   int error_code = Process::Start(
       namespc, path, string_args, args_length, working_directory,
diff --git a/runtime/bin/process_win.cc b/runtime/bin/process_win.cc
index fc61eba..635e8d9 100644
--- a/runtime/bin/process_win.cc
+++ b/runtime/bin/process_win.cc
@@ -9,6 +9,7 @@
 
 #include <process.h>  // NOLINT
 #include <psapi.h>    // NOLINT
+#include <source_location>
 #include <vector>
 
 #include "bin/builtin.h"
@@ -294,12 +295,15 @@
   CloseProcessPipe(handles4);
 }
 
-static int SetOsErrorMessage(char** os_error_message) {
+static int SetOsErrorMessage(
+    CStringUniquePtr* os_error_message,
+    std::source_location location = std::source_location::current()) {
   int error_code = GetLastError();
   const int kMaxMessageLength = 256;
   wchar_t message[kMaxMessageLength];
   FormatMessageIntoBuffer(error_code, message, kMaxMessageLength);
-  *os_error_message = StringUtilsWin::WideToUtf8(message);
+  os_error_message->reset(Utils::SCreate(
+      "%ls (at %s:%d)", message, location.file_name(), location.line()));
   return error_code;
 }
 
@@ -356,7 +360,7 @@
                  intptr_t* err,
                  intptr_t* id,
                  intptr_t* exit_handler,
-                 char** os_error_message)
+                 CStringUniquePtr* os_error_message)
       : path_(path),
         working_directory_(working_directory),
         mode_(mode),
@@ -617,7 +621,13 @@
     if (!InitializeProcThreadAttributeList(attribute_list_, 1, 0, &size)) {
       return CleanupAndReturnError();
     }
-    inherited_handles_ = {stdin_handle, stdout_handle, stderr_handle};
+    inherited_handles_ = {stdin_handle};
+    if (stdout_handle != stdin_handle) {
+      inherited_handles_.push_back(stdout_handle);
+    }
+    if (stderr_handle != stdout_handle && stderr_handle != stdin_handle) {
+      inherited_handles_.push_back(stderr_handle);
+    }
     if (!UpdateProcThreadAttribute(
             attribute_list_, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST,
             inherited_handles_.data(),
@@ -707,8 +717,9 @@
     return 0;
   }
 
-  int CleanupAndReturnError() {
-    int error_code = SetOsErrorMessage(os_error_message_);
+  int CleanupAndReturnError(
+      std::source_location location = std::source_location::current()) {
+    int error_code = SetOsErrorMessage(os_error_message_, location);
     CloseProcessPipes(stdin_handles_, stdout_handles_, stderr_handles_,
                       exit_handles_);
     return error_code;
@@ -734,7 +745,7 @@
   intptr_t* err_;
   intptr_t* id_;
   intptr_t* exit_handler_;
-  char** os_error_message_;
+  CStringUniquePtr* os_error_message_;
 
  private:
   DISALLOW_ALLOCATION();
@@ -755,10 +766,15 @@
                    intptr_t* id,
                    intptr_t* exit_handler,
                    char** os_error_message) {
+  CStringUniquePtr error;
   ProcessStarter starter(path, arguments, arguments_length, working_directory,
                          environment, environment_length, mode, in, out, err,
-                         id, exit_handler, os_error_message);
-  return starter.Start();
+                         id, exit_handler, &error);
+  const auto result = starter.Start();
+  if (result != 0 && error != nullptr) {
+    *os_error_message = DartUtils::ScopedCopyCString(error.get());
+  }
+  return result;
 }
 
 class BufferList : public BufferListBase {
@@ -981,7 +997,7 @@
   HANDLE hjob = CreateJobObject(nullptr, nullptr);
   if (hjob == nullptr) {
     BufferFormatter f(errmsg, errmsg_len);
-    f.Printf("Process::Exec - CreateJobObject failed %d\n", GetLastError());
+    f.Printf("Process::Exec - CreateJobObject failed %d", GetLastError());
     return -1;
   }
   JOBOBJECT_EXTENDED_LIMIT_INFORMATION info;
@@ -991,7 +1007,7 @@
                                  sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION),
                                  &qresult)) {
     BufferFormatter f(errmsg, errmsg_len);
-    f.Printf("Process::Exec - QueryInformationJobObject failed %d\n",
+    f.Printf("Process::Exec - QueryInformationJobObject failed %d",
              GetLastError());
     return -1;
   }
@@ -1005,7 +1021,7 @@
   if (!SetInformationJobObject(hjob, JobObjectExtendedLimitInformation, &info,
                                sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION))) {
     BufferFormatter f(errmsg, errmsg_len);
-    f.Printf("Process::Exec - SetInformationJobObject failed %d\n",
+    f.Printf("Process::Exec - SetInformationJobObject failed %d",
              GetLastError());
     return -1;
   }
@@ -1015,7 +1031,7 @@
   // we haven't spawned any children yet this race is harmless)
   if (!AssignProcessToJobObject(hjob, GetCurrentProcess())) {
     BufferFormatter f(errmsg, errmsg_len);
-    f.Printf("Process::Exec - AssignProcessToJobObject failed %d\n",
+    f.Printf("Process::Exec - AssignProcessToJobObject failed %d",
              GetLastError());
     return -1;
   }
@@ -1028,14 +1044,14 @@
   // as the value passed in 'path', we strip that off when starting the
   // process.
   intptr_t pid = -1;
-  char* os_error_message = nullptr;  // Scope allocated by Process::Start.
+  CStringUniquePtr os_error_message;
   ProcessStarter starter(path, &(arguments[1]), (arguments_length - 1),
                          working_directory, nullptr, 0, kInheritStdio, nullptr,
                          nullptr, nullptr, &pid, nullptr, &os_error_message);
   int result = starter.StartForExec(hjob);
   if (result != 0) {
     BufferFormatter f(errmsg, errmsg_len);
-    f.Printf("Process::Exec - %s\n", os_error_message);
+    f.Printf("ProcessStarter::StartForExec failed: %s", os_error_message.get());
     return -1;
   }
 
@@ -1047,14 +1063,14 @@
   DWORD wait_result = WaitForSingleObject(child_process, INFINITE);
   if (wait_result != WAIT_OBJECT_0) {
     BufferFormatter f(errmsg, errmsg_len);
-    f.Printf("Process::Exec - WaitForSingleObject failed %d\n", GetLastError());
+    f.Printf("Process::Exec - WaitForSingleObject failed %d", GetLastError());
     CloseHandle(child_process);
     return -1;
   }
   int retval;
   if (!GetExitCodeProcess(child_process, reinterpret_cast<DWORD*>(&retval))) {
     BufferFormatter f(errmsg, errmsg_len);
-    f.Printf("Process::Exec - GetExitCodeProcess failed %d\n", GetLastError());
+    f.Printf("Process::Exec - GetExitCodeProcess failed %d", GetLastError());
     CloseHandle(child_process);
     return -1;
   }
diff --git a/runtime/bin/utils_win.cc b/runtime/bin/utils_win.cc
index c73d972..7a0217e 100644
--- a/runtime/bin/utils_win.cc
+++ b/runtime/bin/utils_win.cc
@@ -46,9 +46,17 @@
                        code, GetLastError());
     }
     _snwprintf(buffer, buffer_length, L"OS Error %d", code);
+    return;
   }
-  // Ensure string termination.
-  buffer[buffer_length - 1] = 0;
+
+  // Strip trailing whitespace and a dot.
+  while (message_size > 0 && iswspace(buffer[message_size - 1])) {
+    message_size--;
+  }
+  if (message_size > 0 && buffer[message_size - 1] == L'.') {
+    message_size--;
+  }
+  buffer[Utils::Minimum<int>(message_size, buffer_length - 1)] = 0;
 }
 
 FILETIME GetFiletimeFromMillis(int64_t millis) {
diff --git a/runtime/tests/vm/dart/regress_61981_test.dart b/runtime/tests/vm/dart/regress_61981_test.dart
new file mode 100644
index 0000000..2fa8b76
--- /dev/null
+++ b/runtime/tests/vm/dart/regress_61981_test.dart
@@ -0,0 +1,44 @@
+// Copyright (c) 2025, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+// Test that Dart binary correctly handles situations when stdout and stderr
+// handles are the same.
+//
+// This can happen in certain terminal emulators, e.g. one used by GitBash
+// see https://github.com/dart-lang/sdk/issues/61981 for an example.
+
+import 'dart:io';
+
+import 'package:expect/expect.dart';
+import 'package:path/path.dart' as p;
+
+void main(List<String> args) {
+  if (args case ['child-process']) {
+    print('OK');
+    return;
+  }
+
+  if (!Platform.isWindows ||
+      p.basenameWithoutExtension(Platform.executable) != 'dart') {
+    return;
+  }
+
+  final createProcessHelper = p.join(
+    p.dirname(Platform.executable),
+    'create_process_test_helper.exe',
+  );
+
+  final result = Process.runSync(createProcessHelper, [
+    Platform.executable,
+    'run',
+    Platform.script.toFilePath(),
+    'child-process',
+  ]);
+  if (result.exitCode != 0) {
+    print(result.stdout);
+    print(result.stderr);
+    Expect.fail('process exited with ${result.exitCode}');
+  }
+  Expect.equals('OK', (result.stdout as String).trim());
+}