Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Mammoth.LiteMapper Decision Log

## Linux termination versus reaping (2026-10-15)

- .NET 10.0.13 attached non-child HasExited uses kill(pid, 0), which also observes unreaped zombies as present. Kill(entireProcessTree: true) sends signals without waiting for descendant reaping; direct-process exit and pipe EOFs do not imply that every descendant PID entry is gone.
- Test fixtures retain exact PID/kernel-start-token ownership. A same-token Linux process is terminal only for state Z/X with exactly one thread, avoiding a false pass when a zombie group leader still has live siblings. Missing/reused PIDs are no longer the owned process; malformed snapshots fail loudly. Other states and thread counts remain active or unconfirmed.
- Observe asynchronous termination within the existing outer test budget, without retrying the command or sending extra kill requests before assertions. Preserve the helper's direct-child and pipe completion contract. Zombie PID metadata reaping is outside that contract.
- Historic merge CI 38753576372 is consistent with this defect but recorded no procfs state, PPID or thread count. The zombie/reaping explanation remains strongly supported, not proven. No product specification change is needed.

## Issue #58 follow-up: benchmark smoke liveness (2026-10-15)

- Linux fixture ownership uses PID plus the exact kernel start token from procfs stat field 22. Cross-process Process.StartTime comparisons are unsuitable on Linux because .NET caches independently estimated wall-clock boot times (dotnet/runtime#108959). Non-Linux fixtures retain exact UTC start ticks. Missing processes are treated as exited; malformed accessible procfs records fail validation. Keep the orphan-alive assertion and exact identity check without a tolerance.
Expand Down
7 changes: 7 additions & 0 deletions IMPLEMENTATION_PLAN.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Mammoth.LiteMapper Implementation Plan

## Linux fixture termination observation (2026-10-15)

- Scope: user-approved test-only follow-up on develop 1489dad after merged PR #60. Preserve TestProcess kill requests, execution/cleanup deadlines, output capture, pipe checks and workflow gates. No product or performance semantics change.
- Test-first: cover a consistent procfs snapshot, exact PID/start-token ownership, terminal/nonterminal/malformed states, zombie leaders with sibling threads, and persistent active work. Keep the real killed-tree and live-orphan controls.
- Implementation: observe Linux state and thread count alongside the kernel start token; require Z/X with one thread before classifying the same process as terminated. Bound exit observation inside the existing outer test budget and report the last snapshot. Fixture cleanup must use the same distinction without waiting on an external zombie reaper.
- Validation: red-first snapshot cases, repeated focused process tests, broader affected safeguards, independent read-only review, draft PR to develop and terminal exact-head CI. No local timing benchmarks, machine settings, workflow retries, merge or publication.

## Issue #58 follow-up: smoke process deadlines (2026-10-15)

- CI follow-up: correct Linux fixture ownership with the kernel start token, cover procfs command names and malformed records with red-first parser cases, preserve every existing cleanup/liveness assertion, and rerun exact-head Windows/Linux validation. This is test infrastructure only.
Expand Down
8 changes: 8 additions & 0 deletions STATUS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Mammoth.LiteMapper Status

## Linux fixture termination observation (2026-10-15)

- User-approved follow-up starts from develop 1489dad36214161a89ecbe014954cb0fb44ae793 in a separate worktree/branch. Existing work and the merged symbols/allocation/timeout fixes are preserved.
- Scope: consistent exact-owned Linux snapshots, termination versus zombie reaping, bounded observation, persistent-active and live-sibling rejection, failure diagnostics and regression coverage. TestProcess behavior, deadlines, kill requests, output and pipes remain unchanged.
- Validation: all 34 new cases fail before implementation. The integrated 48-case process/snapshot set passes three complete local runs (144/144); broader process/release/publication safeguards pass 289/289 and allocation preservation passes 8/8, zero failures/skips. Release solution build passes with two existing transitive Pkcs audit warnings; packaging-test project builds without warnings/errors. Linux execution and final cross-platform evidence belong to exact-head draft-PR CI. No local timing benchmarks, system/security/power changes, workflow retries, merge or publication.
- Independent read-only static review accepts the snapshot indices, exact ownership, live-sibling/zero-thread rejection, bounded observation and cleanup, retained integration controls and scope with no material findings.
- Tracking: automatic approval review rejected creating a new issue because the user approved a follow-up PR while the original prohibition on issue creation remains. No issue was created and no tracker-write workaround is used.

## Issue #58 follow-up: smoke timeout diagnostics (2026-10-15)

- CI correction: b9ae890 passed full Windows validation and all six Roslyn jobs; Linux passed 1,392 cases and failed only the new orphan fixture's cross-process StartTime ownership check. Linux .NET estimates boot time separately per process, making exact converted UTC ticks unsuitable. Fixture ownership now compares the exact kernel start token on Linux and retains UTC ticks elsewhere, without weakening PID reuse protection or the orphan-alive assertion. Seven procfs parser cases fail before implementation; the integrated process set passes three complete local runs (42/42), broader safeguards pass 255/255, and the Release packaging-test project builds without warnings/errors. No test failures/skips. Linux execution awaits corrected-head CI.
Expand Down
2 changes: 1 addition & 1 deletion docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Use Mammoth.LiteMapper to declare compile-time mappings, configure conversions,

This guide describes Mammoth.LiteMapper 4.0.1. The installation examples pin that version, and the [compiling samples](../samples/) demonstrate the supported mappings. See the [4.0.1 changelog](../CHANGELOG.md#401) for the patch changes and the [migration guide](#versions-and-upgrading) for earlier releases.

Repository contributors can follow the [allocation regression validation guide](ALLOCATION_TESTS.md). Its GC isolation applies only to test processes; applications need no GC configuration for LiteMapper. Contributor process validation gives the BenchmarkDotNet smoke test a five-minute completion deadline and sample runs 60 seconds; timeout diagnostics include bounded output tails and cleanup status. These are process liveness checks, not throughput gates.
Repository contributors can follow the [allocation regression validation guide](ALLOCATION_TESTS.md). Its GC isolation applies only to test processes; applications need no GC configuration for LiteMapper. Contributor process validation gives the BenchmarkDotNet smoke test a five-minute completion deadline and sample runs 60 seconds; timeout diagnostics include bounded output tails and cleanup status. Linux fixture checks distinguish process termination from zombie reaping and reject a dead leader with live sibling threads. These are process liveness checks, not throughput gates.

Runnable examples:

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
using System.IO;
using Mammoth.LiteMapper.ProcessFixture;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace Mammoth.LiteMapper.Packaging.Tests
{
[TestClass]
public sealed class FixtureProcessObservationTests
{
[TestMethod]
public void LinuxSnapshotPreservesIdentityAndLifecycleFieldsAfterTheCompleteCommandName()
{
var snapshot = FixtureProcessIdentity.ParseLinuxSnapshot(
BuildStat("123", "fixture ) with (parentheses)", "S", "0", "7", "0"));

Assert.AreEqual(123, snapshot.ProcessId);
Assert.AreEqual(0, snapshot.ParentProcessId);
Assert.AreEqual('S', snapshot.State);
Assert.AreEqual(7, snapshot.ThreadCount);
Assert.AreEqual(0L, snapshot.StartStamp);
Assert.IsTrue(snapshot.IsAliveFor(123, 0L));
}

[TestMethod]
[DataRow("Z")]
[DataRow("X")]
public void TerminalSingleThreadLinuxSnapshotsAreNotAlive(string state)
{
var snapshot = ParseSnapshot(state, "1");

Assert.IsFalse(snapshot.IsAliveFor(123, 987654L), "A terminal process with no sibling threads cannot still execute fixture work.");
}

[TestMethod]
[DataRow("Z")]
[DataRow("X")]
public void TerminalLeaderWithSiblingThreadsRemainsConservativelyAlive(string state)
{
var snapshot = ParseSnapshot(state, "2");

Assert.IsTrue(snapshot.IsAliveFor(123, 987654L), "A terminal leader can retain sibling threads that still own inherited fixture handles.");
}

[TestMethod]
[DataRow("R")]
[DataRow("S")]
[DataRow("D")]
[DataRow("T")]
[DataRow("t")]
[DataRow("W")]
[DataRow("x")]
[DataRow("K")]
[DataRow("P")]
[DataRow("I")]
public void NonterminalLinuxStatesRemainAlive(string state)
{
var snapshot = ParseSnapshot(state, "1");

Assert.IsTrue(snapshot.IsAliveFor(123, 987654L), "Only uppercase Z and X provide confirmed terminal observations.");
}

[TestMethod]
[DataRow("Z")]
[DataRow("X")]
public void ZeroThreadCountDoesNotConfirmTermination(string state)
{
var snapshot = ParseSnapshot(state, "0");

Assert.IsTrue(snapshot.IsAliveFor(123, 987654L), "A zero thread count is an unconfirmed procfs observation, not proof of termination.");
}

[TestMethod]
[DataRow(124, 987654L)]
[DataRow(123, 987655L)]
public void LinuxSnapshotCannotKeepAReusedIdentityAlive(int expectedProcessId, long expectedStartStamp)
{
var snapshot = ParseSnapshot("S", "1");

Assert.IsFalse(snapshot.IsAliveFor(expectedProcessId, expectedStartStamp), "Both the process ID and kernel start token must still match the owned fixture identity.");
}

[TestMethod]
[DataRow("123 fixture) S 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 1 21 987654 23")]
[DataRow("123 (fixture S 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 1 21 987654 23")]
[DataRow("123 (fixture) S 4 5")]
public void IncompleteLinuxSnapshotStructureFailsLoudly(string stat)
{
Assert.ThrowsExactly<InvalidDataException>(() => FixtureProcessIdentity.ParseLinuxSnapshot(stat));
}

[TestMethod]
[DataRow("invalid", "S", "4", "1", "987654")]
[DataRow("0", "S", "4", "1", "987654")]
[DataRow("-1", "S", "4", "1", "987654")]
[DataRow("123", "Q", "4", "1", "987654")]
[DataRow("123", "SS", "4", "1", "987654")]
[DataRow("123", "S", "invalid", "1", "987654")]
[DataRow("123", "S", "-1", "1", "987654")]
[DataRow("123", "S", "4", "invalid", "987654")]
[DataRow("123", "S", "4", "-1", "987654")]
[DataRow("123", "S", "4", "1", "invalid")]
[DataRow("123", "S", "4", "1", "-1")]
public void InvalidLinuxSnapshotFieldsFailLoudly(string processId, string state, string parentProcessId, string threadCount, string startStamp)
{
var stat = BuildStat(processId, "fixture", state, parentProcessId, threadCount, startStamp);

Assert.ThrowsExactly<InvalidDataException>(() => FixtureProcessIdentity.ParseLinuxSnapshot(stat));
}

private static LinuxProcessSnapshot ParseSnapshot(string state, string threadCount)
{
return FixtureProcessIdentity.ParseLinuxSnapshot(BuildStat("123", "fixture", state, "4", threadCount, "987654"));
}

private static string BuildStat(string processId, string commandName, string state, string parentProcessId, string threadCount, string startStamp)
{
return processId + " (" + commandName + ") " + state + " " + parentProcessId +
" 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 " + threadCount + " 21 " + startStamp + " 23";
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,38 +7,84 @@ namespace Mammoth.LiteMapper.ProcessFixture
{
public static class FixtureProcessIdentity
{
public static LinuxProcessSnapshot ParseLinuxSnapshot(string stat)
{
// comm (field 2) can contain spaces and ')' characters. Fields after
// its final ')' begin at field 3; starttime is field 22 (index 19).
var commandStart = stat.IndexOf('(');
var commandEnd = stat.LastIndexOf(')');
if (commandStart <= 0 || commandEnd < commandStart)
{
throw new InvalidDataException("Fixture procfs record has no complete command name.");
}

var fields = stat.Substring(commandEnd + 1).Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
if (fields.Length < 20 ||
!int.TryParse(stat.Substring(0, commandStart).Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var processId) || processId <= 0 ||
fields[0].Length != 1 || "RSDZTtWXxKPI".IndexOf(fields[0][0]) < 0 ||
!int.TryParse(fields[1], NumberStyles.None, CultureInfo.InvariantCulture, out var parentProcessId) ||
!int.TryParse(fields[17], NumberStyles.None, CultureInfo.InvariantCulture, out var threadCount) ||
!long.TryParse(fields[19], NumberStyles.None, CultureInfo.InvariantCulture, out var startStamp))
{
throw new InvalidDataException("Fixture procfs record has invalid identity or lifecycle fields.");
}

return new LinuxProcessSnapshot(processId, parentProcessId, fields[0][0], threadCount, startStamp);
}

public static LinuxProcessSnapshot ReadLinuxSnapshot(int processId)
{
var statPath = processId == Environment.ProcessId ? "/proc/self/stat" :
"/proc/" + processId.ToString(CultureInfo.InvariantCulture) + "/stat";
return ParseLinuxSnapshot(File.ReadAllText(statPath));
}

public static long GetStartStamp(Process process)
{
// .NET's Linux StartTime uses a wall-clock boot-time estimate cached
// separately in each process. Compare the kernel token directly.
if (OperatingSystem.IsLinux())
{
var statPath = process.Id == Environment.ProcessId ? "/proc/self/stat" :
"/proc/" + process.Id.ToString(CultureInfo.InvariantCulture) + "/stat";
return ParseLinuxStartStamp(File.ReadAllText(statPath));
return ReadLinuxSnapshot(process.Id).StartStamp;
}

return process.StartTime.ToUniversalTime().Ticks;
}

public static long ParseLinuxStartStamp(string stat)
{
// comm (field 2) can contain spaces and ')' characters. Fields after
// its final ')' begin at field 3; starttime is field 22 (index 19).
var commandEnd = stat.LastIndexOf(')');
if (commandEnd < 0)
{
throw new InvalidDataException("Fixture procfs record has no complete command name.");
}
return ParseLinuxSnapshot(stat).StartStamp;
}
}

var fields = stat.Substring(commandEnd + 1).Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
if (fields.Length < 20 ||
!long.TryParse(fields[19], NumberStyles.None, CultureInfo.InvariantCulture, out var startStamp))
{
throw new InvalidDataException("Fixture procfs record has no valid kernel start token.");
}
public readonly struct LinuxProcessSnapshot
{
internal LinuxProcessSnapshot(int processId, int parentProcessId, char state, int threadCount, long startStamp)
{
ProcessId = processId;
ParentProcessId = parentProcessId;
State = state;
ThreadCount = threadCount;
StartStamp = startStamp;
}

return startStamp;
public int ProcessId { get; }
public int ParentProcessId { get; }
public char State { get; }
public int ThreadCount { get; }
public long StartStamp { get; }

public bool IsAliveFor(int expectedProcessId, long expectedStartStamp)
{
// A zombie group leader may still have executable sibling threads.
// Zero threads is an unconfirmed teardown snapshot, not a safe pass.
return ProcessId == expectedProcessId && StartStamp == expectedStartStamp &&
!((State == 'Z' || State == 'X') && ThreadCount == 1);
}

public override string ToString()
{
return FormattableString.Invariant($"PID {ProcessId} PPID {ParentProcessId} state {State} threads {ThreadCount} start {StartStamp}");
}
}
}
Loading
Loading