Skip to content

Commit 5883956

Browse files
committed
fix(diagnostics): report process startup failures
1 parent 85a1b1b commit 5883956

8 files changed

Lines changed: 163 additions & 23 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
## Non-negotiable rules
1010

1111
1. **Zero errors, zero warnings** before any commit. `cmake --build build --parallel 4` must be clean.
12-
2. **No test-count regression.** `scripts/run_component_tests.sh build` must show no failures. The verified baseline is 12,678 tests across 36 component executables and one integration executable; this floor should be raised as new tests are added and lowered only with an explicit, documented reason.
12+
2. **No test-count regression.** `scripts/run_component_tests.sh build` must show no failures. The verified baseline is 12,681 tests across 36 component executables and one integration executable; this floor should be raised as new tests are added and lowered only with an explicit, documented reason.
1313
3. **Push only to `feature/work`.** Never push to `develop` or `master`, and never create tags, without explicit per-action user approval.
1414
4. **SPDX header on every project source/header**`// SPDX-License-Identifier: MIT` + copyright + .NET attribution. Vendored sources retain their upstream headers; Markdown uses an HTML SPDX comment where one is present.
1515
5. **Property naming:** always `getXxxProperty()` / `setXxxProperty()`. Exception: indexers

NEXT.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44
# NEXT.md
55

66
*Last verified: 2026-07-25. Branch: `feature/work`. The P0 component-boundary
7-
repair, three P1 parity repairs, P1 portability revalidation, and twenty-one bounded
7+
repair, three P1 parity repairs, P1 portability revalidation, and twenty-two bounded
88
P2 API slices are complete: 41 physical modules, 90 production dependency
9-
edges, and 12,678 tests across 37 executables.*
9+
edges, and 12,681 tests across 37 executables.*
1010

1111
This is the cold-start handoff for the next working session. Keep it focused
1212
on verified facts, remaining bounded work, and commands needed to resume.
@@ -28,7 +28,7 @@ Historical session detail belongs in git history and `plan.sqlite3`.
2828
- The ten-job selective consumer matrix, including a direct
2929
`Collections.Blocking` consumer, is green. Text.Json retains its target
3030
absence and negative include-leakage assertions.
31-
- The full native baseline is a warning-free build with 12,678 passing tests
31+
- The full native baseline is a warning-free build with 12,681 passing tests
3232
across 36 component executables and one integration executable.
3333
- `TaskT<TResult>::ContinueWith` now supports both action and result-producing
3434
callbacks. It runs inline on completion; `NotOn*` and `OnlyOn*` filter the
@@ -83,6 +83,9 @@ Historical session detail belongs in git history and `plan.sqlite3`.
8383
- `ProcessStartInfo` now supplies explicit child-only environment overrides;
8484
unspecified values inherit, empty values remain empty, and invalid variable
8585
names are rejected before forking.
86+
- POSIX `Process::Start` now reports child setup and exec failures synchronously
87+
with the executable path and native error text, rather than returning a
88+
process that later exits with code 127.
8689
- MinGW-w64 GCC 14-win32/CMake 3.31.6 and Emscripten 5.0.7/CMake 3.31.6 both
8790
compile the post-modular `All` graph and selective `Text.Json` libraries.
8891
This is compile-only evidence: cross tests were deliberately disabled.
@@ -91,7 +94,7 @@ Historical session detail belongs in git history and `plan.sqlite3`.
9194
ASan/LSan ownership scenarios, including 100 continuation teardowns, pass.
9295

9396
The local `plan.sqlite3` snapshot contains 16,201 classified `task` rows and
94-
1,763 completed tickets. Ticket #1737 records the completed P0 split, tickets
97+
1,764 completed tickets. Ticket #1737 records the completed P0 split, tickets
9598
#1738/#1739 the MemoryStream and generic-continuation repairs, ticket #1740 the
9699
XML whitespace repair, #1741 the completed cross-build revalidation and
97100
`WebProxy` portability fix, #1742 focused sanitizer evidence, and #1743 the
@@ -106,7 +109,8 @@ queries, #1755 for its comparer-aware binary search, #1756 for its default
106109
range sort, #1757 for `BigInteger` bitwise operators, #1758 for signed
107110
`BigInteger` shifts, #1759 for byte-vector conversion, and #1760 for the
108111
`ImmutableList<T>` Builder core, #1761 for RFC 2152 UTF-7, and #1762 for
109-
conditional Trace writes, and #1763 for ProcessStartInfo environment overrides;
112+
conditional Trace writes, #1763 for ProcessStartInfo environment overrides, and
113+
#1764 for synchronous Process startup-failure reporting;
110114
it is git-ignored and is not part of a fresh clone.
111115

112116
## P0 completion: restore Collections isolation

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ collector, or the complete .NET platform.
77

88
The repository currently builds as 41 independently selectable CMake
99
components. The verified Linux baseline on 2026-07-25 is a warning-free build
10-
with **12,678 passing tests across 37 test executables**.
10+
with **12,681 passing tests across 37 test executables**.
1111

1212
## What is included
1313

@@ -181,7 +181,7 @@ Other platform evidence is narrower:
181181

182182
| Platform/toolchain | Verified scope |
183183
|---|---|
184-
| Linux/GCC | Current full component build and all 12,678 tests. |
184+
| Linux/GCC | Current full component build and all 12,681 tests. |
185185
| Windows/MinGW | MinGW-w64 GCC 14-win32/CMake 3.31.6 compiled the post-component `All` and selective `Text.Json` library graphs under ticket #1741. GoogleTest was not cross-built and repository CI remains Ubuntu-only. |
186186
| Emscripten | Emscripten 5.0.7/CMake 3.31.6 compiled the post-component `All` and selective `Text.Json` library graphs under ticket #1741. Tests were not cross-built or run, and some runtime APIs deliberately throw `PlatformNotSupportedException`. |
187187
| macOS/Apple Clang | Real downstream Xcode 15.4 builds drove portability fixes on 2026-07-20; this repository has no macOS job or recorded full standalone test baseline. |

docs/CMakeComponents.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ are named `SharpRuntimeTests_<Component>`; genuinely cross-module scenarios
131131
are in `SharpRuntimeIntegrationTests`. CTest also discovers every individual
132132
GoogleTest case.
133133

134-
The verified 2026-07-25 `All` baseline contains 12,678 tests across 36
134+
The verified 2026-07-25 `All` baseline contains 12,681 tests across 36
135135
component executables and one integration executable.
136136

137137
## Boundary validation and CI

modules/diagnostics/include/System/Diagnostics/Process.hpp

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,10 @@ namespace System::Diagnostics {
2828
* three static overloads), WaitForExit (blocking and timeout forms), Kill (single process and
2929
* process-tree via killpg), ExitCode, HasExited, Id, GetCurrentProcess, and optional
3030
* captured-text stdout/stderr redirection (a deliberate simplification of real .NET's
31-
* Stream-based StandardOutput/StandardError -- see getStandardOutputTextProperty). Explicitly
32-
* NOT implemented, all deliberately out of scope for this pass: process enumeration
31+
* Stream-based StandardOutput/StandardError -- see getStandardOutputTextProperty). Child
32+
* setup and exec failures are reported synchronously by Start() rather than being exposed as
33+
* a later exit code 127. The following surfaces are explicitly not implemented, all
34+
* deliberately out of scope for this pass: process enumeration
3335
* (GetProcesses/GetProcessById), memory/CPU/priority/module/thread introspection properties,
3436
* the Exited/OutputDataReceived/ErrorDataReceived event-based async I/O model, UseShellExecute
3537
* (Windows-shell-specific), and the .NET 10 Run/RunAsync/RunAndCaptureText helper family.
@@ -54,7 +56,12 @@ namespace System::Diagnostics {
5456
[[nodiscard]] const ProcessStartInfo& getStartInfoProperty() const;
5557
void setStartInfoProperty(const ProcessStartInfo& value);
5658

57-
/** @brief Starts (or restarts) the process using getStartInfoProperty(). @return true if a process resource was started. */
59+
/**
60+
* @brief Starts (or restarts) the process using getStartInfoProperty().
61+
* @return true if a process resource was started.
62+
* @throws System::InvalidOperationException if child setup or exec fails; its message
63+
* includes the executable name and native error text.
64+
*/
5865
bool Start();
5966

6067
/**

modules/diagnostics/src/System/Diagnostics/Process.cpp

Lines changed: 79 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,21 @@ namespace {
7878
::close(fd);
7979
}
8080

81+
// The child cannot throw back into the parent after fork(). A small status pipe, closed on
82+
// successful exec(), lets Start() distinguish an actual launch from the historical
83+
// "returned Process which immediately exits 127" failure mode.
84+
[[noreturn]] void reportChildStartupFailure(int statusFd, int error) noexcept {
85+
while (::write(statusFd, &error, sizeof(error)) < 0 && errno == EINTR) {}
86+
::_exit(127);
87+
}
88+
89+
void closePipe(int fds[2]) noexcept {
90+
if (fds[0] >= 0) ::close(fds[0]);
91+
if (fds[1] >= 0) ::close(fds[1]);
92+
fds[0] = -1;
93+
fds[1] = -1;
94+
}
95+
8196
}
8297

8398
void Process::reapIfNeeded(Impl& impl) {
@@ -161,6 +176,7 @@ bool Process::Start() {
161176

162177
int stdoutPipe[2] = {-1, -1};
163178
int stderrPipe[2] = {-1, -1};
179+
int startupStatusPipe[2] = {-1, -1};
164180
bool redirOut = si.getRedirectStandardOutputProperty();
165181
bool redirErr = si.getRedirectStandardErrorProperty();
166182
if (redirOut && ::pipe(stdoutPipe) != 0)
@@ -172,31 +188,87 @@ bool Process::Start() {
172188
if (redirOut) { ::close(stdoutPipe[0]); ::close(stdoutPipe[1]); }
173189
throw System::InvalidOperationException(std::string("Failed to create stderr pipe: ") + std::strerror(errno));
174190
}
191+
if (::pipe(startupStatusPipe) != 0) {
192+
closePipe(stdoutPipe);
193+
closePipe(stderrPipe);
194+
throw System::InvalidOperationException(
195+
std::string("Failed to create process startup status pipe: ") + std::strerror(errno));
196+
}
197+
int startupStatusFlags = ::fcntl(startupStatusPipe[1], F_GETFD);
198+
if (startupStatusFlags < 0 ||
199+
::fcntl(startupStatusPipe[1], F_SETFD, startupStatusFlags | FD_CLOEXEC) != 0) {
200+
const int error = errno;
201+
closePipe(stdoutPipe);
202+
closePipe(stderrPipe);
203+
closePipe(startupStatusPipe);
204+
throw System::InvalidOperationException(
205+
std::string("Failed to configure process startup status pipe: ") + std::strerror(error));
206+
}
175207

176208
pid_t pid = ::fork();
177209
if (pid < 0) {
178-
if (redirOut) { ::close(stdoutPipe[0]); ::close(stdoutPipe[1]); }
179-
if (redirErr) { ::close(stderrPipe[0]); ::close(stderrPipe[1]); }
210+
closePipe(stdoutPipe);
211+
closePipe(stderrPipe);
212+
closePipe(startupStatusPipe);
180213
throw System::InvalidOperationException(std::string("Failed to fork: ") + std::strerror(errno));
181214
}
182215

183216
if (pid == 0) {
217+
::close(startupStatusPipe[0]);
184218
// Child: put it in its own process group so Kill(entireProcessTree=true) can target the
185219
// whole tree via killpg without also signaling the parent's group.
186220
::setpgid(0, 0);
187-
if (redirOut) { ::dup2(stdoutPipe[1], STDOUT_FILENO); ::close(stdoutPipe[0]); ::close(stdoutPipe[1]); }
188-
if (redirErr) { ::dup2(stderrPipe[1], STDERR_FILENO); ::close(stderrPipe[0]); ::close(stderrPipe[1]); }
221+
if (redirOut) {
222+
if (::dup2(stdoutPipe[1], STDOUT_FILENO) < 0)
223+
reportChildStartupFailure(startupStatusPipe[1], errno);
224+
::close(stdoutPipe[0]);
225+
::close(stdoutPipe[1]);
226+
}
227+
if (redirErr) {
228+
if (::dup2(stderrPipe[1], STDERR_FILENO) < 0)
229+
reportChildStartupFailure(startupStatusPipe[1], errno);
230+
::close(stderrPipe[0]);
231+
::close(stderrPipe[1]);
232+
}
189233
if (!si.getWorkingDirectoryProperty().empty()) {
190-
if (::chdir(si.getWorkingDirectoryProperty().c_str()) != 0) ::_exit(127);
234+
if (::chdir(si.getWorkingDirectoryProperty().c_str()) != 0)
235+
reportChildStartupFailure(startupStatusPipe[1], errno);
191236
}
192237
for (const auto& [name, value] : si.getEnvironmentVariablesProperty()) {
193-
if (::setenv(name.c_str(), value.c_str(), 1) != 0) ::_exit(127);
238+
if (::setenv(name.c_str(), value.c_str(), 1) != 0)
239+
reportChildStartupFailure(startupStatusPipe[1], errno);
194240
}
195241
::execvp(argv[0], argv.data());
196-
::_exit(127); // execvp only returns on failure
242+
reportChildStartupFailure(startupStatusPipe[1], errno); // execvp only returns on failure
197243
}
198244

199245
// Parent
246+
::close(startupStatusPipe[1]);
247+
startupStatusPipe[1] = -1;
248+
int childStartupError = 0;
249+
ssize_t statusRead = 0;
250+
do {
251+
statusRead = ::read(startupStatusPipe[0], &childStartupError, sizeof(childStartupError));
252+
} while (statusRead < 0 && errno == EINTR);
253+
const int statusReadError = errno;
254+
::close(startupStatusPipe[0]);
255+
startupStatusPipe[0] = -1;
256+
if (statusRead != 0) {
257+
if (statusRead != static_cast<ssize_t>(sizeof(childStartupError))) {
258+
::kill(pid, SIGKILL);
259+
}
260+
int ignoredStatus = 0;
261+
while (::waitpid(pid, &ignoredStatus, 0) < 0 && errno == EINTR) {}
262+
closePipe(stdoutPipe);
263+
closePipe(stderrPipe);
264+
if (statusRead == static_cast<ssize_t>(sizeof(childStartupError))) {
265+
throw System::InvalidOperationException(
266+
"Failed to start process '" + si.getFileNameProperty() + "': " +
267+
std::strerror(childStartupError));
268+
}
269+
throw System::InvalidOperationException(
270+
std::string("Failed to receive process startup status: ") + std::strerror(statusReadError));
271+
}
200272
if (redirOut) {
201273
::close(stdoutPipe[1]);
202274
impl_->stdoutReader = std::thread(drainPipe, stdoutPipe[0], &impl_->stdoutText);
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// SPDX-License-Identifier: MIT
2+
// Copyright (c) Robert Vokac and contributors
3+
4+
#include <cerrno>
5+
#include <cstring>
6+
#include <string>
7+
8+
#include <gtest/gtest.h>
9+
10+
#include "System/Diagnostics/Process.hpp"
11+
#include "System/InvalidOperationException.hpp"
12+
13+
using System::Diagnostics::Process;
14+
using System::Diagnostics::ProcessStartInfo;
15+
16+
namespace {
17+
18+
void expectStartupErrorFor(const ProcessStartInfo& startInfo, const std::string& expectedPath) {
19+
try {
20+
(void)Process::Start(startInfo);
21+
FAIL() << "Process::Start unexpectedly succeeded";
22+
} catch (const System::InvalidOperationException& error) {
23+
const std::string message = error.getMessageProperty();
24+
EXPECT_NE(message.find(expectedPath), std::string::npos);
25+
EXPECT_NE(message.find(std::strerror(ENOENT)), std::string::npos);
26+
}
27+
}
28+
29+
} // namespace
30+
31+
TEST(ProcessStartupFailureTest, MissingExecutableThrowsSynchronously) {
32+
const std::string missingExecutable = "/definitely-missing-sharp-runtime-process-1764";
33+
expectStartupErrorFor(ProcessStartInfo(missingExecutable), missingExecutable);
34+
}
35+
36+
TEST(ProcessStartupFailureTest, MissingWorkingDirectoryThrowsSynchronously) {
37+
const std::string missingDirectory = "/definitely-missing-sharp-runtime-directory-1764";
38+
ProcessStartInfo startInfo("/bin/true");
39+
startInfo.setWorkingDirectoryProperty(missingDirectory);
40+
expectStartupErrorFor(startInfo, "/bin/true");
41+
}
42+
43+
TEST(ProcessStartupFailureTest, SuccessfulLaunchStillReturnsRunningProcess) {
44+
ProcessStartInfo startInfo("/bin/sh");
45+
startInfo.getArgumentListProperty().push_back("-c");
46+
startInfo.getArgumentListProperty().push_back("printf startup-ok");
47+
startInfo.setRedirectStandardOutputProperty(true);
48+
49+
Process process = Process::Start(startInfo);
50+
process.WaitForExit();
51+
52+
EXPECT_EQ(process.getExitCodeProperty(), 0);
53+
EXPECT_EQ(process.getStandardOutputTextProperty(), "startup-ok");
54+
}

plan.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Sharp Runtime plan
22

33
*Last verified: 2026-07-25 — 41 physical components, 90 direct production
4-
dependency edges, a clean native build, 12,678 passing tests across 37
4+
dependency edges, a clean native build, 12,681 passing tests across 37
55
executables, and a green ten-job selective matrix.*
66

77
Sharp Runtime is in a consumer-driven expansion phase. The original type
@@ -32,7 +32,7 @@ was never created. Neither file should be linked as current documentation.
3232
### Code and validation
3333

3434
- Native Linux/GCC build: zero errors and zero warnings.
35-
- Tests: 12,678 passing across 36 component binaries plus one integration
35+
- Tests: 12,681 passing across 36 component binaries plus one integration
3636
binary.
3737
- Component graph: 41 physical modules and 90 direct production edges.
3838
- Boundary validator: no cycles, duplicate public include paths, orphan
@@ -52,7 +52,7 @@ The 2026-07-25 local snapshot contains:
5252
| Table | State |
5353
|---|---|
5454
| `task` | 16,201 rows: 1,082 `ported`, 140 `ignore`, 14,979 legacy `ignored`; no unclassified or `tobedecided` rows |
55-
| `ticket` | 1,763 rows, all `done`; no `todo`, `doing`, `blocked`, or `needs_user` rows |
55+
| `ticket` | 1,764 rows, all `done`; no `todo`, `doing`, `blocked`, or `needs_user` rows |
5656

5757
Because `plan.sqlite3` is git-ignored, these counts describe the maintainer
5858
snapshot, not data shipped in a fresh clone.
@@ -154,6 +154,9 @@ assertion without an explicit architecture decision.
154154
- Added explicit ProcessStartInfo child environment overrides under ticket
155155
#1763. They are validated before fork, passed only to the child, and retain
156156
inherited values not explicitly overridden.
157+
- Added synchronous POSIX Process startup failure reporting under ticket #1764.
158+
Child setup and exec errors now reach Start() with the executable name and
159+
native error text instead of appearing later as exit code 127.
157160
- Added consumer-driven coverage across core, collections, IO, networking,
158161
threading/tasks, text/JSON, XML, numerics, globalization, and cryptographic
159162
hashing/random APIs.
@@ -214,7 +217,7 @@ The first consumer-driven ports after modularization added:
214217
- XML schema exception types.
215218

216219
The verified test baseline grew from 12,494 at the modularization checkpoint
217-
to 12,678.
220+
to 12,681.
218221

219222
## Candidate roadmap
220223

0 commit comments

Comments
 (0)