fix(launcher): Campaign LA LA3 narrow review fixes
This commit is contained in:
parent
26feba8186
commit
347a1a5d16
9 changed files with 495 additions and 145 deletions
|
|
@ -1,3 +1,4 @@
|
|||
using System.Collections.Concurrent;
|
||||
using System.Threading;
|
||||
using AcDream.Launcher.Core.Launching;
|
||||
|
||||
|
|
@ -235,6 +236,95 @@ public sealed class LauncherProcessSupervisorTests
|
|||
states);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentRunningAndExitedPublicationsRemainMonotonicAndInOrder()
|
||||
{
|
||||
var factory = new FakeChildProcessFactory(exitsWithinStopTimeout: true);
|
||||
using var supervisor = new LauncherProcessSupervisor(factory);
|
||||
using var runningPublicationEntered = new ManualResetEventSlim(false);
|
||||
using var releaseRunningPublication = new ManualResetEventSlim(false);
|
||||
var states = new ConcurrentQueue<LauncherSessionState>();
|
||||
|
||||
supervisor.StateChanged += (_, state) =>
|
||||
{
|
||||
if (state == LauncherSessionState.Running)
|
||||
{
|
||||
runningPublicationEntered.Set();
|
||||
Assert.True(
|
||||
releaseRunningPublication.Wait(TimeSpan.FromSeconds(5)),
|
||||
"test did not release the Running publication barrier");
|
||||
}
|
||||
|
||||
states.Enqueue(state);
|
||||
};
|
||||
|
||||
Task startTask = Task.Run(() => supervisor.Start(Spec(), "pw"));
|
||||
try
|
||||
{
|
||||
Assert.True(
|
||||
runningPublicationEntered.Wait(TimeSpan.FromSeconds(5)),
|
||||
"Running publication did not reach the test barrier");
|
||||
|
||||
// Commit Exited while Running's observer is deliberately
|
||||
// paused. Storage reaches the terminal state immediately, but
|
||||
// publication must queue behind the earlier Running event.
|
||||
factory.LastCreated!.ExitForTest(17);
|
||||
Assert.Equal(LauncherSessionState.Exited, supervisor.State);
|
||||
}
|
||||
finally
|
||||
{
|
||||
releaseRunningPublication.Set();
|
||||
}
|
||||
|
||||
await startTask.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(
|
||||
[
|
||||
LauncherSessionState.Starting,
|
||||
LauncherSessionState.Running,
|
||||
LauncherSessionState.Exited,
|
||||
],
|
||||
states);
|
||||
Assert.Equal(17, supervisor.ExitCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StateChangedPublicationAllowsCrossThreadReadsAndReentrantExit()
|
||||
{
|
||||
var factory = new FakeChildProcessFactory(exitsWithinStopTimeout: true);
|
||||
using var supervisor = new LauncherProcessSupervisor(factory);
|
||||
var states = new List<LauncherSessionState>();
|
||||
|
||||
supervisor.StateChanged += (_, state) =>
|
||||
{
|
||||
states.Add(state);
|
||||
if (state != LauncherSessionState.Running)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// A publisher that invokes callbacks while holding the state
|
||||
// gate deadlocks this cross-thread read. The callback also
|
||||
// raises Exited re-entrantly; it must queue after Running rather
|
||||
// than recurse out of order or deadlock.
|
||||
Task<LauncherSessionState> readTask = Task.Run(() => supervisor.State);
|
||||
Assert.True(readTask.Wait(TimeSpan.FromSeconds(5)));
|
||||
Assert.Equal(LauncherSessionState.Running, readTask.Result);
|
||||
factory.LastCreated!.ExitForTest(23);
|
||||
};
|
||||
|
||||
supervisor.Start(Spec(), "pw");
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
LauncherSessionState.Starting,
|
||||
LauncherSessionState.Running,
|
||||
LauncherSessionState.Exited,
|
||||
],
|
||||
states);
|
||||
Assert.Equal(LauncherSessionState.Exited, supervisor.State);
|
||||
Assert.Equal(23, supervisor.ExitCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LauncherProcessSpecCarriesNoCredentialLikeMember()
|
||||
{
|
||||
|
|
@ -358,12 +448,22 @@ public sealed class LauncherProcessSupervisorTests
|
|||
{
|
||||
// Simulates a child that dies synchronously from inside
|
||||
// Process.Start() itself (review finding F9's race).
|
||||
HasExited = true;
|
||||
ExitCode = 0;
|
||||
Exited?.Invoke(this, EventArgs.Empty);
|
||||
ExitForTest(0);
|
||||
}
|
||||
}
|
||||
|
||||
public void ExitForTest(int exitCode)
|
||||
{
|
||||
if (HasExited)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
HasExited = true;
|
||||
ExitCode = exitCode;
|
||||
Exited?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
public bool TryRequestGracefulStop()
|
||||
{
|
||||
TryRequestGracefulStopCallCount++;
|
||||
|
|
@ -382,9 +482,7 @@ public sealed class LauncherProcessSupervisorTests
|
|||
{
|
||||
KillCallCount++;
|
||||
CallOrder.Add("kill");
|
||||
HasExited = true;
|
||||
ExitCode = -1;
|
||||
Exited?.Invoke(this, EventArgs.Empty);
|
||||
ExitForTest(-1);
|
||||
}
|
||||
|
||||
public bool WaitForExit(TimeSpan timeout)
|
||||
|
|
@ -392,9 +490,7 @@ public sealed class LauncherProcessSupervisorTests
|
|||
if (!exitsWithinStopTimeout)
|
||||
return false;
|
||||
|
||||
HasExited = true;
|
||||
ExitCode = 0;
|
||||
Exited?.Invoke(this, EventArgs.Empty);
|
||||
ExitForTest(0);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
using System.Threading;
|
||||
using AcDream.Launcher.Core.Profiles;
|
||||
|
||||
namespace AcDream.Launcher.Core.Tests.Profiles;
|
||||
|
|
@ -287,68 +286,42 @@ public sealed class LauncherProfileStoreTests : IDisposable
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveNeverLeavesTheTempFileWorldOrGroupReadableDuringTheWrite()
|
||||
public void TempCredentialCreationOptionsRequestAtomicPlatformCorrectCreation()
|
||||
{
|
||||
// Review finding F4: the temp file used to be created with the
|
||||
// process's default umask and only chmod'd AFTER the atomic
|
||||
// rename, leaving a window where the plaintext-credential temp
|
||||
// file could be world/group-readable. The fix chmods the temp
|
||||
// file immediately after creation, BEFORE any content (including
|
||||
// the password) is serialized into it. A large document makes
|
||||
// the write take long enough for a concurrent poller to have a
|
||||
// real chance at observing a regression.
|
||||
FileStreamOptions options =
|
||||
LauncherProfileStore.CreateCredentialTempFileOptions();
|
||||
Assert.Equal(FileMode.CreateNew, options.Mode);
|
||||
Assert.Equal(FileAccess.Write, options.Access);
|
||||
Assert.Equal(FileShare.None, options.Share);
|
||||
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
Assert.Equal(
|
||||
LauncherProfileStore.OwnerOnlyFileMode,
|
||||
options.UnixCreateMode);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Null(options.UnixCreateMode);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TempCredentialFileIsOwnerOnlyFromItsFirstObservableLinuxState()
|
||||
{
|
||||
// Deterministic proof of the exact production create path: inspect
|
||||
// the file while the CreateNew handle is still open, before any
|
||||
// serialization or post-create chmod can occur. This replaces the
|
||||
// old timing-only poller, which could miss the vulnerable window.
|
||||
if (!OperatingSystem.IsLinux())
|
||||
return;
|
||||
|
||||
var store = new LauncherProfileStore(_filePath);
|
||||
store.Load();
|
||||
store.AddServer("Local ACE", "127.0.0.1", 9000);
|
||||
for (int i = 0; i < 300; i++)
|
||||
{
|
||||
store.AddAccount("Local ACE", $"account{i}", new string('x', 4096));
|
||||
}
|
||||
|
||||
string tempPath = _filePath + ".tmp";
|
||||
bool observedLooseMode = false;
|
||||
bool stop = false;
|
||||
var poller = new Thread(() =>
|
||||
{
|
||||
while (!Volatile.Read(ref stop))
|
||||
{
|
||||
if (File.Exists(tempPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
// The platform-compat analyzer can't see the
|
||||
// enclosing test method's `OperatingSystem.IsLinux()`
|
||||
// guard across this lambda boundary; suppressed
|
||||
// rather than restructured, since the guard is
|
||||
// real and this whole method is a no-op off Linux.
|
||||
#pragma warning disable CA1416
|
||||
UnixFileMode mode = File.GetUnixFileMode(tempPath);
|
||||
#pragma warning restore CA1416
|
||||
if ((mode & ~(UnixFileMode.UserRead | UnixFileMode.UserWrite)) != 0)
|
||||
{
|
||||
observedLooseMode = true;
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Renamed/deleted between the Exists check and
|
||||
// GetUnixFileMode — not a finding, just keep
|
||||
// polling.
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
poller.Start();
|
||||
using FileStream stream = LauncherProfileStore.CreateCredentialTempFile(tempPath);
|
||||
|
||||
store.Save();
|
||||
|
||||
Volatile.Write(ref stop, true);
|
||||
poller.Join();
|
||||
|
||||
Assert.False(observedLooseMode);
|
||||
Assert.Equal(
|
||||
LauncherProfileStore.OwnerOnlyFileMode,
|
||||
File.GetUnixFileMode(tempPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -112,6 +112,19 @@ public sealed class StatusEventParserTests
|
|||
Assert.IsType<UnknownStatusEvent>(e);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("[]")]
|
||||
[InlineData("null")]
|
||||
[InlineData("42")]
|
||||
[InlineData("\"text\"")]
|
||||
public void CompleteJsonWithANonObjectRootSurfacesAsMalformedEvent(string line)
|
||||
{
|
||||
var e = StatusEventParser.Parse(line);
|
||||
|
||||
var malformed = Assert.IsType<MalformedStatusEvent>(e);
|
||||
Assert.Contains("root", malformed.Error, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
|
|
@ -136,6 +149,33 @@ public sealed class StatusEventParserTests
|
|||
Assert.IsType<UnknownStatusEvent>(e);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("{\"e\":\"started\",\"t\":\"2026-08-14T12:00:00Z\",\"sessionId\":\"s1\"}")]
|
||||
[InlineData("{\"v\":\"1\",\"e\":\"connected\",\"t\":\"2026-08-14T12:00:00Z\",\"sessionId\":\"s1\"}")]
|
||||
[InlineData("{\"v\":2,\"e\":\"started\",\"t\":\"2026-08-14T12:00:00Z\",\"sessionId\":\"s1\"}")]
|
||||
[InlineData("{\"v\":1,\"e\":\"connected\",\"sessionId\":\"s1\"}")]
|
||||
[InlineData("{\"v\":1,\"e\":\"started\",\"t\":42,\"sessionId\":\"s1\"}")]
|
||||
[InlineData("{\"v\":1,\"e\":\"connected\",\"t\":\"not-a-time\",\"sessionId\":\"s1\"}")]
|
||||
[InlineData("{\"v\":1,\"e\":\"started\",\"t\":\"2026-08-14T12:00:00+02:00\",\"sessionId\":\"s1\"}")]
|
||||
[InlineData("{\"v\":1,\"e\":\"connected\",\"t\":\"2026-08-14T12:00:00Z\"}")]
|
||||
[InlineData("{\"v\":1,\"e\":\"started\",\"t\":\"2026-08-14T12:00:00Z\",\"sessionId\":42}")]
|
||||
[InlineData("{\"v\":1,\"e\":\"connected\",\"t\":\"2026-08-14T12:00:00Z\",\"sessionId\":\"\"}")]
|
||||
public void PayloadFreeKnownEventsRequireTheFullPinnedV1Envelope(string line)
|
||||
{
|
||||
var e = StatusEventParser.Parse(line);
|
||||
|
||||
var malformed = Assert.IsType<MalformedStatusEvent>(e);
|
||||
Assert.False(string.IsNullOrWhiteSpace(malformed.Error));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("{\"v\":1,\"t\":\"2026-08-14T12:00:00Z\",\"sessionId\":\"s1\"}")]
|
||||
[InlineData("{\"v\":1,\"e\":42,\"t\":\"2026-08-14T12:00:00Z\",\"sessionId\":\"s1\"}")]
|
||||
public void MissingOrWrongKindEventNameSurfacesAsMalformedEvent(string line)
|
||||
{
|
||||
Assert.IsType<MalformedStatusEvent>(StatusEventParser.Parse(line));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KnownEValueWithMissingRequiredFieldSurfacesAsMalformedEventRatherThanThrowing()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -61,6 +61,21 @@ public sealed class StatusFileTailerTests : IDisposable
|
|||
Assert.IsType<ConnectedStatusEvent>(events[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContinuesPastCompleteNonObjectJsonValuesToTheFollowingValidLine()
|
||||
{
|
||||
AppendShared(
|
||||
"[]\nnull\n42\n\"text\"\n"
|
||||
+ Line("connected", "s1"));
|
||||
var tailer = new StatusFileTailer(_path);
|
||||
|
||||
IReadOnlyList<StatusEvent> events = tailer.ReadNewEvents();
|
||||
|
||||
Assert.Equal(5, events.Count);
|
||||
Assert.All(events.Take(4), e => Assert.IsType<MalformedStatusEvent>(e));
|
||||
Assert.IsType<ConnectedStatusEvent>(events[4]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TolerateAPartialLastLineAndCompletesItOnALaterPoll()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue