XFEstudio/XFEEDToolBox
Preserve flight stop causes during bounded jump loading
Keep health monitoring read-only during a matching jump after inputs have been released, within the existing 75-second deadline. Expired health samples still prohibit every input. Preserve typed monitoring failures and the first cancellation cause while keeping explicit manual stops authoritative. Persist health and stop diagnostics without delaying cancellation on disk I/O. Keep different target and generation acknowledgements distinct, and stop classifying observed route changes as user clicks. Validation: 1859 total regression tests, 1853 passed, 0 failed, 6 skipped; release candidate published with the same native support package. The candidate has not resumed round17: the earlier host discarded its cancellation source, and clarification of possible user cancellation is pending. Original 1200t cargo and confirmed receipts remain untouched.
1f7c213
代码差异
@@ -29,35 +29,67 @@ public interface IFlightJourneyDriver:IFlightDepartureDriver
}
/// <summary>One owner and cancellation scope for departure, jumps, local flight and docking, regardless of UI entry.</summary>
public sealed class FlightAutomationCoordinator(Func<PlayerState> player)
public sealed class FlightAutomationCoordinator(Func<PlayerState> player,Func<DateTimeOffset>? clock=null)
{
private readonly object gate=new();
private readonly Func<DateTimeOffset> time=clock??(()=>DateTimeOffset.UtcNow);
private CancellationTokenSource? active;
private FlightSafetyPauseException? protectionPause;
private Exception? monitoringPause;
private bool manualStop;
public NavigationRunner Interstellar{get;}=new(player);
private CancellationToken activeParent;
private string runId="";
private FlightAutomationStopRecord? lastStop;
public FlightAutomationStopRecord? LastStop{get{lock(gate)return lastStop;}}
public NavigationRunner Interstellar{get;}=new(player,clock);
public SystemNavigationRunner Local{get;}=new(player);
public FlightAutomationEntry? ActiveEntry{get;private set;}
public FlightAutomationStage Stage{get;private set;}
public bool IsActive{get{lock(gate)return active is not null;}}
public bool IsControllingFlight=>IsActive&&Stage is FlightAutomationStage.Departing or FlightAutomationStage.Interstellar or FlightAutomationStage.Local;
public event Action<FlightAutomationProgress>? Progress;
public event Action<FlightAutomationStopRecord>? StopRecorded;
public bool IsAwaitingInputFreeJump(PlayerState current,DateTimeOffset now)
{
lock(gate)if(active is null||active.IsCancellationRequested||activeParent.IsCancellationRequested||manualStop
||ActiveEntry is null||Stage!=FlightAutomationStage.Interstellar)return false;
return Interstellar.IsAwaitingInputFreeJump(current,now);
}
private FlightAutomationStopRecord RecordStopLocked(string source,Exception? cause,bool veto=false)
{
var now=time();
if(lastStop is null)
{
FlightStopSnapshot? snapshot=null;try{snapshot=FlightStopSnapshot.Capture(player());}catch{/* A diagnostic read cannot prevent cancellation. */}
lastStop=new(runId,source,now,ActiveEntry,Stage,activeParent.IsCancellationRequested,cause?.Message??(veto?"用户停止自动驾驶。":"上游已取消自动驾驶。"),cause?.GetType().FullName,snapshot)
{Cause=cause,ManualVeto=veto,ManualVetoAt=veto?now:null};
}
else if(veto&&!lastStop.ManualVeto||activeParent.IsCancellationRequested&&!lastStop.ParentTokenCancelled)
lastStop=lastStop with{ManualVeto=lastStop.ManualVeto||veto,ManualVetoAt=lastStop.ManualVetoAt??(veto?now:null),ParentTokenCancelled=lastStop.ParentTokenCancelled||activeParent.IsCancellationRequested};
return lastStop;
}
private void PublishStop(FlightAutomationStopRecord record)
{if(StopRecorded is {} handlers)foreach(Action<FlightAutomationStopRecord> handler in handlers.GetInvocationList())try{handler(record);}catch{/* Logging cannot alter cancellation authority. */}}
public void Stop()
{
CancellationTokenSource stop;FlightAutomationStopRecord record;
lock(gate)
{
if(active is null)return;
manualStop=true;protectionPause=null;active.Cancel();
manualStop=true;record=RecordStopLocked("manual-stop",null,veto:true);stop=active;
}
PublishStop(record);try{stop.Cancel();}catch(ObjectDisposedException){/* This exact run already finished. */}
}
public void PauseForSafety(FlightSafetyPauseException reason)
=>PauseForMonitoring(reason);
public void PauseForMonitoring(Exception cause)
{
ArgumentNullException.ThrowIfNull(reason);
ArgumentNullException.ThrowIfNull(cause);CancellationTokenSource stop;FlightAutomationStopRecord record;
lock(gate)
{
if(active is null||manualStop)return;
protectionPause??=reason;active.Cancel();
monitoringPause??=cause;record=RecordStopLocked("monitoring-pause",monitoringPause);stop=active;
}
PublishStop(record);try{stop.Cancel();}catch(ObjectDisposedException){/* This exact run already finished. */}
}
public Task<FlightAutomationResult> NavigateGalaxyAsync(string destination,IFlightControlDriver driver,IFlightDepartureDriver departure,CancellationToken token=default)
@@ -260,13 +292,20 @@ public sealed class FlightAutomationCoordinator(Func<PlayerState> player)
lock(gate)
{
if(active is not null)throw new InvalidOperationException("已有自动驾驶正在运行,请先停止当前航程。");
stop=CancellationTokenSource.CreateLinkedTokenSource(token);active=stop;ActiveEntry=entry;protectionPause=null;manualStop=false;
stop=CancellationTokenSource.CreateLinkedTokenSource(token);active=stop;activeParent=token;ActiveEntry=entry;Stage=FlightAutomationStage.Preparing;
monitoringPause=null;manualStop=false;lastStop=null;runId=Guid.NewGuid().ToString("N");
}
void Report(FlightAutomationStage stage,string message){Stage=stage;Progress?.Invoke(new(entry,stage,destination,message));}
var thisRun=runId;
using var parentRegistration=token.Register(()=>
{
FlightAutomationStopRecord? record=null;lock(gate)if(ReferenceEquals(active,stop)&&runId==thisRun)record=RecordStopLocked("parent-token",null);
if(record is not null)PublishStop(record);
});
void Report(FlightAutomationStage stage,string message){lock(gate)Stage=stage;Progress?.Invoke(new(entry,stage,destination,message));}
FlightAutomationResult CancellationResult()
{
lock(gate)
return !token.IsCancellationRequested&&!manualStop&&protectionPause is {} reason
return !token.IsCancellationRequested&&!manualStop&&monitoringPause is {} reason
?new(NavigationRunState.Failed,reason):new(NavigationRunState.Stopped);
}
PlayerState? initialState=null;
@@ -283,12 +322,27 @@ public sealed class FlightAutomationCoordinator(Func<PlayerState> player)
stop.Token.ThrowIfCancellationRequested();var initial=player();RequireSession(initial);initialState=initial;
Report(FlightAutomationStage.Preparing,"正在核对飞船、航程与自动驾驶状态。");
var result=await operation(initial,stop.Token,Report);
// Inner navigation runners release their inputs and consume their
// cancellation. Preserve only an explicitly requested safety pause.
if(result.State!=NavigationRunState.Completed&&stop.IsCancellationRequested)
if(result.State==NavigationRunState.Stopped&&lastStop is null)
{
FlightAutomationStopRecord? record=null;
lock(gate)
{
if(lastStop is null)
{
var runner=Stage==FlightAutomationStage.Interstellar?Interstellar.LastStop:null;
record=lastStop=runner is not null?runner with{RunId=runId,Entry=ActiveEntry,Stage=Stage,ParentTokenCancelled=token.IsCancellationRequested}
:RecordStopLocked("inner-runner-stopped",null);
}
}
if(record is not null)PublishStop(record);
}
// Inner runners consume cancellation after releasing their inputs.
// Preserve the first monitor cause; cleanup cannot replace it with
// a generic stopped result or a later transport error.
if(stop.IsCancellationRequested)
{
var cancelled=CancellationResult();
if(cancelled.Failure is null||result.State==NavigationRunState.Stopped&&result.Failure is null)result=cancelled;
result=cancelled;
}
MarkDeparture(result.Failure);
Report(result.State==NavigationRunState.Completed?FlightAutomationStage.Completed:result.State==NavigationRunState.Stopped?FlightAutomationStage.Stopped:FlightAutomationStage.Failed,
@@ -301,10 +355,13 @@ public sealed class FlightAutomationCoordinator(Func<PlayerState> player)
}
catch(Exception error)
{
if(stop.IsCancellationRequested&&CancellationResult().Failure is null)
{Report(FlightAutomationStage.Stopped,"自动驾驶已停止。");return new(NavigationRunState.Stopped);}
if(stop.IsCancellationRequested)
{
var cancelled=CancellationResult();MarkDeparture(cancelled.Failure);
Report(cancelled.Failure is null?FlightAutomationStage.Stopped:FlightAutomationStage.Failed,cancelled.Failure?.Message??"自动驾驶已停止。");return cancelled;
}
MarkDeparture(error);Report(FlightAutomationStage.Failed,error.Message);return new(NavigationRunState.Failed,error);
}
finally{lock(gate){active=null;ActiveEntry=null;protectionPause=null;manualStop=false;}stop.Dispose();}
finally{lock(gate){active=null;ActiveEntry=null;activeParent=default;monitoringPause=null;manualStop=false;}stop.Dispose();}
}
}
@@ -0,0 +1,47 @@
using System.Text.Json.Serialization;
using XFEEDToolBox.Core.Models;
namespace XFEEDToolBox.Core.Navigation;
public sealed record FlightStopSnapshot(string CommanderId,long? ShipId,DateTimeOffset? LoadGameAt,string GameMode,bool InGame,bool Docked,int CargoCount,
long? CurrentSystemAddress,long? SelectedSystemAddress,int RouteCount,long[] RoutePrefix,long? RouteDestination,bool JumpInProgress,
long? JumpTargetSystemAddress,string JumpTargetSystem,DateTimeOffset? JumpStartedAt,DateTimeOffset? LastJumpAt)
{
public static FlightStopSnapshot Capture(PlayerState state)=>new(state.CommanderId,state.CurrentShipId,state.LoadGameAt,state.GameMode,state.InGame,state.Docked,state.CargoCount,
state.Navigation.CurrentSystemAddress,state.Navigation.TargetSystemAddress,state.Navigation.Route.Count,state.Navigation.Route.Take(16).Select(point=>point.SystemAddress).ToArray(),
state.Navigation.Route.LastOrDefault()?.SystemAddress,state.Navigation.JumpInProgress,state.Navigation.JumpTargetSystemAddress,state.Navigation.JumpTargetSystem,
state.Navigation.JumpStartedAt,state.Navigation.LastJumpAt);
}
/// <summary>The first cause is immutable; explicit manual cancellation can add a veto without erasing that evidence.</summary>
public sealed record FlightAutomationStopRecord(string RunId,string Source,DateTimeOffset At,FlightAutomationEntry? Entry,FlightAutomationStage Stage,
bool ParentTokenCancelled,string Reason,string? CauseType,FlightStopSnapshot? Snapshot)
{
[JsonIgnore]public Exception? Cause{get;init;}
public bool ManualVeto{get;init;}
public DateTimeOffset? ManualVetoAt{get;init;}
}
/// <summary>Read-only proof that this runner already released inputs for this exact, bounded Journal jump.</summary>
public sealed record InputFreeJumpSnapshot(string CommanderId,long ShipId,DateTimeOffset LoadGameAt,string GameMode,long OriginSystemAddress,
long TargetSystemAddress,string TargetSystem,DateTimeOffset JumpStartedAt,DateTimeOffset WaitingSince,bool InputsReleased)
{
public static readonly TimeSpan MaximumWait=TimeSpan.FromSeconds(75);
public DateTimeOffset Deadline=>WaitingSince+MaximumWait;
public bool Matches(PlayerState current,DateTimeOffset now)=>InputsReleased&&now>=WaitingSince&&now<=Deadline
&¤t.InGame&&!current.Docked&¤t.CommanderId==CommanderId&¤t.CurrentShipId==ShipId&¤t.LoadGameAt==LoadGameAt&¤t.GameMode==GameMode
&&!NavigationSafety.UnhealthyHull(current)&&!NavigationSafety.HasExternalThreat(current)
&¤t.Navigation.CurrentSystemAddress==OriginSystemAddress&¤t.Navigation.JumpInProgress
&¤t.Navigation.JumpTargetSystemAddress==TargetSystemAddress&&string.Equals(current.Navigation.JumpTargetSystem,TargetSystem,StringComparison.OrdinalIgnoreCase)
&¤t.Navigation.JumpStartedAt==JumpStartedAt;
public static InputFreeJumpSnapshot? AfterSuccessfulRelease(PlayerState initial,PlayerState current,NavWaypoint actualTarget,
DateTimeOffset runJournalStartedAt,DateTimeOffset waitingSince)
{
if(string.IsNullOrWhiteSpace(initial.CommanderId)||initial.CurrentShipId is not >=0||initial.LoadGameAt is not {} loaded
||current.Navigation.CurrentSystemAddress is not >0||current.Navigation.JumpStartedAt is not {} started||started<runJournalStartedAt||started<loaded
||current.Navigation.JumpTargetSystemAddress!=actualTarget.SystemAddress||!string.Equals(current.Navigation.JumpTargetSystem,actualTarget.Name,StringComparison.OrdinalIgnoreCase))return null;
var snapshot=new InputFreeJumpSnapshot(initial.CommanderId,initial.CurrentShipId.Value,loaded,initial.GameMode,current.Navigation.CurrentSystemAddress.Value,
actualTarget.SystemAddress,actualTarget.Name,started,waitingSince,true);
return snapshot.Matches(current,waitingSince)?snapshot:null;
}
}
@@ -41,11 +41,34 @@ public sealed class NavigationRunner(Func<PlayerState> player,Func<DateTimeOffse
private readonly Func<DateTimeOffset> time=clock??(()=>DateTimeOffset.UtcNow);
private readonly object gate=new();
private CancellationTokenSource? cancellation;
private InputFreeJumpSnapshot? inputFreeJump;
private FlightAutomationStopRecord? lastStop;
private string stopRunId="";
private CancellationToken parentCancellation;
public FlightAutomationStopRecord? LastStop{get{lock(gate)return lastStop;}}
public event Action<FlightAutomationStopRecord>? StopRecorded;
public InputFreeJumpSnapshot? AwaitingInputFreeJump{get{lock(gate)return inputFreeJump;}}
public bool IsAwaitingInputFreeJump(PlayerState current,DateTimeOffset now)
{lock(gate)return cancellation is {IsCancellationRequested:false}&&inputFreeJump?.Matches(current,now)==true;}
public NavigationRunState State { get; private set; }
public Exception? Failure {get;private set;}
public string LastMessage{get;private set;}="";
public event Action<NavigationProgress>? Progress;
public void Stop(){lock(gate)cancellation?.Cancel();}
private FlightAutomationStopRecord CaptureStopLocked(string source,bool manual=false)
{
if(lastStop is not null)return lastStop;
FlightStopSnapshot? snapshot=null;try{snapshot=FlightStopSnapshot.Capture(player());}catch{}
return lastStop=new(stopRunId,source,time(),null,FlightAutomationStage.Interstellar,parentCancellation.IsCancellationRequested,
source=="native-player-override"?"原生飞行帧确认玩家接管。":manual?"显式停止星际导航。":"父级取消星际导航。",null,snapshot){ManualVeto=manual,ManualVetoAt=manual?time():null};
}
private void PublishStop(FlightAutomationStopRecord record)
{if(StopRecorded is {} handlers)foreach(Action<FlightAutomationStopRecord> handler in handlers.GetInvocationList())try{handler(record);}catch{}}
public void Stop()
{
CancellationTokenSource? stop;FlightAutomationStopRecord? record=null;
lock(gate){inputFreeJump=null;stop=cancellation;if(stop is not null)record=CaptureStopLocked("runner-stop",manual:true);}
if(record is not null)PublishStop(record);try{stop?.Cancel();}catch(ObjectDisposedException){}
}
public async Task RunAsync(string destination,IFlightControlDriver driver,CancellationToken token=default,IFlightJourneyFailureClassifier? failureClassifier=null)
{
Failure=null;
@@ -53,8 +76,14 @@ public sealed class NavigationRunner(Func<PlayerState> player,Func<DateTimeOffse
lock(gate)
{
if(cancellation is not null)throw new InvalidOperationException("自动导航已经在运行。");
stop=CancellationTokenSource.CreateLinkedTokenSource(token);cancellation=stop;
stop=CancellationTokenSource.CreateLinkedTokenSource(token);cancellation=stop;inputFreeJump=null;lastStop=null;stopRunId=Guid.NewGuid().ToString("N");parentCancellation=token;
}
using var clearJumpOnCancellation=stop.Token.Register(()=>
{
FlightAutomationStopRecord? record=null;
lock(gate)if(ReferenceEquals(cancellation,stop)){inputFreeJump=null;record=CaptureStopLocked("parent-token");}
if(record is not null)PublishStop(record);
});
NavigationProgress? terminal=null;
var lastNext="—";
void Report(NavigationRunState state,string next,string message,FlightDecision? decision=null)
@@ -138,13 +167,19 @@ public sealed class NavigationRunner(Func<PlayerState> player,Func<DateTimeOffse
while(true)
{
stop.Token.ThrowIfCancellationRequested();var state=player();
lock(gate)if(inputFreeJump is {} remembered&&!remembered.Matches(state,time()))inputFreeJump=null;
if(NavigationSafety.UnhealthyHull(state))throw new InvalidOperationException("船体已失效,已停止自动导航。");
if(!state.InGame)throw new InvalidOperationException("游戏会话结束,已停止导航。");
if(NavigationSafety.HasExternalThreat(state))throw new FlightThreatPauseException();
if(!NavigationRouteProgress.SameItinerary(route,state.Navigation))
{
if(!ExpectedFinalClear(state.Navigation))throw new InvalidOperationException("游戏航线已变更,已停止导航。");
if(finalClearSince is null){await driver.ReleaseAsync();finalClearSince=time();}
if(finalClearSince is null)
{
finalClearSince=jumpWaitSince??time();await driver.ReleaseAsync();stop.Token.ThrowIfCancellationRequested();
var released=player();var witness=InputFreeJumpSnapshot.AfterSuccessfulRelease(initial,released,finalWaypoint,journalStartedAt,finalClearSince.Value);
lock(gate)if(ReferenceEquals(cancellation,stop)&&!stop.IsCancellationRequested)inputFreeJump=witness;
}
if(!state.Navigation.JumpInProgress&&state.Navigation.CurrentSystemAddress==finalWaypoint.SystemAddress
&&state.Navigation.LastJumpAt>=state.Navigation.JumpStartedAt)
{
@@ -161,7 +196,12 @@ public sealed class NavigationRunner(Func<PlayerState> player,Func<DateTimeOffse
&&state.Navigation.JumpTargetSystem.Equals(remaining[0].Name,StringComparison.OrdinalIgnoreCase)
&&state.Navigation.JumpStartedAt is {} activeJump&&activeJump>=journalStartedAt&&activeJump<=GameJournalClock.Now(state,time()).AddSeconds(JournalFutureAllowanceSeconds))
{
if(jumpWaitSince is null){await driver.ReleaseAsync();jumpWaitSince=time();}
if(jumpWaitSince is null)
{
jumpWaitSince=time();await driver.ReleaseAsync();stop.Token.ThrowIfCancellationRequested();
var released=player();var witness=InputFreeJumpSnapshot.AfterSuccessfulRelease(initial,released,remaining[0],journalStartedAt,jumpWaitSince.Value);
lock(gate)if(ReferenceEquals(cancellation,stop)&&!stop.IsCancellationRequested)inputFreeJump=witness;
}
if(time()-jumpWaitSince>TimeSpan.FromSeconds(75))throw new InvalidOperationException("跃迁抵达事件超时,已释放自动控制。");
Report(NavigationRunState.Running,remaining[0].Name,"跃迁已开始,等待实际抵达;加载期间不发送飞行输入。");
await Task.Delay(100,stop.Token);continue;
@@ -209,7 +249,11 @@ public sealed class NavigationRunner(Func<PlayerState> player,Func<DateTimeOffse
{await PauseForTelemetry(remaining[0].Name,new FlightTelemetryUnavailableException("重新确认下一跳。",recovery:FlightRecovery.ReselectTarget));continue;}
// No enabled scoop cannot turn a measured shortfall into fuel.
// Return the typed task to the journey owner instead of waiting forever.
if(frame.PlayerOverride){Report(NavigationRunState.Stopped,remaining[0].Name,"玩家已接管,自动导航停止。");break;}
if(frame.PlayerOverride)
{
FlightAutomationStopRecord record;lock(gate){inputFreeJump=null;record=CaptureStopLocked("native-player-override",manual:true);}PublishStop(record);
Report(NavigationRunState.Stopped,remaining[0].Name,"玩家已接管,自动导航停止。");break;
}
if(frame.GeometryVerified&&now-frame.ObservedAt<=TimeSpan.FromMilliseconds(300)&&frame.ObservedAt<=now.AddMilliseconds(100)
&&NavigationFuelResupply.Required(frame) is {} shortage)throw shortage;
var decision=navigator.Decide(frame,remaining[0],observation.Thermal,now);
@@ -251,7 +295,7 @@ public sealed class NavigationRunner(Func<PlayerState> player,Func<DateTimeOffse
catch(Exception e){Failure=Failure is null?e:new AggregateException(Failure,e);terminal=new(NavigationRunState.Failed,destination,"—","释放自动控制失败:"+e.Message);}
finally
{
lock(gate){cancellation=null;}stop.Dispose();
lock(gate){inputFreeJump=null;cancellation=null;}stop.Dispose();
if(terminal is {} final){State=final.State;LastMessage=final.Message;Progress?.Invoke(final);}
}
}
@@ -72,6 +72,23 @@ public sealed class ToolboxRuntime : IAsyncDisposable
Spansh = new(Markets, http);
Runner = new(() => Player, (market, key) => Markets.Search(key, market, 0, 1000).FirstOrDefault(x => x.Quote.Commodity == key)?.Quote, Path.Combine(dataDirectory,"trade-ledgers"));
FlightAutomation=new(()=>Player);
FlightAutomation.StopRecorded+=record=>
{
// The in-memory cause exists before cancellation. Persist on the
// writer so disk I/O cannot delay an emergency input release.
writes.Writer.TryWrite(()=>
{
ReportDiagnostic("flight.stop:"+record.RunId,"驾驶停止",
record.ManualVeto?"已按停止请求释放自动驾驶。":record.Cause is {} cause?"驾驶暂停:"+cause.Message:"自动驾驶已停止。",
System.Text.Json.JsonSerializer.Serialize(new
{
stop=record with{Cause=null},causeType=record.Cause?.GetType().FullName,causeMessage=record.Cause?.Message
},ToolboxSettings.JsonOptions),record.ManualVeto?ActivitySeverity.Status:
record.Cause is {} failure&&XFEEDToolBox.Core.Navigation.NavigationRecovery.HasInvalidDataCause(failure)?ActivitySeverity.Error:
record.Cause is IOException?ActivitySeverity.Status:ActivitySeverity.Warning);
return Task.CompletedTask;
});
};
Runner.Progress += p => { Log("执行", p.Message); RunChanged?.Invoke(p); };
}
@@ -282,8 +299,9 @@ public sealed class ToolboxRuntime : IAsyncDisposable
var state=status.TryGetProperty("sample",out var sample)?Field(sample,"state"):"";
var stopped=status.TryGetProperty("lastOriginalStop",out var stop)?Field(stop,"observedAtUnixMilliseconds"):"";
var application=status.TryGetProperty("applicationControl",out var control)?control:default;
var diagnostic=status.TryGetProperty("diagnostic",out var details)?details:default;
var key=string.Join(':',Field(status,"processId"),Field(status,"service"),Field(status,"watchEntityId"),Field(status,"originalCommandSerial"),stopped,Field(status,"issue"),state,
Field(application,"detail"),Field(application,"policyReason"));
Field(application,"detail"),Field(application,"policyReason"),Field(status,"targetSystemAddress"),Field(diagnostic,"generation"));
lock(scoDiagnosticGate){if(key==lastScoDiagnostic)return;lastScoDiagnostic=key;}
var line=System.Text.Json.JsonSerializer.Serialize(new{at=DateTimeOffset.UtcNow,status=status.Clone()})+Environment.NewLine;
writes.Writer.TryWrite(()=>
@@ -0,0 +1,47 @@
using XFEEDToolBox.Core.Navigation;
namespace XFEEDToolBox.Core.Session;
/// <summary>Read-only monitoring decisions never extend a health sample or authorize flight input.</summary>
public sealed record NativeFlightHealthGapDecision(bool PauseMonitoring,bool SafetyStop,string Phase,string Issue,
DateTimeOffset? UnavailableSince,TimeSpan UnavailableFor,bool ExpectedInputFreeJumpLoading);
public sealed record NativeFlightProtectionDiagnostic(DateTimeOffset At,int ProcessId,long StartedUtcTicks,string GameMode,
string Phase,string Issue,long? LastHealthSequence,ulong? LastHealthEntity,DateTimeOffset? LastHealthAt,double? LastHealthAgeMilliseconds,
DateTimeOffset? UnavailableSince,double UnavailableMilliseconds,bool ExpectedInputFreeJumpLoading,bool MonitoringOnly,
string? ExceptionType,string? ExceptionMessage);
/// <summary>The caller supplies positive, released-input jump evidence with its original 75-second deadline.</summary>
public sealed class NativeFlightHealthGapPolicy
{
public static readonly TimeSpan InputFreshness=TimeSpan.FromMilliseconds(500);
public static readonly TimeSpan OrdinaryGapLimit=TimeSpan.FromSeconds(30);
// Defence in depth if a caller ever supplies an incorrect always-true
// loading predicate. The caller's original jump deadline remains stricter.
public static readonly TimeSpan MaximumReadOnlyGap=TimeSpan.FromSeconds(75);
private long unavailableUtcTicks;
public DateTimeOffset? UnavailableSince
{get{var ticks=Interlocked.Read(ref unavailableUtcTicks);return ticks>0?new DateTimeOffset(ticks,TimeSpan.Zero):null;}}
public void Recovered()=>Interlocked.Exchange(ref unavailableUtcTicks,0);
public static bool InputSampleFresh(DateTimeOffset? sampledAt,DateTimeOffset now)
=>sampledAt is {} at&&at<=now&&now-at<=InputFreshness;
public static bool RecoverableGap(Exception error,CancellationToken token)
{
if(token.IsCancellationRequested||NavigationRecovery.HasInvalidDataCause(error)||FlightSafetyPauseException.Present(error))return false;
if(error is AggregateException aggregate)return aggregate.InnerExceptions.Count>0&&aggregate.InnerExceptions.All(inner=>RecoverableGap(inner,token));
if(error is FlightTelemetryUnavailableException or IOException or TimeoutException or OperationCanceledException)
return error.InnerException is not {} inner||RecoverableGap(inner,token);
return error is InvalidOperationException&&error.Message is "Native main-ship health unavailable or stale" or "Native player telemetry unavailable or stale";
}
public NativeFlightHealthGapDecision Observe(Exception error,DateTimeOffset now,bool expectedInputFreeJumpLoading,CancellationToken token=default)
{
token.ThrowIfCancellationRequested();Interlocked.CompareExchange(ref unavailableUtcTicks,now.UtcTicks,0);
var elapsed=now-UnavailableSince.Value;
if(FlightSafetyPauseException.Present(error))return new(false,true,"safety-stop",error.Message,UnavailableSince,elapsed,false);
if(elapsed<TimeSpan.Zero||!RecoverableGap(error,token))return new(true,false,"monitoring-paused",error.Message,UnavailableSince,elapsed,false);
if(elapsed<OrdinaryGapLimit)return new(false,false,"health-gap",error.Message,UnavailableSince,elapsed,expectedInputFreeJumpLoading);
if(expectedInputFreeJumpLoading&&elapsed<MaximumReadOnlyGap)
return new(false,false,"input-free-jump-loading",error.Message,UnavailableSince,elapsed,true);
return new(true,false,"monitoring-paused",error.Message,UnavailableSince,elapsed,expectedInputFreeJumpLoading);
}
}
@@ -19,6 +19,13 @@ public sealed class NativeFlightTestProtection:IAsyncDisposable
private readonly bool automaticExit;
private readonly bool exitForFailedHandoff;
private readonly Action? externalProtectionCheck;
private readonly Func<bool>? expectedInputFreeJumpLoading;
private readonly Action<Exception>? pauseAutomation;
private readonly Action<NativeFlightProtectionDiagnostic>? diagnostics;
private readonly NativeFlightHealthGapPolicy healthGap=new();
private readonly object diagnosticGate=new();
private string? lastDiagnosticPhase;
private int monitoringPauseReported;
private readonly IGameSessionDriver sessionDriver;
private bool independentHealth;
private Task? polling;
@@ -26,17 +33,51 @@ public sealed class NativeFlightTestProtection:IAsyncDisposable
private int damagePause;
private long lastHealthTicks,lastSequence;
private ulong entity;
public ShipHealthSample? LastHealth{get;private set;}
private ShipHealthSample? lastHealth;
public ShipHealthSample? LastHealth=>Volatile.Read(ref lastHealth);
public string? LastIssue{get;private set;}
public bool Stopped=>Volatile.Read(ref unsafeState)!=0||Volatile.Read(ref damagePause)!=0;
public bool Fresh=>!Stopped&&Volatile.Read(ref damagePause)==0&&!monitor.TestingBlocked&&DateTimeOffset.UtcNow.UtcTicks-Interlocked.Read(ref lastHealthTicks)<=TimeSpan.FromMilliseconds(500).Ticks;
private NativeFlightTestProtection(int pid,GameSessionTarget target,IGameSessionDriver sessions,Func<PlayerState> player,Action stopAutomation,ShipProtectionMonitor? sharedMonitor,bool automaticExit,bool exitForFailedHandoff,Action? externalProtectionCheck)
private bool HealthSampleFresh(DateTimeOffset now)
{
var ticks=Interlocked.Read(ref lastHealthTicks);
return NativeFlightHealthGapPolicy.InputSampleFresh(ticks>0?new DateTimeOffset(ticks,TimeSpan.Zero):null,now);
}
public bool Fresh=>!Stopped&&Volatile.Read(ref damagePause)==0&&!monitor.TestingBlocked&&HealthSampleFresh(DateTimeOffset.UtcNow);
private NativeFlightTestProtection(int pid,GameSessionTarget target,IGameSessionDriver sessions,Func<PlayerState> player,Action stopAutomation,ShipProtectionMonitor? sharedMonitor,bool automaticExit,bool exitForFailedHandoff,Action? externalProtectionCheck,
Func<bool>? expectedInputFreeJumpLoading,Action<Exception>? pauseAutomation,Action<NativeFlightProtectionDiagnostic>? diagnostics)
{
pipe=new(pid);this.target=target;this.player=player;this.stopAutomation=stopAutomation;sessionDriver=sessions;this.automaticExit=automaticExit;this.exitForFailedHandoff=exitForFailedHandoff;this.externalProtectionCheck=externalProtectionCheck;
this.expectedInputFreeJumpLoading=expectedInputFreeJumpLoading;this.pauseAutomation=pauseAutomation;this.diagnostics=diagnostics;
ownsMonitor=sharedMonitor is null;
monitor=sharedMonitor??new(sessions,player,StopAutomation,automaticExit);
}
private void StopAutomation(){try{stopAutomation();}catch{/* A stopped worker cannot disarm health monitoring. */}}
private void Diagnose(string phase,string issue,Exception? error=null,bool expectedLoading=false)
{
lock(diagnosticGate){if(lastDiagnosticPhase==phase)return;lastDiagnosticPhase=phase;}
if(diagnostics is null)return;
try
{
var now=DateTimeOffset.UtcNow;var health=LastHealth;var unavailable=healthGap.UnavailableSince;
diagnostics(new(now,target.ProcessId,target.StartedUtcTicks,player().GameMode,phase,issue,
health?.Sequence,health?.EntityId,health?.At,health is null?null:(now-health.At).TotalMilliseconds,
unavailable,unavailable is {} began?(now-began).TotalMilliseconds:0,expectedLoading,
phase is "health-gap" or "input-free-jump-loading",error?.GetType().FullName,error?.Message));
}
catch{/* Evidence delivery cannot grant input or prevent a required pause. */}
}
private void StopAutomation()
{
Diagnose("safety-stop",monitor.LastTrigger?.Message??LastIssue??"原飞船安全保护停止自动控制。");
try{stopAutomation();}catch{/* A stopped worker cannot disarm health monitoring. */}
}
private void PauseMonitoring(Exception error,bool expectedLoading=false)
{
LastIssue=error.Message;Interlocked.Exchange(ref unsafeState,1);
if(Interlocked.Exchange(ref monitoringPauseReported,1)!=0)return;
Diagnose("monitoring-paused",error.Message,error,expectedLoading);
try{if(pauseAutomation is not null)pauseAutomation(error);else stopAutomation();}
catch{try{stopAutomation();}catch{/* The protected state is already latched. */}}
}
public static async Task ExitForFailedHandoffAsync(int pid)=>await HandleFailedHandoffAsync(pid);
public static async Task<bool> HandleFailedHandoffAsync(int pid)
{
@@ -51,7 +92,8 @@ public sealed class NativeFlightTestProtection:IAsyncDisposable
await new GameSessionController(protection.sessionDriver,()=>false).EmergencyExitAsync(protection.target,CancellationToken.None);
return true;
}
public static async Task<NativeFlightTestProtection> ArmAsync(int pid,string hash,IGameSessionDriver sessions,Func<PlayerState> player,Action stopAutomation,CancellationToken token=default,ShipProtectionMonitor? sharedMonitor=null,bool automaticExit=true,bool exitForFailedHandoff=true,Action? externalProtectionCheck=null)
public static async Task<NativeFlightTestProtection> ArmAsync(int pid,string hash,IGameSessionDriver sessions,Func<PlayerState> player,Action stopAutomation,CancellationToken token=default,ShipProtectionMonitor? sharedMonitor=null,bool automaticExit=true,bool exitForFailedHandoff=true,Action? externalProtectionCheck=null,
Func<bool>? expectedInputFreeJumpLoading=null,Action<Exception>? pauseAutomation=null,Action<NativeFlightProtectionDiagnostic>? diagnostics=null)
{
if(Volatile.Read(ref active) is not null)throw new InvalidOperationException("已有飞行测试保护正在运行。");
var pipe=new NavigationPipeClient(pid);var hello=await pipe.CallAsync(new{op="describe"},token);
@@ -64,7 +106,8 @@ public sealed class NativeFlightTestProtection:IAsyncDisposable
if(sharedMonitor is not null&&sharedMonitor.BoundTarget!=target)throw new InvalidOperationException("连续血量监测与受保护游戏实例不一致。");
if(sharedMonitor is not null&&sharedMonitor.AutomaticExitEnabled!=automaticExit)throw new InvalidOperationException("连续血量监测的退出模式不一致。");
externalProtectionCheck?.Invoke();
var protection=new NativeFlightTestProtection(pid,target,sessions,player,stopAutomation,sharedMonitor,automaticExit,exitForFailedHandoff,externalProtectionCheck);
var protection=new NativeFlightTestProtection(pid,target,sessions,player,stopAutomation,sharedMonitor,automaticExit,exitForFailedHandoff,externalProtectionCheck,
expectedInputFreeJumpLoading,pauseAutomation,diagnostics);
protection.independentHealth=hello.TryGetProperty("independentShipHealth",out var independent)&&independent.ValueKind==JsonValueKind.True;
try
{
@@ -88,7 +131,7 @@ public sealed class NativeFlightTestProtection:IAsyncDisposable
throw new InvalidOperationException("飞船保护已停止测试,禁止继续发送飞行输入。");
if(protection.monitor.TestingBlocked)throw new FlightHealthPauseException();
protection.externalProtectionCheck?.Invoke();
if(DateTimeOffset.UtcNow.UtcTicks-Interlocked.Read(ref protection.lastHealthTicks)>TimeSpan.FromMilliseconds(500).Ticks)
if(!protection.HealthSampleFresh(DateTimeOffset.UtcNow))
throw new FlightTelemetryUnavailableException("正在恢复连续血量监测,恢复后继续驾驶。");
}
public void ObserveJournal(ObservedJournalEvent observed)=>monitor.Observe(observed,player());
@@ -118,12 +161,11 @@ public sealed class NativeFlightTestProtection:IAsyncDisposable
// Multiple IPC reads can observe one physics frame. Retain its actual
// timestamp; a duplicate neither refreshes the input lease nor stops protection.
if(sample.Sequence==lastSequence)return sample;
entity=sample.EntityId;lastSequence=sample.Sequence;LastHealth=sample;Interlocked.Exchange(ref lastHealthTicks,sample.At.UtcTicks);
entity=sample.EntityId;lastSequence=sample.Sequence;Volatile.Write(ref lastHealth,sample);Interlocked.Exchange(ref lastHealthTicks,sample.At.UtcTicks);
return sample;
}
private async Task PollAsync()
{
DateTimeOffset? unavailableSince=null;
try
{
while(!stop.IsCancellationRequested)
@@ -132,15 +174,18 @@ public sealed class NativeFlightTestProtection:IAsyncDisposable
try
{
var health=await ReadAsync(stop.Token);monitor.Observe(health,DateTimeOffset.UtcNow);
unavailableSince=null;if(!Stopped)LastIssue=null;
if(healthGap.UnavailableSince is not null)
Diagnose(Stopped?"health-restored-awaiting-rearm":"health-recovered","已重新读取实际新鲜船体与护盾样本。");
healthGap.Recovered();if(!Stopped)LastIssue=null;
}
catch(Exception error)when(error is IOException or InvalidOperationException or OperationCanceledException&& !stop.IsCancellationRequested)
catch(Exception error)when(!stop.IsCancellationRequested)
{
LastIssue=error.Message;unavailableSince??=DateTimeOffset.UtcNow;
// Input is already barred as soon as the last real sample
// expires. Brief IPC/frame stalls may recover without disarming.
if(DateTimeOffset.UtcNow-unavailableSince>=TimeSpan.FromSeconds(30)&&!Stopped)
{Interlocked.Exchange(ref unsafeState,1);StopAutomation();}
LastIssue=error.Message;
var expectedLoading=NativeFlightHealthGapPolicy.RecoverableGap(error,stop.Token)&&expectedInputFreeJumpLoading?.Invoke()==true;
var decision=healthGap.Observe(error,DateTimeOffset.UtcNow,expectedLoading,stop.Token);
if(decision.SafetyStop){Interlocked.Exchange(ref unsafeState,1);StopAutomation();}
else if(decision.PauseMonitoring)PauseMonitoring(error,expectedLoading);
else Diagnose(decision.Phase,decision.Issue,error,expectedLoading);
}
if(monitor.EmergencyActive){Interlocked.Exchange(ref unsafeState,1);StopAutomation();break;}
if(monitor.TestingBlocked)
@@ -153,7 +198,7 @@ public sealed class NativeFlightTestProtection:IAsyncDisposable
}
}
catch(OperationCanceledException)when(stop.IsCancellationRequested){}
catch(Exception error){LastIssue=error.Message;Interlocked.Exchange(ref unsafeState,1);StopAutomation();}
catch(Exception error){PauseMonitoring(error);}
}
public async ValueTask DisposeAsync()
{
@@ -25,6 +25,9 @@ public sealed class ShipProtectionMonitor(IGameSessionDriver driver,Func<PlayerS
public bool ExitOnInterdiction{get;init;}=true;
public bool ContinuousHealthReady=>nativeProtection?.Fresh==true;
public bool AutomaticExitEnabled=>automaticExit;
public Func<bool>? ExpectedInputFreeJumpLoading{get;set;}
public Action<Exception>? PauseAutomationForMonitoring{get;set;}
public Action<NativeFlightProtectionDiagnostic>? NativeProtectionDiagnostics{get;set;}
public event Action<ShipProtectionProgress>? Progress;
public event Action<SessionReentryEvent>? Reentry;
public bool TestingBlocked{get{lock(gate)return guard.TestingBlocked;}}
@@ -53,7 +56,8 @@ public sealed class ShipProtectionMonitor(IGameSessionDriver driver,Func<PlayerS
RequireHealthyShip();var expected=BoundTarget??throw new InvalidOperationException("飞船保护尚未绑定游戏实例。");
if(nativeProtection is {Stopped:false})return;
if(nativeProtection is {} previous){nativeProtection=null;await previous.DisposeAsync();}
nativeProtection=await NativeFlightTestProtection.ArmAsync(expected.ProcessId,gameHash,driver,player,StopAutomation,token,this,automaticExit,exitForFailedHandoff);
nativeProtection=await NativeFlightTestProtection.ArmAsync(expected.ProcessId,gameHash,driver,player,StopAutomation,token,this,automaticExit,exitForFailedHandoff,
expectedInputFreeJumpLoading:ExpectedInputFreeJumpLoading,pauseAutomation:PauseAutomationForMonitoring,diagnostics:NativeProtectionDiagnostics);
Report(false,"连续保护中 · 被拦截立即大退;受损后破盾或低血量立即大退");
}
finally{nativeGate.Release();}
@@ -35,6 +35,7 @@ public sealed class FlightAutomationCoordinatorTests
internal bool Arrive=true,AutoLand=true;
internal bool ManualOverride;
internal Exception? LocalInspectionFailure,ClassifiedFailure;
internal Exception? GalaxyReleaseFailure;
internal int Classifications;
public Task<Exception> ClassifyFailureAsync(Exception failure,CancellationToken token)
{token.ThrowIfCancellationRequested();Calls.Add("classify");Classifications++;return Task.FromResult(ClassifiedFailure??failure);}
@@ -64,7 +65,7 @@ public sealed class FlightAutomationCoordinatorTests
public virtual Task SelectTargetAsync(NavWaypoint next,CancellationToken token)=>throw new InvalidOperationException("Unexpected live jump input in fixture");
public virtual Task<FlightObservation> ReadAsync(CancellationToken token)=>throw new InvalidOperationException("Unexpected live flight read in fixture");
public Task ApplyAsync(FlightDecision decision,long sequence,NavWaypoint next,DateTimeOffset expires,CancellationToken token)=>throw new InvalidOperationException("Unexpected live jump input in fixture");
public Task ReleaseAsync(){Calls.Add("release-galaxy");return Task.CompletedTask;}
public virtual Task ReleaseAsync(){Calls.Add("release-galaxy");if(GalaxyReleaseFailure is {} failure)throw failure;return Task.CompletedTask;}
public Task<SystemNavigationCapabilities> InspectAsync(CancellationToken token){Calls.Add("inspect");OnInspect?.Invoke();token.ThrowIfCancellationRequested();if(LocalInspectionFailure is {} error)throw error;return Task.FromResult(new SystemNavigationCapabilities(Build,true,true,true,true,true,true,"isolated arrival fixture"));}
private SystemNavigationTarget? target;
public Task SelectAsync(SystemNavigationTarget selected,CancellationToken token){Calls.Add("select");target=selected;return Task.CompletedTask;}
@@ -455,4 +456,85 @@ public sealed class FlightAutomationCoordinatorTests
var driver=new Journey{AutoLand=false};var result=await new FlightAutomationCoordinator(()=>driver.Player).TravelToStationAsync(Destination,driver);
Check.Equal(NavigationRunState.Failed,result.State);Check.That(!driver.Player.Docked&&result.Failure!.Message.Contains("实际停靠"));
}
[Test]public async Task MonitoringCancellationPreservesTheOriginalInvalidDataSafetyOrTransportCauseAndRecordsBeforeCancel()
{
foreach(var cause in new Exception[]{new InvalidDataException("identity observation unavailable"),new FlightHealthPauseException(),new IOException("health observation transport")})
{
var driver=new Journey();var flights=new FlightAutomationCoordinator(()=>driver.Player);CancellationToken activeToken=default;var beforeCancel=false;
flights.StopRecorded+=record=>{if(record.Source=="monitoring-pause")beforeCancel=!activeToken.IsCancellationRequested;};
driver.Departure=token=>{activeToken=token;flights.PauseForMonitoring(cause);token.ThrowIfCancellationRequested();return Task.CompletedTask;};
var result=await flights.TravelToStationAsync(Destination,driver);Check.Equal(NavigationRunState.Failed,result.State);Check.That(ReferenceEquals(cause,result.Failure)&&beforeCancel);
var diagnostic=flights.LastStop!;Check.Equal("monitoring-pause",diagnostic.Source);Check.That(!diagnostic.ManualVeto&&!diagnostic.ParentTokenCancelled&&ReferenceEquals(cause,diagnostic.Cause));
Check.Equal(FlightAutomationEntry.Trade,diagnostic.Entry!.Value);Check.Equal(FlightAutomationStage.Departing,diagnostic.Stage);Check.Equal(7L,diagnostic.Snapshot!.ShipId!.Value);
Check.That(!flights.IsActive&&flights.LastStop==diagnostic);
}
}
[Test]public async Task ManualVetoKeepsTheFirstMonitoringEvidenceButNeverEntersRecovery()
{
var driver=new Journey();var flights=new FlightAutomationCoordinator(()=>driver.Player);var first=new InvalidDataException("first monitoring failure");
driver.Departure=token=>{flights.PauseForMonitoring(first);flights.PauseForMonitoring(new IOException("later cleanup"));flights.Stop();token.ThrowIfCancellationRequested();return Task.CompletedTask;};
var result=await flights.TravelToStationAsync(Destination,driver);Check.Equal(NavigationRunState.Stopped,result.State);Check.That(result.Failure is null);
var record=flights.LastStop!;Check.Equal("monitoring-pause",record.Source);Check.That(record.ManualVeto&&record.ManualVetoAt is not null&&ReferenceEquals(first,record.Cause));
}
[Test]public async Task AMonitoringCauseCannotBeReplacedByTheDriversLaterReleaseFailure()
{
var driver=new Journey{GalaxyReleaseFailure=new IOException("cleanup release failed")};var flights=new FlightAutomationCoordinator(()=>driver.Player);var first=new InvalidDataException("first source loss");
driver.Departure=token=>{flights.PauseForMonitoring(first);token.ThrowIfCancellationRequested();return Task.CompletedTask;};
var result=await flights.TravelToStationAsync(Destination,driver);Check.Equal(NavigationRunState.Failed,result.State);Check.That(ReferenceEquals(first,result.Failure)&&ReferenceEquals(first,flights.LastStop!.Cause));
}
[Test]public async Task SeparateJourneysCannotInheritAnotherRunsStopCauseAndEachCompletedStopRemainsQueryable()
{
var driver=new Journey();var flights=new FlightAutomationCoordinator(()=>driver.Player);var first=new IOException("first run");var second=new InvalidDataException("second run");
driver.Departure=token=>{flights.PauseForMonitoring(first);token.ThrowIfCancellationRequested();return Task.CompletedTask;};
Check.That(ReferenceEquals(first,(await flights.TravelToStationAsync(Destination,driver)).Failure));var firstRecord=flights.LastStop!;
driver.Departure=token=>{flights.PauseForMonitoring(second);token.ThrowIfCancellationRequested();return Task.CompletedTask;};
Check.That(ReferenceEquals(second,(await flights.TravelToStationAsync(Destination,driver)).Failure));var secondRecord=flights.LastStop!;
Check.That(firstRecord.RunId!=secondRecord.RunId&&ReferenceEquals(first,firstRecord.Cause)&&ReferenceEquals(second,secondRecord.Cause));
flights.PauseForMonitoring(new IOException("after completion"));Check.That(ReferenceEquals(secondRecord,flights.LastStop));
}
[Test]public async Task AParentCancellationVetoDoesNotEraseTheRecordedMonitoringCause()
{
using var parent=new CancellationTokenSource();var driver=new Journey();var flights=new FlightAutomationCoordinator(()=>driver.Player);var first=new IOException("monitor before parent");
driver.Departure=token=>{flights.PauseForMonitoring(first);parent.Cancel();token.ThrowIfCancellationRequested();return Task.CompletedTask;};
var result=await flights.TravelToStationAsync(Destination,driver,parent.Token);Check.Equal(NavigationRunState.Stopped,result.State);Check.That(result.Failure is null);
Check.That(flights.LastStop!.ParentTokenCancelled&&ReferenceEquals(first,flights.LastStop.Cause));
}
private sealed class LoadingJourney(Func<DateTimeOffset> time,bool failRelease=false):Journey
{
internal readonly TaskCompletionSource ReleaseEntered=new(TaskCreationOptions.RunContinuationsAsynchronously);
internal readonly TaskCompletionSource AllowRelease=new(TaskCreationOptions.RunContinuationsAsynchronously);
private int releaseCount;
public override Task<FlightDriverCapabilities> PreflightAsync(CancellationToken token)
{
Arrive=false;Player=Player with{Navigation=Player.Navigation with{JumpInProgress=true,JumpTargetSystemAddress=2,JumpTargetSystem="System 2",JumpStartedAt=time()}};
return base.PreflightAsync(token);
}
public override async Task ReleaseAsync()
{
Calls.Add("release-galaxy");if(Interlocked.Increment(ref releaseCount)!=1)return;
ReleaseEntered.TrySetResult();await AllowRelease.Task;if(failRelease)throw new IOException("original release was not acknowledged");
}
}
[Test]public async Task InputFreeLoadingOnlyQualifiesAfterSuccessfulReleaseAndForTheExactJumpWithinItsOriginalDeadline()
{
var now=DateTimeOffset.UtcNow;var driver=new LoadingJourney(()=>now);var flights=new FlightAutomationCoordinator(()=>driver.Player,()=>now);
var waiting=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
flights.Interstellar.Progress+=p=>{if(p.Message.Contains("跃迁已开始"))waiting.TrySetResult();};
var run=flights.TravelToStationAsync(Destination,driver);await driver.ReleaseEntered.Task.WaitAsync(TimeSpan.FromSeconds(2));
Check.That(!flights.IsAwaitingInputFreeJump(driver.Player,now));driver.AllowRelease.SetResult();await waiting.Task.WaitAsync(TimeSpan.FromSeconds(2));
Check.That(flights.IsAwaitingInputFreeJump(driver.Player,now));Check.That(flights.IsAwaitingInputFreeJump(driver.Player,now.AddSeconds(75)));
Check.That(!flights.IsAwaitingInputFreeJump(driver.Player,now.AddSeconds(75).AddMilliseconds(1)));
foreach(var changed in new[]{driver.Player with{CommanderId="other"},driver.Player with{CurrentShipId=8},driver.Player with{LoadGameAt=now},
driver.Player with{Navigation=driver.Player.Navigation with{JumpInProgress=false}},driver.Player with{Navigation=driver.Player.Navigation with{JumpTargetSystemAddress=3}},
driver.Player with{Navigation=driver.Player.Navigation with{JumpStartedAt=now.AddSeconds(1)}}})
Check.That(!flights.IsAwaitingInputFreeJump(changed,now));
flights.Stop();Check.That(!flights.IsAwaitingInputFreeJump(driver.Player,now));Check.Equal(NavigationRunState.Stopped,(await run.WaitAsync(TimeSpan.FromSeconds(2))).State);
}
[Test]public async Task AFailedOriginalReleaseNeverAuthorizesTheLoadingException()
{
var now=DateTimeOffset.UtcNow;var driver=new LoadingJourney(()=>now,failRelease:true);var flights=new FlightAutomationCoordinator(()=>driver.Player,()=>now);
var run=flights.TravelToStationAsync(Destination,driver);await driver.ReleaseEntered.Task.WaitAsync(TimeSpan.FromSeconds(2));
Check.That(!flights.IsAwaitingInputFreeJump(driver.Player,now));driver.AllowRelease.SetResult();var result=await run.WaitAsync(TimeSpan.FromSeconds(2));
Check.Equal(NavigationRunState.Failed,result.State);Check.That(!flights.IsAwaitingInputFreeJump(driver.Player,now)&&flights.Interstellar.AwaitingInputFreeJump is null);
}
}
@@ -0,0 +1,30 @@
using System.Text.Json;
using XFEEDToolBox.Core.Services;
using XFEExtension.NetCore.XUnit.Attributes;
namespace XFEEDToolBox.Test;
[TestFixture]
public sealed class FlightDiagnosticPersistenceTests
{
[Test]public async Task IdenticalAcknowledgementsDeduplicateButDifferentTargetsAndGenerationsPersist()
{
using var temporary=new TemporaryDirectory();
await using(var runtime=new ToolboxRuntime(temporary.Path))
{
await runtime.InitializeAsync(offline:true);
static JsonElement Ack(long target,long generation)=>JsonSerializer.SerializeToElement(new
{
processId=42,service="InterstellarTargetSelection",issue="acknowledged",targetSystemAddress=target,
sample=new{state="acknowledged"},diagnostic=new{generation,selected=true}
});
foreach(var row in new[]{Ack(1,5),Ack(1,5),Ack(2,5),Ack(2,5),Ack(2,6),Ack(2,6)})runtime.RecordScoDiagnostic(row);
}
var rows=File.ReadAllLines(Path.Combine(temporary.Path,"logs","sco-diagnostics.jsonl"))
.Select(line=>{using var document=JsonDocument.Parse(line);return document.RootElement.GetProperty("status").Clone();}).ToArray();
Check.Equal(3,rows.Length);
Check.Equal(1L,rows[0].GetProperty("targetSystemAddress").GetInt64());
Check.Equal(2L,rows[1].GetProperty("targetSystemAddress").GetInt64());
Check.Equal(6L,rows[2].GetProperty("diagnostic").GetProperty("generation").GetInt64());
}
}
@@ -0,0 +1,75 @@
using XFEEDToolBox.Core.Navigation;
using XFEEDToolBox.Core.Session;
using XFEExtension.NetCore.XUnit.Attributes;
namespace XFEEDToolBox.Test;
[TestFixture]
public sealed class NativeFlightHealthGapPolicyTests
{
private static readonly DateTimeOffset Now=DateTimeOffset.Parse("2026-10-27T07:41:46+08:00");
[Test]public void AReleasedInputJumpMayOutlastThirtySecondsButNeverExceedsItsBoundedReadOnlyWindow()
{
var gap=new NativeFlightHealthGapPolicy();var error=new InvalidOperationException("Native main-ship health unavailable or stale");
Check.That(!gap.Observe(error,Now,false).PauseMonitoring);
Check.Equal("health-gap",gap.Observe(error,Now.AddSeconds(29),true).Phase);
var loading=gap.Observe(error,Now.AddSeconds(30),true);
Check.That(!loading.PauseMonitoring&&!loading.SafetyStop&&loading.ExpectedInputFreeJumpLoading);
Check.Equal("input-free-jump-loading",loading.Phase);Check.Equal(Now,loading.UnavailableSince!.Value);
Check.That(!gap.Observe(error,Now.AddMilliseconds(74999),true).PauseMonitoring);
Check.That(gap.Observe(error,Now.AddSeconds(75),true).PauseMonitoring);
}
[Test]public void MissingReleaseEvidenceAndTheOriginalJumpDeadlineNeverBorrowTheLoadingExemption()
{
var gap=new NativeFlightHealthGapPolicy();var error=new IOException("health transport");
gap.Observe(error,Now,false);
Check.That(gap.Observe(error,Now.AddSeconds(30),false).PauseMonitoring);
var witnessed=new NativeFlightHealthGapPolicy();witnessed.Observe(error,Now,true);
Check.That(!witnessed.Observe(error,Now.AddSeconds(31),true).PauseMonitoring);
// The coordinator withdraws its witness at the original jump's 75s,
// independent of this gap's later start or any intermediate polling.
Check.That(witnessed.Observe(error,Now.AddSeconds(60),false).PauseMonitoring);
}
[Test]public void UnknownIdentityAndWrappedProtocolErrorsNeverBecomeExpectedLoading()
{
foreach(var error in new Exception[]{new InvalidDataException("wrong ship"),new InvalidOperationException("Another controller owns navigation"),
new IOException("transport wrapper",new InvalidDataException("wrong build")),new IOException("unknown inner",new InvalidOperationException("unverified interface")),
new AggregateException(new IOException("pipe"),new InvalidDataException("reordered entity")),new FormatException("bad native value")})
{
Check.That(!NativeFlightHealthGapPolicy.RecoverableGap(error,default));
var result=new NativeFlightHealthGapPolicy().Observe(error,Now,true);
Check.That(result.PauseMonitoring&&!result.SafetyStop&&!result.ExpectedInputFreeJumpLoading);
}
}
[Test]public void OnlyKnownMissingHealthAndTransportErrorsReceiveReadOnlyGrace()
{
foreach(var error in new Exception[]{new FlightTelemetryUnavailableException("stale health"),new IOException("broken pipe"),new TimeoutException("read timeout"),
new OperationCanceledException("IPC read deadline"),new InvalidOperationException("Native player telemetry unavailable or stale"),
new AggregateException(new IOException("pipe"),new FlightTelemetryUnavailableException("stale"))})
Check.That(NativeFlightHealthGapPolicy.RecoverableGap(error,default));
using var stop=new CancellationTokenSource();stop.Cancel();
Check.That(!NativeFlightHealthGapPolicy.RecoverableGap(new IOException("pipe"),stop.Token));
var cancelled=false;try{new NativeFlightHealthGapPolicy().Observe(new IOException("pipe"),Now,true,stop.Token);}catch(OperationCanceledException){cancelled=true;}
Check.That(cancelled);
}
[Test]public void ALoadingWitnessNeverRenewsHealthInputAuthorityAndRecoveryNeedsAnActualNewSample()
{
var gap=new NativeFlightHealthGapPolicy();var sampled=Now;var error=new FlightTelemetryUnavailableException("stale");
Check.That(NativeFlightHealthGapPolicy.InputSampleFresh(sampled,Now.AddMilliseconds(500)));
Check.That(!NativeFlightHealthGapPolicy.InputSampleFresh(sampled,Now.AddMilliseconds(501)));
gap.Observe(error,Now,true);Check.That(!gap.Observe(error,Now.AddSeconds(31),true).PauseMonitoring);
Check.That(!NativeFlightHealthGapPolicy.InputSampleFresh(sampled,Now.AddSeconds(31)));
gap.Recovered();Check.That(gap.UnavailableSince is null);
Check.That(!NativeFlightHealthGapPolicy.InputSampleFresh(sampled,Now.AddSeconds(32)));
Check.That(NativeFlightHealthGapPolicy.InputSampleFresh(Now.AddSeconds(32),Now.AddSeconds(32)));
Check.That(!NativeFlightHealthGapPolicy.InputSampleFresh(Now.AddSeconds(33),Now.AddSeconds(32)));
}
[Test]public void RealHealthAndThreatProtectionPreemptsAnyLoadingWitness()
{
foreach(var error in new Exception[]{new FlightHealthPauseException(),new FlightThreatPauseException(),new AggregateException(new IOException("pipe"),new FlightHealthPauseException())})
{
var result=new NativeFlightHealthGapPolicy().Observe(error,Now,true);
Check.That(result.SafetyStop&&!result.PauseMonitoring&&!result.ExpectedInputFreeJumpLoading);Check.Equal("safety-stop",result.Phase);
}
}
}
@@ -336,6 +336,67 @@ public class NativeFlightTestProtectionTests
}
finally{stop.Cancel();await server;}
}
[Test]public async Task LoadingWitnessStillBarsExpiredInputAndRecoveryRequiresAnActualFreshHealthRead()
{
var pid=Random.Shared.Next(380000000,390000000);using var stop=new CancellationTokenSource(TimeSpan.FromSeconds(6));
var sessions=new Sessions();sessions.Target=sessions.Target with{ProcessId=pid};var hash=new string('a',64);var at=DateTimeOffset.UtcNow;var resume=0;var pauses=0;
var phases=new System.Collections.Concurrent.ConcurrentQueue<string>();
var player=new PlayerState{CommanderId="F1",InGame=true,GameMode="Solo",CurrentShipId=7,LoadGameAt=at.AddMinutes(-1),Ships=[new(){ShipId=7,HullHealth=1,HullHealthAt=at,ConfigurationAt=at}]};
var server=ServeHealth(pid,hash,_=>99,stop.Token,independent:true,observedAt:()=>Volatile.Read(ref resume)==0?at:DateTimeOffset.UtcNow);
try
{
await using var protection=await NativeFlightTestProtection.ArmAsync(pid,hash,sessions,()=>player,()=>{},stop.Token,automaticExit:false,
expectedInputFreeJumpLoading:()=>true,pauseAutomation:_=>Interlocked.Increment(ref pauses),diagnostics:diagnostic=>phases.Enqueue(diagnostic.Phase));
await Task.Delay(650,stop.Token);Check.That(!protection.Fresh&&!protection.Stopped);Check.Equal(0,pauses);
Check.Throws<FlightTelemetryUnavailableException>(()=>NativeFlightTestProtection.CheckBeforeInput(pid));
Interlocked.Exchange(ref resume,1);while(!protection.Fresh)await Task.Delay(20,stop.Token);
NativeFlightTestProtection.CheckBeforeInput(pid);
Check.Equal(1,phases.Count(phase=>phase=="health-gap"));Check.Equal(1,phases.Count(phase=>phase=="health-recovered"));
Check.Equal(0,pauses);Check.That(!sessions.Calls.Contains("exit"));
}
finally{stop.Cancel();await server;}
}
[Test]public async Task UnknownHealthFailurePersistsItsActualCauseBeforePausingInsteadOfCallingManualStop()
{
var pid=Random.Shared.Next(390000000,400000000);using var stop=new CancellationTokenSource(TimeSpan.FromSeconds(5));
var sessions=new Sessions();sessions.Target=sessions.Target with{ProcessId=pid};var hash=new string('a',64);var at=DateTimeOffset.UtcNow;var manualStops=0;
var evidence=new System.Collections.Concurrent.ConcurrentQueue<NativeFlightProtectionDiagnostic>();
var paused=new TaskCompletionSource<Exception>(TaskCreationOptions.RunContinuationsAsynchronously);
var player=new PlayerState{CommanderId="F1",InGame=true,GameMode="Solo",CurrentShipId=7,LoadGameAt=at.AddMinutes(-1),Ships=[new(){ShipId=7,HullHealth=1,HullHealthAt=at,ConfigurationAt=at}]};
var server=ServeHealth(pid,hash,_=>99,stop.Token,independent:true,healthFailure:sequence=>sequence>1?"Unknown original health source":null);
try
{
await using var protection=await NativeFlightTestProtection.ArmAsync(pid,hash,sessions,()=>player,()=>Interlocked.Increment(ref manualStops),stop.Token,automaticExit:false,
expectedInputFreeJumpLoading:()=>true,pauseAutomation:error=>
{Check.That(evidence.Any(item=>item.Phase=="monitoring-paused"));paused.TrySetResult(error);},diagnostics:evidence.Enqueue);
var reason=await paused.Task.WaitAsync(stop.Token);Check.That(reason is InvalidOperationException);Check.Equal("Unknown original health source",reason.Message);
await Task.Delay(160,stop.Token);
var diagnostic=evidence.Single(item=>item.Phase=="monitoring-paused");
Check.Equal(pid,diagnostic.ProcessId);Check.Equal(sessions.Target.StartedUtcTicks,diagnostic.StartedUtcTicks);Check.Equal("Solo",diagnostic.GameMode);
Check.Equal(1L,diagnostic.LastHealthSequence!.Value);Check.Equal(99UL,diagnostic.LastHealthEntity!.Value);
Check.Equal(reason.Message,diagnostic.ExceptionMessage!);Check.That(diagnostic.UnavailableSince is not null);
Check.That(protection.Stopped&&!protection.Fresh);Check.Equal(0,manualStops);Check.That(!sessions.Calls.Contains("exit"));
}
finally{stop.Cancel();await server;}
}
[Test]public async Task ActualDamageUsesTheExistingSafetyStopEvenWithAReleasedJumpLoadingWitness()
{
var pid=Random.Shared.Next(400000000,410000000);using var stop=new CancellationTokenSource(TimeSpan.FromSeconds(5));
var sessions=new Sessions();sessions.Target=sessions.Target with{ProcessId=pid};var hash=new string('a',64);var at=DateTimeOffset.UtcNow;var safetyStops=0;var monitoringPauses=0;
var phases=new System.Collections.Concurrent.ConcurrentQueue<string>();
var player=new PlayerState{CommanderId="F1",InGame=true,GameMode="Solo",CurrentShipId=7,LoadGameAt=at.AddMinutes(-1),StatusAt=at,
Status=new((1L<<24)|8,0,1,1,0,null,null,null,null,[]),Ships=[new(){ShipId=7,HullHealth=1,HullHealthAt=at,ConfigurationAt=at}]};
var server=ServeHealth(pid,hash,_=>99,stop.Token,hull:sequence=>sequence<4?1:.49,independent:true);
try
{
await using var protection=await NativeFlightTestProtection.ArmAsync(pid,hash,sessions,()=>player,()=>
{Check.That(phases.Contains("safety-stop"));Interlocked.Increment(ref safetyStops);},stop.Token,automaticExit:false,
expectedInputFreeJumpLoading:()=>true,pauseAutomation:_=>Interlocked.Increment(ref monitoringPauses),diagnostics:diagnostic=>phases.Enqueue(diagnostic.Phase));
while(Volatile.Read(ref safetyStops)==0)await Task.Delay(20,stop.Token);
Check.That(protection.Stopped&&!protection.Fresh);Check.Equal(0,monitoringPauses);Check.That(!sessions.Calls.Contains("exit"));
}
finally{stop.Cancel();await server;}
}
[Test] public async Task ProductionMonitorSharesNativeFeedAndDisposalReleasesTheInputInterlock()
{
var pid=Random.Shared.Next(210000000,220000000);using var stop=new CancellationTokenSource(TimeSpan.FromSeconds(5));var sessions=new Sessions();sessions.Target=sessions.Target with{ProcessId=pid};
@@ -120,14 +120,14 @@ public partial class ToolboxViewModel
{
if(mirrored.Cleared)
{
if(NavigationActive)StopNavigation();
if(NavigationActive)StopForObservedRouteChange("游戏航线已清空,已释放原导航输入。");
navigationItinerary.Reset();navigationDisplayRoute=[];LastConfirmedGameRoute=null;
synchronizingGameRoute=true;try{NavigationDestination="";}finally{synchronizingGameRoute=false;}
NavigationStatus="游戏航线已取消。";NavigationStatusSeverity=InfoBarSeverity.Informational;
}
else if(mirrored.Destination is {} gameDestination)
{
if(mirrored.Replaced&&NavigationActive)StopNavigation();
if(mirrored.Replaced&&NavigationActive)StopForObservedRouteChange("游戏航线终点已改变,已释放原导航输入。");
synchronizingGameRoute=true;try{NavigationDestination=gameDestination.Name;}finally{synchronizingGameRoute=false;}
var system=new GalacticSystem(gameDestination.Name,gameDestination.Position,gameDestination.SystemAddress,gameDestination.StarClass,"游戏航线");
galaxyCache[system.Name]=system;
@@ -358,6 +358,11 @@ public partial class ToolboxViewModel
private int navigationUserStopVersion;
internal int NavigationUserStopVersion=>Volatile.Read(ref navigationUserStopVersion);
private void StopOwnedNavigation(){navigationStartStop?.Cancel();runtime.FlightAutomation.Stop();runtime.Navigator.Stop();}
private void StopForObservedRouteChange(string reason)
{
runtime.FlightAutomation.PauseForMonitoring(new InvalidOperationException(reason));
runtime.Navigator.Stop();
}
internal void StopNavigationForScoAcceptance()
{
if(!Environment.GetCommandLineArgs().Contains("--accept-sco"))throw new InvalidOperationException("SCO QA stop requires its explicit launch entry.");